外观
第11章—React组件如何写单测
1972字约7分钟
2024-09-19
当你写完一个 React 组件,如何保证它的功能是正常的呢?
在浏览器里渲染出来,手动测试一遍就好了啊。
那如果这个组件交给别人维护了,他并不知道这个组件的功能应该是什么样的,怎么保证他改动代码之后,组件功能依然正常?
这种情况就需要单元测试了。
单元测试可以测试函数、类的方法等细粒度的代码单元,保证功能正常。
有了单元测试之后,后续代码改动只需要跑一遍单元测试就知道功能是否正常。
但很多同学觉得单元测试没意义,因为代码改动比较频繁,单元测试也跟着需要频繁改动。
确实,如果代码改动特别频繁,就没必要单测了,手动测试就好。
因为如果手动测试一遍需要 5 分钟,写单元测试可能需要一个小时。
但如果代码比较稳定,那单测还是很有必要的,比如组件库里的组件、hooks 库里的 hooks、一些工具函数等。
手动测试 5 分钟,每次都要手动测试,假设 20 次,那就是 100 分钟的成本,而且还不能保证测试是可靠的。
写单测要一个小时,每次直接跑单测自动化测试,跑 100 次也是一个小时的成本,而且还是测试结果很可靠。
综上,单元测试能保证函数、类的方法等代码单元的功能正常,把手动测试变成自动化测试。
但是写单元测试成本还是挺高的,如果代码改动频繁,那手动测试更合适。一些比较稳定的代码,还是有必要写单测的,写一次,自动测试 n 次,收益很大。
那 React 的组件和 hooks 怎么写单测呢?
这节我们一起来写几个单测试试。
用 create-react-app 创建个 react 项目:
npx create-react-app --template=typescript react-unit-test
测试 react 组件和 hooks 可以使用 @testing-library/react 这个包,然后测试用例使用 jest 来组织。
这两个包 cra 都给引入了,我们直接跑下 npm run test 就可以看到单测结果。
App 组件是这样的:
它的单测是这么写的:
通过 @testing-library/react 的 render 函数把组件渲染出来。
通过 screen 来查询 dom,查找文本内容匹配正则 /learn react/ 的 a 标签。
然后断言它在 document 内。
你也可以这么写:
test('renders learn react link 2', () => {
const { container } = render(<App />);
const linkElement = container.querySelector('.App-link');
expect(linkElement?.textContent).toMatch(/learn react/i)
});
render 会返回组件挂载的容器 dom,它是一个 HTMLElement 的对象,有各种 dom 方法。
可以用 querySelector 查找到那个 a 标签,然后判断它的内容是否匹配正则。
这两种写法都可以。
第二种方法更容易理解,就是拿到渲染容器的 dom,再用 dom api 来查找 dom。
第一种方法的 screen 是 @testing-library/react 提供的 api,是从全局查找 dom,可以直接根据文本查(getByText),根据标签名和属性查(getByRole) 等。
antd 组件的测试也是用的第二种来查找 dom 的:
那如果有 onClick、onChange 等事件监听器的组件,怎么测试呢?
我们写个组件 Toggle.tsx:
import { useCallback, useState } from 'react';
function Toggle() {
const [status, setStatus] = useState(false);
const clickHandler = useCallback(() => {
setStatus((prevStatus) => !prevStatus);
}, []);
return (
<div>
<button onClick={clickHandler}>切换</button>
<p>{status ? 'open' : 'close' }</p>
</div>
);
}
export default Toggle;
有个 state 来存储 open、close 的状态,点击按钮切换。
渲染出来是这样的:
这个组件如何测试呢?
单测里触发事件需要用到 fireEvent 方法了。
改下 App.test.tsx
import { render, fireEvent } from '@testing-library/react';
import Toggle from './Toggle';
test('toggle', () => {
const { container } = render(<Toggle/>);
expect(container.querySelector('p')?.textContent).toBe('close');
fireEvent.click(container.querySelector('button')!)
expect(container.querySelector('p')?.textContent).toBe('open');
})
用 render 方法把组件渲染出来。
用 container 节点的 dom api 查询 p 标签的文本,断言是 close。
然后用 fireEvent.click 触发 button 的点击事件。
断言 p 标签的文本是 open。
跑一下:
npm run test
测试通过了:
fireEvent 可以触发任何元素的任何事件:
那如何触发 change 事件呢?
这样写:
第二个参数传入 target 的 value 值。
此外,如果我有段异步逻辑,过段时间才会渲染内容,这时候怎么测呢?
比如 Toggle 组件里点击按钮之后,过了 2s 才改状态:
setTimeout(() => {
setStatus((prevStatus) => !prevStatus);
}, 2000);
这时候测试用例就报错了:
这种用 waitFor 包裹下,设置 timeout 的时间就好了:
await waitFor(() => expect(container.querySelector('p')?.textContent).toBe('open'), {
timeout: 3000
});
测试通过了:
除了这些之外,还有一个 api 比较常用,就是 act
它是 react-dom 包里的,@testing-library/react 对它做了一层包装。
就是可以把所有浏览器里跑的代码都包一层 act,这样行为会和在浏览器里一样。
文档里的例子是这样的:
把单测里的 fireEvent 用 act 包一层:
import { render, fireEvent, waitFor } from '@testing-library/react';
import { act } from 'react-dom/test-utils';
import Toggle from './Toggle';
test('toggle', async () => {
const { container } = render(<Toggle/>);
expect(container.querySelector('p')?.textContent).toBe('close');
act(() => {
fireEvent.click(container.querySelector('button')!)
})
await waitFor(() => expect(container.querySelector('p')?.textContent).toBe('open'), {
timeout: 3000
});
})
结果一样:
组件测试我们学会了,那如果我想单独测试 hooks 呢?
这就要用到 renderHook 的 api 了。
我们写个 useCounter 的 hook:
import { useState } from 'react';
type UseCounterReturnType = [
count: number,
increment: (delta: number) => void,
decrement: (delta: number) => void
];
export default function useCounter(initialCount: number = 0): UseCounterReturnType {
const [count, setCount] = useState(initialCount);
const increment = (delta: number) => {
setCount(count => count + delta);
};
const decrement = (delta: number) => {
setCount(count => count - delta);
};
return [count, increment, decrement];
}
先在 App.tsx 里用一下:
import useCounter from './useCounter';
function App() {
const [count, increment, decrement] = useCounter();
return (
<div>
<div>
{count}
</div>
<div>
<button onClick={() => increment(1)}>加一</button>
<button onClick={() => decrement(2)}>减二</button>
</div>
</div>
);
}
export default App;
跑一下:
npm run start
没啥问题。
然后来写下这个 hook 的单测:
test('useCounter', async () => {
const hook = renderHook(() => useCounter(0));
const [count, increment, decrement] = hook.result.current;
act(() => {
increment(2);
});
expect(hook.result.current[0]).toBe(2);
act(() => {
decrement(3);
});
expect(hook.result.current[0]).toBe(-1);
hook.unmount();
});
renderHook 返回的 result.current 就是 hook 的返回值。
这就是 hook 的单测写法。
案例代码上传了小册仓库。
总结
单元测试能保证函数、类的方法等代码单元的功能正常,把手动测试变成自动化测试。
变更不频繁的代码,还是有必要写单测的,写一次,自动测试 n 次,收益很大。
我们学了 react 组件和 hook 的单测写法。
主要是用 @testing-library/react 这个库,它有一些 api:
- render:渲染组件,返回 container 容器 dom 和其他的查询 api
- fireEvent:触发某个元素的某个事件
- createEvent:创建某个事件(一般不用这样创建)
- waitFor:等待异步操作完成再断言,可以指定 timeout
- act:包裹的代码会更接近浏览器里运行的方式
- renderHook:执行 hook,可以通过 result.current 拿到 hook 返回值
其实也没多少东西。
jest 的 api 加上 @testing-libary/react 的这些 api,就可以写任何组件、hook 的单元测试了。