React Testing Library
React Testing Library是基于DOM Testing Library构建的,它提供了一些用于处理React components的api。(如果使用Create React App创建的项目,那么它已经支持使用React Testing Library编写测试代码)
问题
如果你希望为你的WEB UI 编写可维护的测试。为了实现这个目标,你希望测试可以避开组件的具体实现细节,而是更关注于其是否能保证实现你期望的功能。另一方面,测试库应该是长期可维护的状态,在改变应用的实现方式不改变功能的情况下(也就是代码重构),不需要重新编写测试,拖慢项目进度。
解决
React Testing Library是测试React components的非常轻量级的解决方案。它提供的主要功能是类似于用户在页面上查找元素的方式查找DOM节点。通过这种测试方式,可以让你确保Web UI是否能正常工作。React Testing Library的主要指导原则是:
你的测试越像你的软件使用的方式,测试就越能给你带来信心
你可以通过Label
查找表单元素,通过Text
查找链接和按钮,以及其它类似的查找方式。同时它还提供data-testid用于查找内容或标签没有意义或不实际的元素(我的理解是类似按钮是一个图标的情况,无法直接描述)
这个库是Enzyme的替代品。你可以使用Enzyme遵循上面的规则进行测试,但是因为Enzyme提供了很多额外的对于应用实现细节测试的功能,所以强行使用它会增加测试编写的难度。
不具备的部分
- 测试运行程序或框架
- 特定于某个测试框架
React Testing Library 常见测试场景
Rendering a component
下面是待测试组件:
import React from 'react';
const title = 'Hello React';
function App() {
return <div>{title}</div>;
}
export default App;
在测试中可以通过render渲染一个组件,然后在后面的测试中便可以访问该组件
import React from 'react';
import { render } from '@testing-library/react';
import App from './App';
describe('App', () => {
test('renders App component', () => {
render(<App />);
});
});
可以通过screen.debug()
查看渲染出来的HTML DOM树是什么样的,在写测试代码前,先通过debug
查看当前页面中可见的元素,再开始查询元素,这会有助于编写测试代码.
import React from 'react';
import { render, screen } from '@testing-library/react';
import App from './App';
describe('App', () => {
test('renders App component', () => {
render(<App />);
screen.debug();
});
});
<body>
<div>
<div>
Hello React
</div>
</div>
</body>
下面是使用了React的一些特性(useState,event handler,props,component)以后,生成的HTML DOM树.可以看出React Testing Library 不关心真实的组件的编写方式,最后渲染出来的还是普通的HTML DOM树.所以我们在测试的时候,也只需要针对渲染出来的HTML DOM树进行测试即可.
import React from 'react';
function App() {
const [search, setSearch] = React.useState('');
function handleChange(event) {
setSearch(event.target.value);
}
return (
<div> <Search value={search} onChange={handleChange}> Search: </Search> <p>Searches for {search ? search : '...'}</p> </div>
);
}
function Search({ value, onChange, children }) {
return (
<div> <label htmlFor="search">{children}</label> <input id="search" type="text" value={value} onChange={onChange} /> </div>
);
}
export default App;
<body>
<div>
<div>
<div>
<label for="search" >
Search:
</label>
<input id="search" type="text" value="" />
</div>
<p>
Searches for
...
</p>
</div>
</div>
</body>
React Testing库用于像用户一样与React组件进行交互。用户看到的只是从React组件渲染的HTML,因此这就是为什么将此HTML结构视为输出而不是两个单独的React组件的原因。
Selecting elements
在渲染完React组件以后,React Testing Library
为你提供了多种不同的搜索方法用来获取元素.获取到的元素便可以用来进行后面的断言或者用户交互操作.下面来看看如何使用它们:
import React from 'react';
import { render, screen } from '@testing-library/react';
import App from './App';
describe('App', () => {
test('renders App component', () => {
render(<App />);
expect(screen.getByText('Search:')).toBeInTheDocument();
});
});
如果你不是很清楚组件渲染后的HTML DOM树,建议你先使用debug
查看树结构.然后再通过screen
对象的搜索方法查找你需要的元素.
通常,如果没有找到元素,getByText
会抛出错误,这样的错误提示会有助于让你知道,在你执行下一步的操作前,你没有正确的获取到你想要的元素.有的人也会使用此抛出错误的特性做隐式类型判断,但并不推荐这么用
import React from 'react';
import { render, screen } from '@testing-library/react';
import App from './App';
describe('App', () => {
test('renders App component', () => {
render(<App />);
// 隐式类型判断
// because getByText would throw error
// if element wouldn't be there
screen.getByText('Search:');
// 显式类型判断
// recommended
expect(screen.getByText('Search:')).toBeInTheDocument();
});
});
getByText不仅可以接受字符串作为查询条件,也可以接受正则表达式.字符串参数用于完全匹配,而正则表达式用于部分匹配,在某些情况下这样会更加方便和灵活.
import React from 'react';
import { render, screen } from '@testing-library/react';
import App from './App';
describe('App', () => {
test('renders App component', () => {
render(<App />);
// fails
expect(screen.getByText('Search')).toBeInTheDocument();
// succeeds
expect(screen.getByText('Search:')).toBeInTheDocument();
// succeeds
expect(screen.getByText(/Search/)).toBeInTheDocument();
});
});
当然getByText
只是众多搜索方法中的一种,其他的搜索方法,以及方法的优先及请参考后文应该使用哪个查询
Search variants
除了查询函数以外,还存在查询变体queryBy
findBy
.具体的方法如下:
- queryByText
- queryByRole
- queryByLabelText
- queryByPlaceholderText
- queryByAltText
- queryByDisplayValue
- findByText
- findByRole
- findByLabelText
- findByPlaceholderText
- findByAltText
- findByDisplayValue
getBy和queryBy的不同
在使用时最大的疑问通常是: 什么使用应该使用getBy,什么时候该使用其他的两个变体queryBy
findBy
如果要判断一个元素不存在,并进行断言.这时候如果使用getBy
就会导致测试报错.使用queryBy
便能正常的进行.
import React from 'react';
import { render, screen } from '@testing-library/react';
import App from './App';
describe('App', () => {
test('renders App component', () => {
render(<App />);
screen.debug();
// fails
expect(screen.getByText(/Searches for JavaScript/)).toBeNull();
});
});
import React from 'react';
import { render, screen } from '@testing-library/react';
import App from './App';
describe('App', () => {
test('renders App component', () => {
render(<App />);
expect(screen.queryByText(/Searches for JavaScript/)).toBeNull();
});
});
findBy
通常用于异步元素.在下面的例子中,在初始化的渲染后,组件会远程获取user的数据信息,获取到数据以后重新渲染组件,条件渲染部分就会渲染出Signed in as
.
function getUser() {
return Promise.resolve({ id: '1', name: 'Robin' });
}
function App() {
const [search, setSearch] = React.useState('');
const [user, setUser] = React.useState(null);
React.useEffect(() => {
const loadUser = async () => {
const user = await getUser();
setUser(user);
};
loadUser();
}, []);
function handleChange(event) {
setSearch(event.target.value);
}
return (
<div> {user ? <p>Signed in as {user.name}</p> : null} <Search value={search} onChange={handleChange}> Search: </Search> <p>Searches for {search ? search : '...'}</p> </div>
);
}
如果我们想测试异步获取数据前后页面的变化,就可以使用findBy等待我们要更新的元素,不需要使用WaitFor.
import React from 'react';
import { render, screen } from '@testing-library/react';
import App from './App';
describe('App', () => {
test('renders App component', async () => {
render(<App />);
expect(screen.queryByText(/Signed in as/)).toBeNull();
expect(await screen.findByText(/Signed in as/)).toBeInTheDocument();
});
});
简而言之,getBy用于正常的查询元素,queryBy用于查询我们希望它不存在的元素并进行断言,findBy用于查询需要等待的异步元素.
Search multiple elements
如果要断言多个元素,可以使用多元素查询方法
- getAllBy
- queryAllBy
- findAllBy
Assertive Functions
除了常见的Jest的断言函数,React Testing Library
还提供了一些常用的断言函数,类似于上文中我们用到的toBeInTheDocument
.
- toBeDisabled
- toBeEnabled
- toBeEmpty
- toBeEmptyDOMElement
- toBeInTheDocument
- toBeInvalid
- toBeRequired
- toBeValid
- toBeVisible
- toContainElement
- toContainHTML
- toHaveAttribute
- toHaveClass
- toHaveFocus
- toHaveFormValues
- toHaveStyle
- toHaveTextContent
- toHaveValue
- toHaveDisplayValue
- toBeChecked
- toBePartiallyChecked
- toHaveDescription
Fire event
到目前为止,我们只接触了测试当前组件是否渲染了某个元素.接下来说一下用户交互:
下面测试的场景是用户在input当中输入新的值,页面重新渲染,新的值会显示在页面上.
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import App from './App';
describe('App', () => {
test('renders App component', () => {
render(<App />);
screen.debug();
fireEvent.change(screen.getByRole('textbox'), {
target: { value: 'JavaScript' },
});
screen.debug();
});
});
fireEvent
函数的两个参数分别是,input元素和事件对象.screen.debug()
输出键入新值以后渲染的HTML DOM树的变化,可以发现第二次的输出中包含了新的值.
此外,如果你的组件包含异步任务,比如在页面加载的一开始先请求用户信息,那么上面的测试代码就会提示下面的错误信息: “Warning: An update to App inside a test was not wrapped in act(…).”.这代表这里有异步任务需要我们等待,需要先等异步人物执行完毕以后再进行其它的操作
describe('App', () => {
test('renders App component', async () => {
render(<App />);
// wait for the user to resolve
// needs only be used in our special case
await screen.findByText(/Signed in as/);
screen.debug();
fireEvent.change(screen.getByRole('textbox'), {
target: { value: 'JavaScript' },
});
screen.debug();
});
});
然后我们再针对input键入事件前后页面变化进行断言
describe('App', () => {
test('renders App component', async () => {
render(<App />);
// wait for the user to resolve
// needs only be used in our special case
await screen.findByText(/Signed in as/);
expect(screen.queryByText(/Searches for JavaScript/)).toBeNull();
fireEvent.change(screen.getByRole('textbox'), {
target: { value: 'JavaScript' },
});
expect(screen.getByText(/Searches for JavaScript/)).toBeInTheDocument();
});
});
针对event
的测试,官方更推荐使用使用,具体原因,看下文常见的错误使用方式
Callback handlers
回调函数的测试方式: mock回调函数,传给render的组件即可
function Search({ value, onChange, children }) {
return (
<div> <label htmlFor="search">{children}</label> <input id="search" type="text" value={value} onChange={onChange} /> </div>
);
}
describe('Search', () => {
test('calls the onChange callback handler', () => {
const onChange = jest.fn();
render(
<Search value="" onChange={onChange}> Search: </Search>
);
fireEvent.change(screen.getByRole('textbox'), {
target: { value: 'JavaScript' },
});
expect(onChange).toHaveBeenCalledTimes(1);
});
});
Asynchronous
下面的例子是一个远程获取数据以后展示在页面上的例子:
import React from 'react';
import axios from 'axios';
const URL = 'http://hn.algolia.com/api/v1/search';
function App() {
const [stories, setStories] = React.useState([]);
const [error, setError] = React.useState(null);
async function handleFetch(event) {
let result;
try {
result = await axios.get(`${URL}?query=React`);
setStories(result.data.hits);
} catch (error) {
setError(error);
}
}
return (
<div> <button type="button" onClick={handleFetch}> Fetch Stories </button> {error && <span>Something went wrong ...</span>} <ul> {stories.map((story) => ( <li key={story.objectID}> <a href={story.url}>{story.title}</a> </li> ))} </ul> </div>
);
}
export default App;
单击按钮以后,请求开始.下面是对应的测试代码:
import React from 'react';
import axios from 'axios';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import App from './App';
jest.mock('axios');
describe('App', () => {
test('fetches stories from an API and displays them', async () => {
const stories = [
{ objectID: '1', title: 'Hello' },
{ objectID: '2', title: 'React' },
];
axios.get.mockImplementationOnce(() =>
Promise.resolve({ data: { hits: stories } })
);
render(<App />);
await userEvent.click(screen.getByRole('button'));
const items = await screen.findAllByRole('listitem');
expect(items).toHaveLength(2);
});
});
在render组件以前,要先确保对http请求进行了mock处理,在进行请求时返回的便是我们的mock数据.
测试请求出错的代码:
import React from 'react';
import axios from 'axios';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import App from './App';
jest.mock('axios');
describe('App', () => {
test('fetches stories from an API and displays them', async () => {
...
});
test('fetches stories from an API and fails', async () => {
axios.get.mockImplementationOnce(() =>
Promise.reject(new Error())
);
render(<App />);
await userEvent.click(screen.getByRole('button'));
const message = await screen.findByText(/Something went wrong/);
expect(message).toBeInTheDocument();
});
});
React Router
待测试组件:
// app.js
import React from 'react'
import { Link, Route, Switch, useLocation } from 'react-router-dom'
const About = () => <div>You are on the about page</div>
const Home = () => <div>You are home</div>
const NoMatch = () => <div>No match</div>
export const LocationDisplay = () => {
const location = useLocation()
return <div data-testid="location-display">{location.pathname}</div>
}
export const App = () => (
<div> <Link to="/">Home</Link> <Link to="/about">About</Link> <Switch> <Route exact path="/"> <Home /> </Route> <Route path="/about"> <About /> </Route> <Route> <NoMatch /> </Route> </Switch> <LocationDisplay /> </div>
)
测试代码:
// app.test.js
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { createMemoryHistory } from 'history'
import React from 'react'
import { Router } from 'react-router-dom'
import '@testing-library/jest-dom/extend-expect'
import { App, LocationDisplay } from './app'
test('full app rendering/navigating', () => {
const history = createMemoryHistory()
render(
<Router history={history}> <App /> </Router>
)
// verify page content for expected route
// often you'd use a data-testid or role query, but this is also possible
expect(screen.getByText(/you are home/i)).toBeInTheDocument()
const leftClick = { button: 0 }
userEvent.click(screen.getByText(/about/i), leftClick)
// check that the content changed to the new page
expect(screen.getByText(/you are on the about page/i)).toBeInTheDocument()
})
test('landing on a bad page', () => {
const history = createMemoryHistory()
history.push('/some/bad/route')
render(
<Router history={history}> <App /> </Router>
)
expect(screen.getByText(/no match/i)).toBeInTheDocument()
})
test('rendering a component that uses useLocation', () => {
const history = createMemoryHistory()
const route = '/some-route'
history.push(route)
render(
<Router history={history}> <LocationDisplay /> </Router>
)
expect(screen.getByTestId('location-display')).toHaveTextContent(route)
})
React Redux
待测试组件:
import { connect } from 'react-redux'
const App = props => {
return <div>{props.user}</div>
}
const mapStateToProps = state => {
return state
}
export default connect(mapStateToProps)(App)
测试代码:
// test-utils.js
import React from 'react'
import { render as rtlRender } from '@testing-library/react'
import { createStore } from 'redux'
import { Provider } from 'react-redux'
// Import your own reducer
import reducer from '../reducer'
function render( ui, { initialState, store = createStore(reducer, initialState), ...renderOptions } = {} ) {
function Wrapper({ children }) {
return <Provider store={store}>{children}</Provider>
}
return rtlRender(ui, { wrapper: Wrapper, ...renderOptions })
}
// re-export everything
export * from '@testing-library/react'
// override render method
export { render }
React Testing Library 常见的错误使用方式
- Using cleanup
建议:don’t use cleanup
大多数主流的测试框架现在都会进行自动清理工作,所以不再需要手动的清理行为
// bad
import { render, screen, cleanup } from "@testing-library/react";
afterEach(cleanup)
// good
import { render,screen } from "@testing-library/react";
- Not using screen
建议:use screen for querying and debugging
DOM Testing Library v6.11.0加入了screen,使用screen可以避免手动的添加和删除查询函数,你只需要使用screen,然后由编辑器帮你自动补全剩下的查询函数
// bad
const { getByRole } = render(<Example />);
const errorMesssageNode = getByRole("alert");
// good
render(<Example />)
const errorMessageNode = screen.getByRole("alert");
- Using the Wrong assertion
建议:install and use @testing-library/jest-dom
toBedisabled断言来自jest-dom。强烈建议使用jest-dom,因为这样收到的错误消息要好得多
const button = screen.getByRole("button, {name: /disabled button/i}); // bad expect(button.disabled).toBe(true); // error message: // expect(received).toBe(expected) // Obejct.is equality // // Expected: true // Received: false // good expect(button).toBeDisabled() // error massage // received element id not disabled // <button />
- Wrapping things in act unnecessarily
建议:Learn when act is necessary and don’t wrap things in act unnecessarily.
render
fireEvent
已经包含了act
的功能,所以不需要再使用act
// bad
act(() => {
render(<Example />)
});
const input = screen.getByRole('textbox', {name: /choose a fruit/i});
act(() => {
fireEvent.keyDown(input, {key: 'ArrowDown'});
});
// good
render(<Example />);
const input = screen.getByRole('textbox', {name: /choose a fruit/i});
fireEvent.keyDown(input, {key: 'ArrowDown'});
- Using the wrong query
这是一个查询推荐顺序:应该使用哪个查询,使用最接近用户的方式进行查询
// bad
// assuming you've got this DOM to work with:
// <label>Username</label><input data-testid="username" />
screen.getByTestId('username');;
// good
// change the DOM to be accessible by associating the label and setting the type
// <label for="username">Username</label><input id="username" type="text" />
screen.getByRole('textbox', {name: /username/i});
// bad
const {container} = render(<Example />);
const button = container.querySelector('.btn-primary');
expect(button).toHaveTextContent(/click me/i);
// good
render(<Example />)
screen.getByRole('button', {name: /click me/i});
// bad
screen.getByTestId('submit-button');
// good
screen.getByRole('button', {name: /submit/i});
- Not using @testing-library/user-event
建议:Use @testing-library/user-event over fireEvent where possible.
@testing-library/user-event
是一个基于fireEvent构建的,它提供了几种与用户交互更相似的方法。 在下面的示例中,fireEvent.change将只触发input上面的change事件。 但是userEvent.type还会触发keyDown,keyPress和keyUp事件。它更接近于用户的实际交互。
// bad
fireEvent.change(input, {target: {value: 'hello world'}});
// good
userEvent.type(input, 'hello world');
- Using query variants for anything except checking for non-existence*
建议:Only use the query* variants for asserting that an element cannot be found
类似于queryByRole这样的查询方法,只有在判断一个元素不存在于当前页面中时使用
// bad
expect(screen.queryByRole('alert')).toBeInTheDocument();
// good
expect(screen.getByRole('alert')).toBeInTheDocument();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
- Using waitFor to wait for elements that can be queried with find
下面这两段代码是等效的,但是第二个更简单,而且错误提示信息也会更好
// bad
const submitButton = await waitFor(() =>
screen.getByRole('button', {name: /submit/i}),
)
// good
const submitButton = await screen.findByRole('button', {name: /submit/i})
应该使用哪个查询
根据指导原则,你的测试应该尽可能的类似于用户使用你的页面或组件。下面是推荐的有限顺序:
- Queries Accessible to Everyone(每个人都可以访问的查询)
- getByRole
它可以用于查询处于
accessibility tree
中的所有元素,并且可以通过name
选项过滤返回的元素。它应该是你查询的首选项。大多数情况下,它都会带着name
选项一起使用,就像这样:getByRole('button', {name: /submit/i})
。这里是Roles的列表可供参考Roles list
- getByLabelText
这是查询表单元素的首选项
- getByPlaceholderText
这是查询表单元素的一个代替方案
- getByText
它对表单没有用,但这是找到大多数非交互式元素(例如div和spans)的第一方法
- getByDisplayValue
2.Semantic Queries(语义查询)
- getByAltText
如果你查询的元素支持
alt
text(例如img
area
input
),那么可以使用它来进行查询
- getByTitle
通过title属性查询,但是注意,一般通过屏幕查看页面的用户是无法直接看到titile的 3.Test IDs
- getByTestId
它通常用于查询一些用户看不到听不到的内容,这些内容无法通过Role或者Text匹配到。
注意
虽然也可以使用querySelector DOM API
进行查询,但是这是极其不推荐的做法,因为用户是看不到这些属性的。如果你不得不这样做的话,可以给它添加testid,像下面这样
// @testing-library/react
const { container } = render(<MyComponent />)
const foo = container.querySelector('[data-foo="bar"]')
有用的浏览器扩展
扩展工具Testing Playground可以帮助你找到最合适查询方式
Reference
今天的文章React Testing Library使用总结分享到此就结束了,感谢您的阅读。
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
如需转载请保留出处:https://bianchenghao.cn/20938.html