为什么要进行单元测试
- 正确性,单元测试能确保组件与预期结果一致,特别是一些边界问题
- 自动化,迭代组件的时候,只需跑一下单测用例,不用手动从头测过
技术选型
- jest(Facebook开源的一个前端测试框架)
- @umijs/test umi 基于jest的封装
- @testing-library/react(简单而完整的React DOM测试实用程序鼓励良好的测试实践)
- @testing-library/user-event (以与用户相同的方式触发事件)
- @testing-library/jest-dom(自定义匹配器来测试DOM的状态)
- @testing-library/react-hooks 测试 react hooks的工具
- react-test-renderer 提供了一个实验性的React渲染器,可用于将React组件渲染为纯JavaScript对象
如何开始?
1.安装依赖
npm i @testing-library/user-event@14.4.3 -D
npm i @testing-library/react@12.1.2 -D
npm i @testing-library/jest-dom@5.15.1 -D
npm i @umijs/test@3.5.34 -D
npm i @testing-library/react-hooks@8.0.1 -D
npm i react-test-renderer@17.0.2 -D
2.环境配置
- 在根目录新建 jest.config.js 文件
module.exports = {
setupFilesAfterEnv: ['@testing-library/jest-dom/extend-expect'],
};
- 在package.json 中配置测试命令
...
"script":{
"test":"umi-test",
"test:coverage":"umi-test --coverage"
}
...
3.简单例子
- 新建一个button组件 (二次封装 antd button组件,在点击的时候自动加上loading)
import React,{useState} from 'react'
import {Button} from 'antd'
import omit from 'lodash/omit'
import type { ButtonProps as AButtonProps } from 'antd/es/button/button';
export interface ButtonProps extends AButtonProps {
async?:boolean
}
const Index = (props:ButtonProps)=>{
const [loading, setLoading] = useState(false);
const onClick = async (e: React.MouseEvent<HTMLElement, MouseEvent>) => {
setLoading(true);
try {
await props.onClick?.(e);
} catch (err) {
console.warn('Button asynchronous operation failure:', err);
}
setLoading(false);
};
let resetProps = omit(props,['async'])
if(props.async){
resetProps = {
...resetProps,
loading,
onClick
}
}
return <Button {...resetProps} />
}
export default Index
- 在button组件旁新建tests 文件夹,在下面新建 index.test.tsx 文件 为组件编写测试用例
import React from 'react';
import Button from '..';
import { render, act,screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event'
import sleep from '../../utils/sleep'
describe('Button', () => {
it('async correctly', async () => {
const AsyncButton = () => {
const getData = async () => {
await sleep(100);
};
return (
<Button async onClick={getData}>
content
</Button>
);
};
render(<AsyncButton />);
const node = screen.getByRole('button',{name:'content'})
await userEvent.click(node)
expect(node).toHaveClass('ant-btn-loading');
await act(async ()=>{
await sleep(200);
expect(node).not.toHaveClass('ant-btn-loading');
})
});
});
- 运行单测命令 npm run test 就可以看到单测结果
4.如何单个用例测试
上面我们运行 npm run test 命令,会去查找当前项目下所有的单测用例,如果我们只需要对单个用例进行测试,解决呢?
无论是jest 还是 @umijs/test 都是可以进行单个用例测试的
node 'node_
modules/@umijs/test/bin/umi-test.js' '/Users/mryeziqing
/Desktop/核盛/组件平台/hera-ui/packages/hera-ui/src/but
ton/__tests__/index.test.tsx' -t 'Button async correctl
y'
实际开发过程中,不可能一个一个路径的输,可以使用 vscode 插件 Jset Runner。安装该插件之后,在用例上面会有一个 run 按钮,点击就可以进行单个用例测试。Jest Runner 默认调用的是 jest,如果想调用 umi-test的话,可以在 项目下新建 .vscode/settings.json,配置运行命令即可
{
"jestrunner.jestPath": "node_modules/@umijs/test/bin/umi-test.js"
}
常用测试用例
崩溃测试
组件挂载到更新和卸载而没有错误
在项目根目录,新建tests/componentTest.tsx 编写一个通用的测试方法
import React from 'react';
import { render } from '@testing-library/react';
export default function componentTest(
Component: React.ComponentType<any>,
componentName: string,
componentProps = {},
) {
describe(`component ${componentName}`, () => {
it(`renders correctly`, () => {
const wrapper = render(<Component {...componentProps} />);
expect(wrapper).toMatchSnapshot();
});
it('component could be updated and unmounted without errors', () => {
const { rerender, unmount } = render(<Component {...componentProps} />);
expect(() => {
// re-render the same component with different props
rerender(<Component {...componentProps} />);
unmount();
}).not.toThrow();
});
});
};
使用
import Button from '..'
import componentTest from '../../../tests/componentTest'
// 确保组件能够正常渲染
componentTest(Button, 'Button', { children: 'content' });
快照测试
Jest 允许你使用 toMatchSnapshot / toMatchInlineSnapshot 保存数据的“快照”。有了这些,我们可以“保存”渲染的组件输出,并确保对它的更新作为对快照的更新显式提交。
import Button from '..'
import { render } from '@testing-library/react';
describe('Button',()=>{
it('renders content correctly',()=>{
const wrapper = render(<Button>content</Button>)
expect(wrapper).toMatchSnapshot()
const wrapper1 = render(<Button>内容</Button>)
expect(wrapper1).toMatchSnapshot()
})
})
通常,进行具体的断言比使用快照更好。这类测试包括实现细节,因此很容易中断,并且团队可能对快照中断不敏感。选择性地 mock 一些子组件可以帮助减小快照的大小,并使它们在代码评审中保持可读性。
事件测试
使用@testing-library/user-event 在 DOM 元素上触发真正的 DOM 事件,然后对结果进行断言
我们新建一个 Toggle 组件:
import React, { useState } from 'react';
export default function Toggle() {
const [state, setState] = useState(false);
return (
<button
onClick={() => {
setState(previousState => !previousState);
}}
id='toggle'
>
{state === true ? 'Turn off' : 'Turn on'}
</button>
);
}
我们可以为他编写测试用例:
import React from 'react'
import Toggle from '..'
import { render,screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
describe('Toggle',()=>{
it('Update values when clicked', async ()=>{
render(<Toggle />)
const node = screen.getByRole('button')
expect(node.textContent).toBe('Turn on')
await userEvent.click(node)
expect(node.textContent).toBe('Turn off')
})
})
mock测试
有些模块可能在测试环境中不能很好地工作,或者对测试本身不是很重要。使用虚拟数据来 mock 这些模块可以使你为代码编写测试变得更容易
我们现在写一个Text组件,超出字数之后 Tooltip显示
import { Tooltip } from 'antd'
import React,{memo} from 'react'
export interface TextProps {
maxLength?:number,
content:string | number
}
const Index:React.FC<TextProps> = (props)=>{
const {maxLength=30,content} = props
if(!content){
return null
}
const title = content.toString();
if(title.length > maxLength){
const text = title.slice(0, maxLength);
return (
<Tooltip title={title}>
{text}...
</Tooltip>
)
}
return <span>{title}</span>
}
export default memo(Index)
不想在测试中加载 antd 的 Tooltip 这个组件,我们可以将依赖 mock 到一个虚拟组件,然后运行我们的测试:
import React from 'react';
import Text from '..'
import {render,screen} from '@testing-library/react'
jest.mock('antd',()=>{
return {
Tooltip:(props)=>{
return (
<div>
<span data-testid='title' >{props.title}</span>
<span data-testid='children'>{props.children}</span>
</div>
)
}
}
})
describe('Text',()=>{
it('text correctly',()=>{
render(<Text maxLength={6} content='我是text组件' />)
const titleNode = screen.getByTestId('title')
const childNode = screen.getByTestId('children')
expect(titleNode.textContent).toBe('我是text组件')
expect(childNode.textContent).toBe('我是text...')
})
})
上面讲到的是 组件的 mock,如果是数据请求呢?
我们可以mock 请求来拿到数据 ,而不是在所有测试中调用真正的 API
import React, { useState,useEffect } from 'react'
export interface UserProps {
id:string
}
interface UserState {
name:string,
age:string,
address:string
}
const Index:React.FC<UserProps> = (props)=>{
const [user,setUser] = useState<UserState | null>(null)
async function fetchUserData(id) {
const response = await fetch("/" + id);
setUser(await response.json());
}
useEffect(() => {
fetchUserData(props.id);
}, [props.id]);
if (!user) {
return <div>加载中...</div>
}
return (
<div>
<summary>{user.name}</summary>
<strong>{user.age}</strong> 岁
<br />
住址 {user.address}
</div>
);
}
export default Index
我们可以为它编写测试
import React from 'react'
import User from '..'
import {render,act} from '@testing-library/react'
describe('User',()=>{
it('renders user data',async ()=>{
const fetchUser = {
name:'叶小秋',
age:'23',
address:'杭州'
}
jest.spyOn(global,'fetch').mockImplementation(()=>(
Promise.resolve({
json:()=> Promise.resolve(fetchUser)
})
))
let wrapper
// 使用异步的 act 应用执行成功的 promise
await act(async ()=>{
wrapper = render(<User id='123' />)
})
expect(wrapper.container.querySelector('summary').textContent).toBe(fetchUser.name)
expect(wrapper.container.querySelector('strong').textContent).toBe(fetchUser.age)
expect(wrapper.container.textContent).toContain(fetchUser.name)
// 清理 mock 以确保测试完全隔离
global.fetch.mockRestore()
})
})
hooks测试
对react的自定义编写的 hooks 进行测试
我们先来编写一个自定义 hooks
import { useCallback, useRef } from 'react';
import useUpdate from './useUpdate';
export interface usePropsValueProps {
value?: any;
onChange?: any;
}
export default function usePropsValue(options: usePropsValueProps) {
const { value, onChange } = options;
const update = useUpdate();
// 是否受控
const controlled = options.hasOwnProperty('value');
const stateRef = useRef(controlled ? value : undefined);
if (controlled) {
stateRef.current = value;
}
const setState = useCallback(
(v, ...args) => {
if (!controlled) {
stateRef.current = v;
update();
}
onChange?.(v, ...args);
},
[value, update, onChange],
);
return [stateRef.current, setState];
}
我们来为他编写测试用例:
import {act,renderHook} from '@testing-library/react-hooks'
import usePropsValue from '../usePropsValue'
describe('usePropsValue',()=>{
it('should be defined',()=>{
expect(usePropsValue).toBeDefined()
})
const setUp = <T extends object>(props:T)=>(
renderHook(()=>{
const [value,setValue] = usePropsValue(props)
return {
value,
setValue
}
})
)
it('should support value',()=>{
const hook = setUp<any>({
value:'叶小秋'
})
expect(hook.result.current.value).toBe('叶小秋')
})
it('should support function update',()=>{
let name = '叶小秋'
const hook = setUp<any>({
value:name,
onChange:(value)=>{
name = value
}
})
act(()=>{
hook.result.current.setValue('一叶之秋')
})
expect(name).toBe('一叶之秋')
const hook1 = setUp<any>({})
expect(hook1.result.current.value).toBe(undefined)
act(()=>{
hook1.result.current.setValue('叶小秋')
})
expect(hook1.result.current.value).toBe('叶小秋')
})
})
测试覆盖率报告
单元测试覆盖率是一种软件测试的度量指标,描述程序中源代码被测试的比例和程度
如何生成报告?
在jest.config.js 配置
module.exports = {
setupFilesAfterEnv: ["@testing-library/jest-dom/extend-expect"],
// // 是否显示覆盖率报告
// collectCoverage: true,
// 告诉 jest 哪些文件需要经过单元测试测试
collectCoverageFrom: ["components/**/*.{ts,tsx}", "!components/**/__test__/__snapshots__"],
// 设置单元测试覆盖率阀值
coverageThreshold: {
// 设置单测覆盖率阀值
global: {
// 保证每个语句都执行了
statements: 90,
// 保证每个函数都调用了
functions: 90,
// 保证每个 if 等分支代码都执行了
branches: 90,
// 保证 每一行都执行了
// lines:xxx,
},
},
};
生成报告
npm run test:coverage
| 指标 | 说明 |
|---|---|
| % Stmts | 语句覆盖率:是不是每个语句都执行了? |
| % Branch | 分支覆盖率:是不是每个if代码块都执行了? |
| % Funcs | 函数覆盖率:是不是每个函数都调用了? |
| % Lines | 行覆盖率:是不是每一行都执行了? |
常用API
jest
-
- .toBe(value) 相等性,检查规则为 === + Object.is
- .toEqual(value) 相等性,递归对比对象字段
- .toBeInstanceOf(Class) 检查是否属于某一个 Class 的 instance
- .toHaveProperty(keyPath, value) 检查断言中的对象是否包含 keyPath 字段,或可以检查该对象的值是否等于 value
- .toBeGreaterThan(number) 大于 number
- .toBeGreaterThanOrEqual(number) 大于等于 number
- .toBeNaN() 值是否是 NaN
- .toMatch(regexp or String) 字符串的相等性,可以填入 string 或者一个正则
- .toContain(item) 截取字符串中的子串
- .toHaveLength(number) 字符串长度
- .not 当前测试的反面
- .toThrow(error?) 检查是否抛出异常
- mock
-
- jest.fn 创建Mock函数
- jest.mock mock 一些不好控制的依赖,比如第三方包
- jest.spyOn 创建一个类似于jest的mock函数。fn,但也可以跟踪对对象[methodName]的调用
@testing-library/react
- render 渲染组件
- screen 为测试用例提供了一个全局 DOM 环境
- act 测试异步代码时包裹,确保状态更新
- screen.debug 输出元素
@testing-library/user-event
- .click 点击事件
- .type 在输入框输入
- .upload 文件上传
参考文献
- react 官网 测试技巧
- jest 官网
- testing-library 官网
- Jest + React Testing Library 单测总结
- 如何做前端单元测试
- 使用Jest进行React单元测试