React ㄨ Jest 测试组件
一、React Test Renderer
react-test-renderer 负责将组件输出成 JSON 对象以方便我们遍历、断言或是进行快照测试。
1、安装及配置
使用 Create React App 时:
直接安装 react-test-renderer 即可
yarn add --dev react-test-renderer
不使用 Create React App 时:
- 安装:
yarn add --dev jest babel-jest @babel/core @babel/preset-env @babel/preset-react react-test-renderer
- 在 package.json 中添加:
{
"scripts": {
"test": "jest"
}
}
- 在根目录的
babel.config.cjs
配置以下内容:
module.exports = {
presets: [
'@babel/preset-env',
['@babel/preset-react', {runtime: 'automatic'}],
],
};
2、API
react-test-renderer 提供一个 React 渲染器 TestRenderer,用于将 React 组件渲染成纯 JavaScript 对象,调用 TestRenderer 的 create
方法并传入要 render 的组件就可以获得一个 TestRenderer 的实例。该实例上存在着以下几个方法和属性:
- act(): 为断言准备一个组件。可以使用 act() 来包装 TestRenderer.create 和 testRenderer.update;
- toJSON(): 生成一个表示 render 结果的 JSON 对象。该对象中只包含像
<div>
这样的原生节点,不会包含用户开发的组件信息,适用于快照测试; - toTree(): 与 toJSON() 类似,但信息更详细,包含用户开发的组件信息;
- update(element): 通过传入一个新元素来更新上次 render 得到的组件树;
- umount(): 卸载内存中的树,同时触发相应的生命周期函数;
- getInstance(): 返回根节点对应的 React 组件实例。如果顶级组件是一个函数式组件,则无法获取;
- root: 该属性保存了根节点对应的测试实例,该实例提供了一系列方法便于编写断言;
- find() 与 findAll(): 用于查找符合特定条件的测试实例。区别在于 find() 会严格要求节点树只有 1 个满足条件的测试实例,如果没有或多于 1 个就会抛出异常,此区别同样适用于下面两组方法;
- findByType() 与 findAllByType(): 用于查找特定类型的测试实例,这里的类型可以是
<div>
这种原生类型,也可以是 Link 这种用户编写的 React 组件; - findByProps() 与 findAllByProps(): 用于查找 props 符合特定结构的测试实例。
- instance: 该测试实例对应的 React 组件实例。
3、基本用法
- 组件测试用例
- 组件
import renderer from 'react-test-renderer'; // 即上述的 TestRenderer
import Link from '../index';
it('changes the class when hovered', () => {
// 创建一个 TestRenderer 实例
const component = renderer.create(
<Link page="http://www.facebook.com">Facebook</Link>,
);
// 返回一个已渲染的的树对象
let tree = component.toJSON();
expect(tree).toMatchSnapshot();
// manually trigger the callback
renderer.act(() => {
tree.props.onMouseEnter();
});
// re-rendering
tree = component.toJSON();
expect(tree).toMatchSnapshot();
// manually trigger the callback
renderer.act(() => {
tree.props.onMouseLeave();
});
// re-rendering
tree = component.toJSON();
expect(tree).toMatchSnapshot();
});
import { useState } from 'react';
const STATUS = {
HOVERED: 'hovered',
NORMAL: 'normal',
};
export default function Link({ page, children }) {
const [status, setStatus] = useState(STATUS.NORMAL);
const onMouseEnter = () => {
setStatus(STATUS.HOVERED);
};
const onMouseLeave = () => {
setStatus(STATUS.NORMAL);
};
return (
<a
className={status}
href={page || '#'}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
{children}
</a>
);
}
运行 yarn test
,生成测试快照:
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`changes the class when hovered 1`] = `
<a
className="normal"
href="http://www.facebook.com"
onMouseEnter={[Function]}
onMouseLeave={[Function]}
>
Facebook
</a>
`;
exports[`changes the class when hovered 2`] = `
<a
className="hovered"
href="http://www.facebook.com"
onMouseEnter={[Function]}
onMouseLeave={[Function]}
>
Facebook
</a>
`;
exports[`changes the class when hovered 3`] = `
<a
className="normal"
href="http://www.facebook.com"
onMouseEnter={[Function]}
onMouseLeave={[Function]}
>
Facebook
</a>
`;
如果组件或测试用例修改,组件快照和之前形成的快照不一致,会导致测试失败,可以通过 --updateSnapshot 来重新生成快照文件:
yarn test --updateSnapshot
# 或简写↓
yarn test -u
二、React Testing Libraryㅤ
React 测试库 React Testing Library 不面向组件代码的实现细节,而是模拟用户的交互方式,测试最终 DOM,对 React 组件测试非常友好。
1、安装及配置ㅤ
以下步骤适用于非 Create React App 项目(Create React App 均已安装并配置好了)
- 安装 @testing-library/react:
yarn add --dev @testing-library/react
- Jest 28 或更高版本需要单独安装 jest-environment-jsdom 包:
yarn add --dev jest-environment-jsdom
如果已经有 jest.config.js,则在 jest.config.js 中设置:
testEnvironment: "jsdom",
否则直接运行创建 jest.config.js,在运行时配置 environment 即可:
yarn test --init
- jest-dom 是 React Testing Libraryㅤ生态中为 Jest 提供自定义 DOM 元素匹配器的配套库,安装如下:
yarn add --dev @testing-library/jest-dom
根目录新建 jest-setup.js:
import '@testing-library/jest-dom'
然后在 jest.config.js 中配置:
setupFilesAfterEnv: ['<rootDir>/jest-setup.js'],
- user-event 是 React Testing Libraryㅤ生态中扩展 fireEvent 模拟用户交互的配套库,安装如下:
yarn add --dev @testing-library/user-event @testing-library/dom
2、错误解决
- 如果测试的组件中引入了 .scss 等文件,可能会出现以下错误:
根目录新建 file.mock.js 文件:
module.exports = {};
然后在 jest.config.js 中配置:
moduleNameMapper: {
"\\.(scss|css|jpg|png|gif)$": "<rootDir>/file.mock.js"
},
- 如果运行测试时出现 React 18 不再支持 ReactDOM.render 的错误:
解决方式:
yarn add @testing-library/react@latest -D
3、Testing Library API
3-1、Queries
Query 通用前缀:
前缀 | 匹配到 0 项 | 匹配到 1 项 | 匹配到多项 | Retry (Async/Await) |
---|---|---|---|---|
get | Throw error | 返回单个节点 | Throw error | ❌ |
getAll | Throw error | 返回节点数组 | 返回节点数组 | ❌ |
query | 返回 null | 返回单个节点 | Throw error | ❌ |
queryAll | 返回 [] | 返回节点数组 | 返回节点数组 | ❌ |
find | Throw error | 返回单个节点 | Throw error | ✅ |
findAll | Throw error | 返回节点数组 | 返回节点数组 | ✅ |
Query 使用后缀:
- ByLabelText:用于表单,匹配 label
- ByPlaceholderText:用于表单,匹配占位符
- ByText:匹配查询文本节点
- ByDisplayValue:匹配输入框等表单元素当前值
- ByAltText:匹配 img 的 alt 属性
- ByTitle:匹配 title 属性或元素
- ByTestId:匹配 data-testid 属性
举个例子:
// 匹配指定文本是否在节点中
it('info', () => {
const { queryByText } = render(<Loading info="加载中" />);
expect(queryByText('加载中')).toBeInTheDocument();
})
// 匹配指定类名的节点
it('maxLength', () => {
const { container } = render(<Input maxLength={15} />);
expect(container.querySelector('.i-input--limit')).toBeInTheDocument();
})
// 匹配指定 test-id 的节点
it('maxWidth', () => {
const { getByTestId } = render(
<Breadcrumb>
<Breadcrumb.Item>item1</Breadcrumb.Item>
<Breadcrumb.Item maxWidth={80} data-testid='test-item'>item2</Breadcrumb.Item>
<Breadcrumb.Item>item3</Breadcrumb.Item>
</Breadcrumb>
);
expect(getByTestId('test-item')).toHaveStyle('max-width: 80px;');
});
3-2、fireEvent
fireEvent[eventName](node: HTMLElement, eventProperties: Object)
举个例子:
it('onClick', () => {
const clickFn = jest.fn();
const { container } = render(<Button onClick={clickFn} />);
fireEvent.click(container.firstChild);
expect(clickFn).toHaveBeenCalled();
});
4、jest-dom API
元素通用验证:
- toBeVisible:可见性(display、visibility、opacity、hidden 等)
- toBeInTheDocument:文档中是否存在该元素;
- toHaveAttribute:元素是否存在该属性;
- toHaveClass:元素是否存在该类名;
- toHaveStyle:元素是否存在该样式;
元素内容验证:
- toContainElement:元素中是否存在指定元素;
- toContainHTML:元素中是否该 HTML 字符串;
- toHaveTextContent:元素中是否存在文本内容,可正则匹配;
表单属性验证:
- toBeDisabled:元素是否被禁用;
- toBeEnabled:元素是否没被禁用;
- toBeInvalid:元素是否无效;
- toBeValid:元素是否有效;
- toBeRequired:元素是否为必填项;
- toBeChecked:元素是否为选中项;
- toHaveFocus:元素是否聚焦;
- toHaveValue:元素是否具有指定值;
- toHaveFormValues:表单是否拥有指定控件;
举个例子:
it('children', () => {
const { queryByText } = render(<Button>foo</Button>);
expect(queryByText('foo')).toBeInTheDocument();
});
5、user-event API
- click(element):单击
- dblClick(element):双击
- tripleClick(element):三击
- hover(element):悬浮
- unhover(element):不悬浮
- clear(element):清除可编辑元素
- selectOptions(element, values):表单选择
- deselectOptions(element, values):表单取消选择
- Keyboard(input):按键
- type(element, text, [options]):输入文本
- tab(options):模拟 tab 键(切换 focus)
- copy():复制
- cut():剪切
- paste([clipboardData]):粘贴
- upload(element, fileOrFiles):上传
举个例子:
it('keyDown', async () => {
const onKeydownFn = jest.fn();
const onEnterFn = jest.fn();
const { container } = render(
<Input onKeyDown={onKeydownFn} onEnter={onEnterFn} />,
);
const InputDOM = container.firstChild.firstChild;
const user = userEvent.setup()
await user.click(InputDOM)
await user.keyboard('abc{enter}')
expect(onEnterFn).toBeCalled();
expect(onKeydownFn).toBeCalled();
});
6、基本用法
- Button 组件测试用例
- Button 组件
- Button 组件类型
import { render, fireEvent } from '@testing-library/react';
import Button from '../index';
describe('Button 组件测试', () => {
it('children', () => {
const { queryByText } = render(<Button>foo</Button>);
expect(queryByText('foo')).toBeInTheDocument();
});
it('type', () => {
const { container } = render(<Button type="success" />);
expect(container.firstChild.classList.contains('i-button--type-success')).toBeTruthy();
});
it('variant', () => {
const { container } = render(<Button variant="outline" />);
expect(container.firstChild.classList.contains('i-button--variant-outline')).toBeTruthy();
});
it('active', () => {
const { container } = render(<Button active />);
expect(container.firstChild.classList.contains('i-button-active')).toBeTruthy();
});
it('disabled', () => {
const clickFn = jest.fn();
const { container } = render(<Button disabled onClick={clickFn} />);
expect(container.firstChild).toBeDisabled();
fireEvent.click(container.firstChild);
expect(clickFn).toBeCalledTimes(0);
});
it('size', () => {
const { container } = render(<Button size="small" />);
expect(container.firstChild.classList.contains('i-button--size-small')).toBeTruthy();
});
it('shape', () => {
const { container } = render(<Button shape="circle" />);
expect(container.firstChild.classList.contains('i-button--shape-circle')).toBeTruthy();
});
it('onClick', () => {
const clickFn = jest.fn();
const { container } = render(<Button onClick={clickFn} />);
fireEvent.click(container.firstChild);
expect(clickFn).toBeCalledTimes(1);
});
});
import React from 'react';
import classNames from 'classnames';
import './index.scss';
import { ButtonProps } from './type';
const Button: React.FC<ButtonProps> = (props) => {
const {
children,
className,
style,
type = 'primary',
variant = 'base',
active = false,
disabled = false,
size = 'medium',
shape = 'round',
onClick = () => { },
...buttonProps
} = props;
return (
<button
className={classNames(
'i-button',
`i-button--type-${type}`,
`i-button--variant-${variant}`,
`i-button--size-${size}`,
`i-button--shape-${shape}`,
active && 'i-button-active',
disabled && 'i-button-disabled',
className,
)}
style={{ ...style }}
disabled={disabled}
onClick={onClick}
{...buttonProps}
>
{children}
</button>
);
};
Button.displayName = 'Button';
export default Button;
import React from 'react';
export interface ButtonProps {
/**
* 内容
*/
children?: React.ReactNode;
/**
* 类名
*/
className?: string;
/**
* 自定义样式
*/
style?: React.CSSProperties;
/**
* 按钮类型,用于描述组件不同的应用场景
* @default primary
*/
type?: 'info' | 'primary' | 'error' | 'warning' | 'success';
/**
* 按钮形式
* @default base
*/
variant?: 'base' | 'outline' | 'dashed' | 'text';
/**
* 是否聚焦状态
* @default false
*/
active?: boolean;
/**
* 是否禁用按钮
* @default false
*/
disabled?: boolean;
/**
* 按钮尺寸
* @default medium
*/
size?: 'small' | 'medium' | 'large';
/**
* 按钮形状
* @default round
*/
shape?: 'square' | 'round' | 'circle';
/**
* 点击按钮触发事件
*/
onClick?: React.MouseEventHandler;
}
运行 yarn test:
三、常见的测试用例
1、生成测试快照
import { render } from '@testing-library/react';
import Button from '../index';
describe('Button 组件测试', () => {
it('create', () => {
const { asFragment } = render(<Button icon="ThePlus" />);
expect(asFragment()).toMatchSnapshot();
});
});
2、校验查询到的子元素
可通过 React Testing Libraryㅤ的 queryByText 这类查询函数查找到需要校验的元素,提供 jest-dom 提供的 toBeInTheDocument 可用来判断是否在文档中:
import { render } from '@testing-library/react';
import Xx from '../index';
describe('Xx 组件测试', () => {
// 匹配指定文本是否在节点中
it('info', () => {
const { queryByText } = render(<Xx info="加载中" />);
expect(queryByText('加载中')).toBeInTheDocument();
})
// 匹配指定类名的节点
it('maxLength', () => {
const { container } = render(<Xx maxLength={15} />);
expect(container.querySelector('.i-input--limit')).toBeInTheDocument();
})
// 匹配指定 alt 的节点
it('image alt', () => {
const { getByAltText } = render(<Xx image='https://picsum.photos/180/120' alt="test-xx" />);
expect(getByAltText('test-xx').src).toBe(imageSrc);
});
// 匹配指定 test-id 的节点
it('maxWidth', () => {
const { getByTestId } = render(
<Xx>
<Xx.Item>item1</Xx.Item>
<Xx.Item maxWidth={80} data-testid='test-item'>item2</Xx.Item>
<Xx.Item>item3</Xx.Item>
</Xx>
);
expect(getByTestId('test-item')).toHaveStyle('max-width: 80px;');
});
});
还可以套用一层 firstChild
、lastChild
的封装来简化同一类匹配方式的代码:
import { render } from '@testing-library/react';
import Badge from '../index';
describe('Badge 组件测试', () => {
function renderSup(badge) {
const { container } = render(badge);
return container.lastChild;
}
it('count', () => {
expect(renderSup(<Badge />)).toHaveTextContent('0');
expect(renderSup(<Badge count='new' />)).toHaveTextContent('new');
});
});
3、根据传入属性校验类名
import { render } from '@testing-library/react';
import Xx from '../index';
describe('Xx 组件测试', () => {
it('shape', () => {
const { container } = render(<Xx shape="round">L</Xx>);
expect(container.firstChild).toHaveClass('i-xx__shape-round');
})
});
4、根据传入属性校验样式
import { render } from '@testing-library/react';
import Xx from '../index';
describe('Xx 组件测试', () => {
it('size', () => {
const { container } = render(<Xx size={24}>L</Xx>);
expect(container.firstChild).toHaveStyle('width: 24px');
})
});
5、测试传入事件是否生效
通过 Jest 的 Mock 函数来确保回调函数如期调用:
import { render, fireEvent } from '@testing-library/react';
import Button from '../index';
describe('Button 组件测试', () => {
it('onClick', () => {
const clickFn = jest.fn();
const { container } = render(<Button onClick={clickFn} />);
fireEvent.click(container.firstChild);
expect(clickFn).toBeCalledTimes(1);
// 或
// expect(clickFn).toHaveBeenCalled();
});
});
6、测试禁用事件是否生效
import { render, fireEvent } from '@testing-library/react';
import Button from '../index';
describe('Button 组件测试', () => {
it('disabled', () => {
const clickFn = jest.fn();
const { container } = render(<Button disabled onClick={clickFn} />);
expect(container.firstChild).toBeDisabled();
fireEvent.click(container.firstChild);
expect(clickFn).toBeCalledTimes(0);
});
});
7、测试模拟键鼠操作生效
通过 userEvent 来模拟用户操作,测试事件是否生效:
import { render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Input from '../index';
describe('Input 组件测试', () => {
it('keyDown', async () => {
const onKeydownFn = jest.fn();
const onEnterFn = jest.fn();
const { container } = render(
<Input onKeyDown={onKeydownFn} onEnter={onEnterFn} />,
);
const InputDOM = container.firstChild.firstChild;
const user = userEvent.setup()
await user.click(InputDOM)
await user.keyboard('abc{enter}')
expect(onEnterFn).toBeCalled();
expect(onKeydownFn).toBeCalled();
});
});
更多测试用例可参考 iDesign React 代码。