使用 Jest 进行单元测试

Use jest for unit test

nojsja 2023-05-23
字数:3.4k丨 阅读时间:14 分钟

使用 Jest 进行单元测试

目录

一、Jest 简介

Jest 是 Facebook 出品的一款 JavaScript 测试框架,它集成了断言、JSDOM、覆盖率报告等功能,使用起来非常简单,而且对 React 的支持也非常好。

依赖库版本说明:

1
2
3
4
5
6
7
8
{
"msw": "^1.0.0",
"jest": "^26.6.3",
"react-test-renderer": "^17.0.0",
"@testing-library/react": "^11.2.6",
"@testing-library/react-hooks": "^8.0.1",
"@testing-library/react-native": "^11.4.0",
}

安装 Jest:

1
npm i -D jest @types/jest

安装 Mock Server 工具:

1
npm i -D msw

安装用于 React/React Native/React Hooks 的测试套件:

1
npm i -D jsdom react-test-renderer @testing-library/react @testing-library/react-hooks @testing-library/react-native

二、Jest 配置编写

建议参考现有 Web/MRN 项目进行配置,@mrn/react-native 自带 jest,一般不用手动安装。

本文示例仓库: empower-sgfe-tools/business-config

如果一个仓库同时包含 mrn 和 web 两个平台的代码,那么需要在根目录下创建一个 jest.config.js 文件,用于配置不同平台下 Jest 的运行环境和测试文件的匹配规则。

jest.config.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
const config = {
projects: [
{
displayName: 'common-channels(web)',
// 声明测试环境,Web 代码是在 jsdom 模拟环境下运行,
// 如果声明为 node 会出现 window is not defined 等平台 API 访问错误
testEnvironment: 'jsdom',
testEnvironmentOptions: {
// 配置测试环境的 URL,用于网络请求 Mock,这里如果不设置使用默认值可能会报错
"url": "http://localhost/"
},
transformIgnorePatterns: [],
// 声明测试脚本入口地址
testMatch: ['<rootDir>/examples/common-channels/web/**/__tests__/**/*.(ts|tsx)'],
},
{
displayName: 'common-channels(mrn)',
preset: "@mrn/react-native",
globals: {
'ts-jest': {
babelConfig: true,
isolatedModules: true
},
'NODE_ENV': 'test'
},
// 声明测试环境,RN 代码是在 node 模拟环境下运行
testEnvironment: 'node',
testEnvironmentOptions: {
// 配置测试环境的 URL,用于网络请求 Mock,这里如果不设置使用默认值可能会报错
url: "https://pieapi-empower.meituan.com"
},
transformIgnorePatterns: [],
moduleDirectories: [
'node_modules'
],
verbose: true,
moduleNameMapper: {
'^react-navigation$': '@mrn/react-navigation',
'^react-native$': '@mrn/react-native'
},
// 声明测试脚本入口地址
testMatch: ['<rootDir>/examples/common-channels/mrn/**/__tests__/**/*.(ts|tsx)'],
},
],
};

module.exports = config;

三、Mock Server 配置

使用 Jest 测试时,由于无法访问真实的后端服务或者后端服务返回的数据无法覆盖所有测试场景,因此需要使用 Mock Server 来模拟后端服务的响应。

可以使用 MSW (Mock Service Worker) 拦截和模拟 Axios 发出的请求,并在测试中验证是否正确地处理了响应。

MSW 是一个用于模拟 HTTP 请求和响应的库,它可以用于测试前端代码或与后端通信的 API。它拦截浏览器发出的网络请求,并将其重定向到本地的服务中,从而模拟服务器响应。同时,它提供了一组 API 来编写和配置模拟响应逻辑。

定义 Mock Api 并创建 Mock Server:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import {setupServer} from 'msw/node';
import {rest} from 'msw';

export const mockServer = setupServer(
rest.post('/api/test', (req, res, ctx) => {
// 模拟 300ms 延迟
return res(ctx.delay(300), ctx.json({
code: 0,
data: [
{
// 渠道 id
channelId: 100,
channelName: 'MT',
status: 1,
isOpenApp: true,
logo: DEFAULT_CHANNEL_ICON_URL + '?channelId=100',
},
...
]
}));
}),
rest.post('/api/test2', (req, res, ctx) => {
// 模拟 300ms 延迟
return res(ctx.delay(300), ctx.json({
code: 0,
data: [
{
channelId: 100,
channelAppId: 100,
channelName: 'MT',
channelPoiId: '101010',
channelPoiName: '测试门店',
},
...
]
}));
}),
);

在测试样例文件中引入 Mock Server:

1
2
3
4
5
6
7
8
9
10
11
import {mockServer} from 'path/to/mock-server';

// 测试之前启动 Mock Server
beforeAll(() => mockServer.listen());
// 每个测试之后重置 Mock Server,防止影响其他测试
afterEach(() => mockServer.resetHandlers());
// 测试结束后关闭 Mock Server
afterAll(() => mockServer.close());

test('Render - ChannelIcon', async () => {...});
...

四、Jest Mock 说明

Jest Mock 是 Jest 测试框架中一个用于模拟(mock)模块或对象的函数,常用于单元测试和集成测试中。

使用Jest Mock 可以:

  • 模拟依赖项,使得被测试代码的行为可以可控、可预测。
  • 在测试中对模块或对象进行替换,并验证被测试代码的交互是否正确。
  • 针对特定的测试场景创建 Mock 对象,来帮助测试一些难以触发的异常情况。

总的来说,当被测试代码依赖于外部 API 或数据库等资源时,使用 Jest Mock 可以模拟这些资源的返回结果,从而避免在测试过程中对真实资源进行访问,提高了测试效率和稳定性。

一个 Jest Mock 的使用示例如下:例子中修改了模块 import 路径,避免加载 @sgfe/enhanced-axios/mrn 模块(依赖 RN NativeModules),从而使用通用模块 axios 发送请求。由于所有 API 服务都是模拟的,因此对请求结果无影响,我们只需要发送请求这一个过程来获取 Mock 数据。

1
2
3
4
5
6
7
/**
* @description 测试环境下不能访问 MRN NativeModules,因此修改 axios 的引入入口,对结果无影响
*/
jest.mock('@sgfe/enhanced-axios/mrn', () => ({
__esModule: true,
...jest.requireActual('axios'),
}));

五、组件快照测试

快照测试原理是对组件的渲染结果进行存储,然后在后续的测试中对组件的渲染结果进行比对,如果在相同的输入条件下渲染结果发生了变化,则测试失败。

快照测试本质上属于黑盒测试,它只关心组件的渲染结果,而不关心组件内部的实现细节,因此快照测试适用于 UI 组件的测试。

必要情况下更新测试样例、源代码会导致快照前后生成不一致,这时候就需要手动调用 jest -u 来强制重新生成快照覆盖当前快照,每次生成快照后最好手动确认下生成结果是否符合预期。

React Native 和 React 平台测试方法是一致的,都依赖 react-test-renderer 这个库。不过对于 React Native 平台代码,目前只能测试 UI 渲染相关的部分,如果待测试代码依赖 NativeModules 等原生模块能力的话可能报错,这种情况建议完整安装 React Native 环境后再测试。

使用测试套件时,导致 React 状态更新的代码应该包裹到 act(…) 中,否则可能出现异步更新的状态无法被正确捕获,无法保证测试准确性。 如果被测试组件内部维护状态更新,则 act 直接包裹整个 render 测试函数,如果被测试组件暴露的方法导致内部状态更新,则用 act 包裹被测试组件暴露的方法。

一个快照测试的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import React from 'react';
import renderer from 'react-test-renderer';
import {ChannelIcon} from 'path/to/library';

...

test('SnapShot - ChannelIcon(with ChannelInfoProvider)', async () => {
await renderer.act(async () => {
const component = renderer.create(
<ChannelInfoProvider>
<ChannelIcon channelId={100} size={32} className="my-class" />
</ChannelInfoProvider>
);
await delay(100);
// 接口未返回,比对第一个快照
expect(component.toJSON()).toMatchSnapshot();
await delay(300);
// 接口已返回,比对第二个快照
expect(component.toJSON()).toMatchSnapshot();
});
});

六、组件渲染结果测试

原理是使用 render 函数将组件渲染为虚拟 DOM,然后通过对应平台测试套件提供的 API 来查询组件的渲染结果,对渲染结果进行断言。

6.1 Web 平台

Web 平台使用 @testing-library/react 库来进行组件渲染结果测试,使用方式比较贴近原生 Web API,相对易用。

在测试时,导致 React 状态更新的代码应该包裹到 act(…) 中,否则可能出现异步更新的状态无法被正确捕获,无法保证测试准确性。

如果被测试组件内部维护状态更新,则 act 直接包裹整个 render 函数,如果被测试组件暴露的方法导致内部状态更新,则用 act 包裹被测试组件暴露的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import React from 'react';
import {render, act} from '@testing-library/react';

...

test('DOM - ChannelInfoProvider', async () => {
// act 包裹整个组件和断言逻辑
act(() => {
const {container} = render(
<ChannelInfoProvider>
<span>children</span>
</ChannelInfoProvider>
);

expect(container.querySelector('span')?.textContent).toEqual('children');
});
});

test('DOM - ChannelIcon', async () => {
// act 包裹整个组件和断言逻辑
act(() => {
const {container} = render(<ChannelIcon channelId={100} size={32} className="my-class" />);

expect(container.querySelector('img')?.getAttribute('src')).toEqual(
DEFAULT_CHANNEL_ICON_URL
);
expect(container.querySelector('img')?.getAttribute('style')).toEqual('width: 32px; height: 32px;');
expect(container.querySelector('img')?.getAttribute('class')).toEqual('my-class');
});
});

6.2 React Native 平台

React Native 平台使用 @testing-library/react-native 库来进行组件渲染结果测试。

示例代码中使用了 UNSAFE_getByType 来获取组件内部的子组件实例。

以下是其它常见的查询方法,可根据需要自行使用:

  • getByTestId(id): 通过元素的 testID 属性查找元素;
  • getByText(text): 通过元素的文本内容查找元素;
  • getByPlaceholderText(placeholder): 通过 input 或 textarea 等表单元素的 placeholder 属性查找元素;
  • getByDisplayValue(value): 通过 input、select、textarea 等表单元素的 value 属性查找元素;
  • getByAccessibilityLabel(label): 通过元素的 accessibilityLabel 属性查找元素;
  • getByA11yHint(hint): 通过元素的 accessibilityHint 属性查找元素;
  • getByA11yRole(role): 通过元素的 accessibilityRole 属性查找元素。

在测试时,导致 React 状态更新的代码理论上应该包裹到 act(…) 中,否则可能导致异步更新的状态无法被正确捕获,无法保证测试准确性。
但实际发现包裹后测试代码可能会报错,这种情况下暂时只能去除 act 调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import React from 'react';
import {render} from '@testing-library/react-native';
import {delay} from 'bluebird';

test('Render - ChannelIcon', async () => {
/**
* @link https://callstack.github.io/react-native-testing-library/docs/api#render
**/
const { /* UNSAFE_getByProps, */ UNSAFE_getByType } = render(<ChannelIcon channelId={100} size={32} />, {
wrapper: ChannelInfoProvider,
});
await delay(100);
const component = UNSAFE_getByType(Image);

// 将渲染结果的 DOM 树输出到控制台,便于开发者调试
// debug();
expect(component).toBeDefined();
expect(component.props.style).toMatchObject({ width: 32, height: 32 });
await delay(100);
// 接口未返回
expect(component.props.source.uri).toBe(DEFAULT_CHANNEL_ICON_URL);
await delay(300);
// 接口已返回
expect(component.props.source.uri).toBe(`${DEFAULT_CHANNEL_ICON_URL}?channelId=100`);
});

七、React Hooks 函数测试

React Hooks 函数测试需要使用 @testing-library/react-hooks 库。

React Native 和 React 平台测试方法是一致的,不过对于 React Native 平台代码,如果待测试代码依赖 NativeModules 等原生模块能力的话可能报错,这种情况建议完整安装 React Native 环境后再测试。

renderHook 方法暴露的 API waitForNextUpdate 用于等待下一次 Hook 更新完成,该函数返回一个 Promise,直到下一次渲染发生时才会被 resolve。一个典型的适用例子就是 Hooks 挂载后请求后端接口,然后更新内部数据,最后渲染组件。

我们在测试 Hook 的过程中,可以通过 waitForNextUpdate() 函数来等待数据更新完成,并确保所有相关的状态更新和回调函数都已执行完毕,然后再进行断言。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import React from 'react';
import {delay} from 'bluebird';
import {renderHook} from '@testing-library/react-hooks';

...

test('Hook - useChannel(channel 100,300)', async () => {
const {result, waitForNextUpdate} = renderHook(() => useChannel([100, 300]), {
wrapper: ChannelInfoProvider, // 可以传入父组件,目标组件将作为父组件 children 被渲染
});

// 等待 Hooks 内部状态更新完成
await waitForNextUpdate();
// 模拟触发状态更新动作,并等待下次状态更新完成
// result.current?.action();
// await waitForNextUpdate();

// 对 hooks 返回结果进行断言
expect(result.current?.length).toBe(2);
expect(result.current?.[0]?.channelId).toBe(100);
expect(result.current?.[1]?.channelId).toBe(300);
});

八、测试覆盖度生成

Jest 提供了一个内置的覆盖率报告工具,可以帮助你分析测试代码的覆盖率情况。生成的覆盖率报告包括以下几个方面的信息:

  • 语句覆盖率(statement coverage):代码中被执行的语句所占比例。
  • 分支覆盖率(branch coverage):代码中每个分支被执行的情况所占比例。
  • 函数覆盖率(function coverage):代码中每个函数被调用的情况所占比例。
  • 行覆盖率(line coverage):代码中被执行的行所占比例。

要生成覆盖率报告,需要在运行 Jest 测试时添加 --coverage 参数,例如:

1
jest --coverage

运行命令后,Jest 会生成一个 HTML 文件,包含有关测试覆盖率的详细信息。你可以在浏览器中打开该文件并查看覆盖率报告。

除此之外,你还可以使用第三方工具来对覆盖率报告进行进一步分析和处理。常见的工具包括 Istanbul、Coveralls、Codecov 等。这些工具提供了更加丰富的功能,例如可视化分析、历史趋势分析、与 CI/CD 工具集成等。如果你需要更深入的分析和管理测试覆盖率,可以考虑使用这些工具之一。

[ loading ]⇷⇷