前端单元测试:Jest从入门到精通
前言
在当今快节奏的前端开发中,单元测试毫无疑问是保障代码质量的重要防线。作为 Facebook 开源的测试框架,Jest 以其零配置、强大功能和开发者友好性,成为前端测试的首选工具。本文将带你掌握 Jest 单元测试的核心技能,一起写更优秀的代码吧!
一、Jest 核心特性
-
零配置:开箱即用,大部分项目无需配置
-
快照测试:轻松捕获组件渲染结构
-
异步支持:完善处理 Promise、async/await
-
Mock 系统:强大的函数/模块模拟能力
-
代码覆盖率:内置覆盖率报告生成
二、安装Jest
npm install --save-dev jest# 或yarn add --dev jest
三、常见的 Jest 命令行操作
1、f 只会跑测试未通过的用例,再次点击 f 会取消当前模式。
2、o 只监听已改变的文件,如果存在多个测试文件,可以开启,会与当前 git 仓库中的提交进行比较,需要使用 git 来监听哪个文件修改了,也可以将 --watchAll 改为 --watch 只会运行修改的文件。
3、a 运行所有测试,如果在 watch 模式中使用了 f 或 o ,使用 a 可以恢复运行所有测试。
4、u 用于更新 Jest 快照测试中的快照。如果更改了渲染组件的输出,可以使用此命令更新快照。
5、w 显示 Jest watch 模式中的所有可用命令和选项的列表。
6、q 退出 Jest 的 watch 模式。
7、i 只会运行之前运行失败的测试文件,但提供更交互式的体验。
四、Jest核心功能
1、测试命名规范
describe(\'模块/组件名称\', () => { it(\'应该...当...\', () => { ... }); it(\'不应该...当...\', () => { ... });});
2、测试优化技巧
// 跳过慢测试describe.only(\'关键功能\', () => { ... });// 并行优化describe.concurrent(\'性能测试\', () => { ... });
3、常用匹配器(断言方法)
toBe
expect(1).toBe(1)
toEqual
expect({a:1}).toEqual({a:1})
toBeTruthy
expect(\'text\').toBeTruthy()
toContain
expect([\'a\',\'b\']).toContain(\'a\')
toMatchSnapshot
expect(component).toMatchSnapshot()
4、React Testing Library(提供用于测试 React 组件的函数)
渲染相关:
render
:是一个核心函数,它的作用是将 React 组件渲染到一个虚拟的 DOM 环境中,以便我们能够测试组件的行为和输出
rerender:
重新渲染组件(更新 props)例:rerender()
unmount:
手动卸载组件 例:const { unmount } = render(...); unmount()
Dom查询:
screen:
getBy..
screen.getByText(\'Submit\') //按文本screen.getByRole(\'button\') // 按 ARIA 角色screen.getByLabelText(\'Username\') // 按关联标签
queryBy..
null
)findBy..
getAllBy..
queryAllBy..
findAllBy..
用户交互模拟:
fireEvent
fireEvent.click(button)
userEvent
userEvent.type(input, \'Hello\')
异步操作处理:
waitFor
waitForElementToBeRemoved
调试工具:
debug
logRoles
查看元素的 ARIA 角色
其他:
within
screen
的子集)act
五、第一个测试用例
先从简单的开始吧!
1、先写一个简单的函数作为被测对象
export function add(a, b) { return a + b;}
2、编写测试脚本
注意:文件命名需要以.test.js
或 .spec.js
结尾,或将文件放于 __tests__
文件夹中
(Jest 默认寻找以 .test.js
或 .spec.js
结尾的文件,或者位于 __tests__
文件夹中的文件)
// utils.test.jsimport { add } from \'./utils\';describe(\'add function\', () => { it(\'correctly adds two numbers\', () => { expect(add(1, 2)).toBe(3); expect(add(0.1, 0.2)).toBeCloseTo(0.3); // 处理浮点数 }); it(\'throws error with non-number args\', () => { expect(() => add(\'1\', 2)).toThrow(\'参数必须是数字\'); });});
代码详解:
1)导入要测试的函数
import { add } from \'./utils\';
这行代码从 ./utils
模块中导入 add
函数,准备对其进行测试。
2)测试套件描述
describe(\'add function\', () => {
describe
是 Jest 的一个全局函数,用于将一组相关的测试用例组织在一起。这里创建了一个名为 \"add function\" 的测试套件。
3)第一个测试用例
it(\'correctly adds two numbers\', () => { expect(add(1, 2)).toBe(3); expect(add(0.1, 0.2)).toBeCloseTo(0.3); // 处理浮点数});
-
it
是 Jest 的测试用例函数,描述这个测试用例的目的 -
第一个
expect
断言add(1, 2)
的结果应该等于 3 -
第二个
expect
测试浮点数相加,使用toBeCloseTo
来避免 JavaScript 浮点数精度问题
4)第二个测试用例
it(\'throws error with non-number args\', () => { expect(() => add(\'1\', 2)).toThrow(\'参数必须是数字\');});
这个测试用例验证当传入非数字参数时:
-
使用箭头函数包装调用
add(\'1\', 2)
-
期望它会抛出错误,且错误信息包含 \"参数必须是数字\"
3、运行测试
npm test
六、Mock
在 Jest 中,Mock(模拟) 是一种重要的测试技术,用于隔离被测代码的依赖(如函数、模块、API 请求等),模拟函数的实现、捕获函数调用或替代模块行为,从而让测试更可控、更聚焦
1、Jest Mock 的三种主要方式
(1) 手动 Mock:替换整个模块
// __mocks__/axios.js(与 axios 模块同级目录)export default { get: jest.fn(() => Promise.resolve({ data: \'mock data\' })),};// 测试文件jest.mock(\'axios\'); // 自动使用 __mocks__ 下的 mock 实现import axios from \'axios\';test(\'mock axios\', async () => { const res = await axios.get(\'/api\'); expect(res.data).toBe(\'mock data\');});
适用场景:替换第三方库或自定义模块的完整实现。
代码详解:
1) 创建 Mock 文件
// __mocks__/axios.js(与 node_modules/axios 同级目录)export default { get: jest.fn(() => Promise.resolve({ data: \'mock data\' })),};
-
__mocks__/axios.js
:
Jest 约定,当调用jest.mock(\'axios\')
时,会自动加载该文件替换真实的axios
模块。 -
get: jest.fn()
:
将axios.get
方法替换为一个 Jest Mock 函数,直接返回一个成功的 Promise({ data: \'mock data\' }
)。
2) 在测试文件中启用 Mock
// 测试文件jest.mock(\'axios\'); // 告诉 Jest 使用 __mocks__/axios.js 的模拟实现import axios from \'axios\'; // 此时导入的已经是 mock 版本
-
jest.mock(\'axios\')
:
通知 Jest 接管axios
模块,所有导入的axios
都会指向__mocks__/axios.js
的模拟实现。
3) 测试中使用 Mock
test(\'mock axios\', async () => { const res = await axios.get(\'/api\'); // 调用 mock 的 get 方法 expect(res.data).toBe(\'mock data\'); // 验证返回的模拟数据});
-
axios.get(\'/api\')
:
实际调用的是__mocks__/axios.js
中定义的get
方法,不会发送真实请求。 -
断言:
直接验证模拟返回的数据是否符合预期。
(2) 使用 jest.fn()
:模拟函数
// 模拟一个函数const mockFn = jest.fn();mockFn.mockReturnValue(42); // 固定返回值//测试用例test(\'mock function\', () => { expect(mockFn()).toBe(42); expect(mockFn).toHaveBeenCalled();});// 模拟模块中的特定方法jest.mock(\'./module\', () => ({ fetchData: jest.fn().mockRejectedValue(new Error(\'Failed\')),}));
常用方法:
-
mockFn.mockReturnValue(value)
:固定返回值。 -
mockFn.mockResolvedValue(value)
:模拟 Promise 成功。 -
mockFn.mockRejectedValue(error)
:模拟 Promise 失败。 -
mockFn.mockImplementation(() => { ... })
:自定义实现。
(3) 使用 jest.spyOn()
:监听真实函数
const obj = { fetch: () => \'real data\',};test(\'spyOn\', () => { const spy = jest.spyOn(obj, \'fetch\') .mockReturnValue(\'mock data\'); // 临时替换实现 expect(obj.fetch()).toBe(\'mock data\'); spy.mockRestore(); // 恢复原始实现});
特点:
-
可以保留原始函数,仅临时修改行为。
-
必须用
mockRestore()
恢复,避免影响其他测试。
总结对比
jest.mock()
jest.fn()
jest.spyOn()
mockRestore()
2、Mock 的常见应用场景
(1) 模拟 API 请求
// 使用 jest.mock + axios mockjest.mock(\'axios\');import axios from \'axios\';test(\'fetch data\', async () => { axios.get.mockResolvedValue({ data: { id: 1 } }); const res = await fetchUser(); expect(axios.get).toHaveBeenCalledWith(\'/users/1\');});
(2) 模拟 React 组件
jest.mock(\'./ChildComponent\', () => () => ( Mocked Child));test(\'renders with mock\', () => { render(); expect(screen.getByText(\'Mocked Child\')).toBeInTheDocument();});
(3) 模拟定时器(setTimeout/setInterval)
jest.useFakeTimers();test(\'timer\', () => { const callback = jest.fn(); setTimeout(callback, 1000); jest.runAllTimers(); // 立即执行所有定时器 expect(callback).toHaveBeenCalled();});
七、React 组件测试深度解析
1、普通组件渲染测试
//Button.jsximport React from \'react\';export default function Button({ onClick, children }) { return ( );}
// Button.test.jsximport React from \'react\';import { render, screen, fireEvent } from \'@testing-library/react\';import Button from \'./Button\';describe(\'Button Component\', () => { it(\'renders correctly with children\', () => { render(); expect(screen.getByTestId(\'action-button\')) .toHaveTextContent(\'Click Me\'); }); it(\'triggers onClick callback\', () => { const handleClick = jest.fn(); render(); fireEvent.click(screen.getByRole(\'button\')); expect(handleClick).toHaveBeenCalledTimes(1); });});
代码详解:
1)导入依赖
import React from \'react\';import { render, screen, fireEvent } from \'@testing-library/react\';import Button from \'./Button\';
-
导入 React 和必要的测试工具
-
从
@testing-library/react
导入render
(渲染组件)、screen
(访问 DOM)和fireEvent
(模拟事件) -
导入要测试的
Button
组件
2)测试套件描述
describe(\'Button Component\', () => {
创建名为 \"Button Component\" 的测试套件,包含所有关于这个组件的测试用例。
3) 第一个测试用例:渲染测试
it(\'renders correctly with children\', () => { render(); expect(screen.getByTestId(\'action-button\')) .toHaveTextContent(\'Click Me\');});
-
渲染
组件,并传入子文本 \"Click Me\"·
-
使用
screen.getByTestId(\'action-button\')
查找带有data-testid=\"action-button\"
属性的元素 -
断言该元素包含文本 \"Click Me\"
注意:这里假设
Button
组件内部有一个data-testid=\"action-button\"
的属性
4)第二个测试用例:点击事件测试
it(\'triggers onClick callback\', () => { const handleClick = jest.fn(); render(); fireEvent.click(screen.getByRole(\'button\')); expect(handleClick).toHaveBeenCalledTimes(1);});
-
创建一个模拟函数
handleClick = jest.fn()
-
渲染带
onClick
处理函数的 -
使用
fireEvent.click()
模拟点击按钮(通过getByRole
查找按钮元素) -
断言
handleClick
被调用了 1 次
2、高阶组件测试技巧
测试高阶主键的关键点:
-
验证渲染的组件是否正确
-
测试注入的 props 是否符合预期
-
检查 HOC 添加的功能是否正常工作
-
确保上下文和依赖正确处理
-
验证静态方法和显示名称是否正确保留
示例:测试高阶组件的基本渲染
import React from \'react\';import { render } from \'@testing-library/react\';import withEnhancement from \'./withEnhancement\';// 创建一个简单的测试组件const TestComponent = ({ value }) => {value};// 应用高阶组件const EnhancedComponent = withEnhancement(TestComponent);test(\'HOC 应该正确渲染包装的组件\', () => { const { getByText } = render(); expect(getByText(\'test\')).toBeInTheDocument();});
代码详解:
1)导入依赖
import React from \'react\';import { render } from \'@testing-library/react\';import withEnhancement from \'./withEnhancement\';
-
React
:必须导入,因为我们要使用 JSX 语法 -
render
:从@testing-library/react
导入,用于渲染组件进行测试 -
withEnhancement
:这是我们要测试的高阶组件,从本地文件导入
2) 创建测试组件
const TestComponent = ({ value }) => {value};
-
这是一个简单的\"哑组件\"(dumb component),只接收一个
value
prop 并渲染它 -
我们使用这个简单组件来测试高阶组件的行为,因为它没有自己的复杂逻辑
-
这样的测试组件有助于隔离 HOC 的行为,避免被组件自身逻辑干扰
3)应用高阶组件
const EnhancedComponent = withEnhancement(TestComponent);
-
这里我们调用
withEnhancement
高阶组件函数,传入我们的TestComponent
-
结果是创建了一个新的增强组件
EnhancedComponent
-
这个新组件应该包含
withEnhancement
提供的所有增强功能
4)测试用例
test(\'HOC 应该正确渲染包装的组件\', () => { const { getByText } = render(); expect(getByText(\'test\')).toBeInTheDocument();});
测试描述:
\'HOC 应该正确渲染包装的组件\'
- 这个描述清楚地说明了我们正在测试高阶组件是否正确地渲染了它包装的组件
渲染组件:
render()
:
-
使用
@testing-library/react
的render
方法渲染增强后的组件 -
我们传递
value=\"test\"
作为 prop,这将传递给原始的TestComponent
获取查询方法:
const { getByText } = render(...)
:
-
render
方法返回一个对象,包含多种查询方法 -
我们解构出
getByText
,它允许我们通过文本内容查找元素
断言:
expect(getByText(\'test\')).toBeInTheDocument()
:
-
getByText(\'test\')
查找包含文本 \"test\" 的元素 -
toBeInTheDocument()
断言该元素确实存在于渲染的 DOM 中 -
如果这个断言通过,说明:
-
高阶组件正确地渲染了包裹的
TestComponent
-
value
prop 被正确地传递给了TestComponent
-
整个组件层次结构按预期工作
-
八、异步代码测试
1、api请求测试
// api.jsexport async function fetchUser(id) { const response = await fetch(`/users/${id}`); if (!response.ok) throw new Error(\'Network error\'); return response.json();}
// api.test.jsimport { fetchUser } from \'./api\';import { setupServer } from \'msw/node\';import { rest } from \'msw\';const server = setupServer( rest.get(\'/users/1\', (req, res, ctx) => { return res(ctx.json({ id: 1, name: \'Alice\' })); }));beforeAll(() => server.listen());afterEach(() => server.resetHandlers());afterAll(() => server.close());describe(\'fetchUser\', () => { it(\'returns user data\', async () => { const user = await fetchUser(1); expect(user).toEqual({ id: 1, name: \'Alice\' }); }); it(\'handles network errors\', async () => { server.use( rest.get(\'/users/1\', (req, res, ctx) => { return res(ctx.status(500)); }) ); await expect(fetchUser(1)).rejects.toThrow(\'Network error\'); });});
Mock Service Worker (MSW) : 是一个用于拦截和模拟 HTTP 请求的库,非常适合在测试中模拟 API 行为
代码详解:
1)核心依赖
import { fetchUser } from \'./api\'; // 要测试的 API 函数import { setupServer } from \'msw/node\'; // Node 环境下的 MSW 服务import { rest } from \'msw\'; // REST API 请求拦截工具
2) 设置 Mock Server
const server = setupServer( rest.get(\'/users/1\', (req, res, ctx) => { return res(ctx.json({ id: 1, name: \'Alice\' })); }));
-
setupServer
: 创建一个模拟的 API 服务。 -
rest.get
: 拦截GET /users/1
请求,并返回模拟响应{ id: 1, name: \'Alice\' }
。 -
ctx.json()
: 构造 JSON 格式的响应体。
3) 生命周期管理
beforeAll(() => server.listen()); // 启动 mock serverafterEach(() => server.resetHandlers()); // 重置 mock(避免测试间污染)afterAll(() => server.close()); // 关闭 mock server
-
server.listen()
: 启动拦截。 -
server.resetHandlers()
: 每个测试后清理 mock(避免跨测试影响)。 -
server.close()
: 所有测试完成后关闭。
4) 测试正常请求
it(\'returns user data\', async () => { const user = await fetchUser(1); expect(user).toEqual({ id: 1, name: \'Alice\' });});
-
调用
fetchUser(1)
,它会发送GET /users/1
请求。 -
被 MSW 拦截后返回模拟数据,断言结果是否符合预期。
5) 测试异常请求
it(\'handles network errors\', async () => { server.use( rest.get(\'/users/1\', (req, res, ctx) => { return res(ctx.status(500)); // 模拟服务器错误 }) ); await expect(fetchUser(1)).rejects.toThrow(\'Network error\');});
-
server.use
: 动态覆盖之前的 mock,返回 500 错误。 -
rejects.toThrow
: 验证fetchUser
是否正确处理了错误。
扩展场景:
模拟延迟
rest.get(\'/users/1\', (req, res, ctx) => { return res(ctx.delay(100), ctx.json({ id: 1 })); // 延迟 100ms});
动态路径参数
rest.get(\'/users/:id\', (req, res, ctx) => { const { id } = req.params; return res(ctx.json({ id }));});
九、测试覆盖率
1、生成覆盖率报告
npx jest --coverage
输出示例:
----------------|---------|----------|---------|---------|-------------------File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s----------------|---------|----------|---------|---------|-------------------src/ | 80 | 75 | 90 | 85 | utils.js | 100 | 100 | 100 | 100 | api.js | 50 | 50 | 66 | 60 | 15-20, 25----------------|---------|----------|---------|---------|-------------------
-
% Stmts(语句覆盖率):代码中有多少语句被执行过。
-
% Branch(分支覆盖率):
if/else
、switch
等分支是否都被覆盖。 -
% Funcs(函数覆盖率):有多少函数被调用过。
-
% Lines(行覆盖率):有多少行代码被执行过。
-
Uncovered Line #s:未被覆盖的行号。
html报告(更详细,位于 coverage/lcov-report/index.html
):
-
不同颜色标注覆盖/未覆盖代码:
-
绿色:已覆盖
-
红色:未覆盖
-
黄色:部分覆盖(如分支未完全覆盖)
-
jest --coverage
coverageThreshold
collectCoverageFrom
coverage/lcov-report/index.html
记住:好的测试不是追求 100% 覆盖率,而是在关键路径上建立可靠的防护网。开始为你的项目编写测试吧,代码质量提升的效果会让你惊喜!