Skip to main content

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 中添加:
package.json
{
"scripts": {
"test": "jest"
}
}
  • 在根目录的 babel.config.cjs 配置以下内容:
babel.config.cjs
module.exports = {
presets: [
'@babel/preset-env',
['@babel/preset-react', {runtime: 'automatic'}],
],
};

点击查看 TS 支持配置

2、API

react-test-renderer 提供一个 React 渲染器 TestRenderer,用于将 React 组件渲染成纯 JavaScript 对象,调用 TestRenderercreate 方法并传入要 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、基本用法

src/components/Link/__test__/index.test.tsx
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();
});

运行 yarn test,生成测试快照:

src/components/Link/__test__/__snapshots__/index.test.tsx.snap
// 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 中设置:

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

jest-setup.js
import '@testing-library/jest-dom'

然后在 jest.config.js 中配置:

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 文件:

file.mock.js
module.exports = {};

然后在 jest.config.js 中配置:

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)
getThrow error返回单个节点Throw error
getAllThrow error返回节点数组返回节点数组
query返回 null返回单个节点Throw error
queryAll返回 []返回节点数组返回节点数组
findThrow error返回单个节点Throw error
findAllThrow error返回节点数组返回节点数组

Query 使用后缀:

举个例子:

// 匹配指定文本是否在节点中
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

元素通用验证:

元素内容验证:

表单属性验证:

举个例子:

it('children', () => {
const { queryByText } = render(<Button>foo</Button>);
expect(queryByText('foo')).toBeInTheDocument();
});

5、user-event API

举个例子:

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、基本用法

src/components/Button/__test__/index.test.tsx
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);
});
});

运行 yarn test:

三、常见的测试用例

1、生成测试快照

index.test.tsx
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 可用来判断是否在文档中:

index.test.tsx
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;');
});
});

还可以套用一层 firstChildlastChild 的封装来简化同一类匹配方式的代码:

index.test.tsx
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、根据传入属性校验类名

index.test.tsx
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、根据传入属性校验样式

index.test.tsx
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 函数来确保回调函数如期调用:

index.test.tsx
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、测试禁用事件是否生效

index.test.tsx
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 代码。