前端单元测试:Jest从入门到精通

前言

在当今快节奏的前端开发中,单元测试毫无疑问是保障代码质量的重要防线。作为 Facebook 开源的测试框架,Jest 以其零配置、强大功能和开发者友好性,成为前端测试的首选工具。本文将带你掌握 Jest 单元测试的核心技能,一起写更优秀的代码吧!

一、Jest 核心特性

  1. 零配置:开箱即用,大部分项目无需配置

  2. 快照测试:轻松捕获组件渲染结构

  3. 异步支持:完善处理 Promise、async/await

  4. Mock 系统:强大的函数/模块模拟能力

  5. 代码覆盖率:内置覆盖率报告生成

二、安装Jest

npm install --save-dev jest
# 或
yarn add --dev jest

三、常见的 Jest 命令行操作


1、 只会跑测试未通过的用例,再次点击 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、 只会运行之前运行失败的测试文件,但提供更交互式的体验。

四、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(<Button newProp="value" />)

unmount手动卸载组件 例:const { unmount } = render(...); unmount()

Dom查询:

screen

查询类型函数适用场景示例
单元素查询getBy..元素必须存在,否则报错
screen.getByText('Submit')   //按文本
screen.getByRole('button')        // 按 ARIA 角色screen.getByLabelText('Username') // 按关联标签
queryBy..元素可能不存在(返回 null
findBy..异步等待元素出现(返回 Promise)
多元素查询getAllBy..返回数组(至少 1 个元素)
queryAllBy..返回数组(允许空数组)
findAllBy..异步等待多个元素
用户交互模拟:
函数作用示例
fireEvent触发 DOM 事件fireEvent.click(button)
userEvent更接近真实用户行为(需额外安装)userEvent.type(input, 'Hello')
 异步操作处理:
函数作用
waitFor等待异步操作完成
waitForElementToBeRemoved等待元素从 DOM 移除
调试工具:
函数作用
debug打印当前 DOM 结构
logRoles

查看元素的 ARIA 角色

其他:
函数作用
within限定查询范围(类似 screen 的子集)
act包裹可能触发状态更新的代码(通常已内置在 RTL 中)

五、第一个测试用例

先从简单的开始吧!

1、先写一个简单的函数作为被测对象

export function add(a, b) {
  return a + b;
}

2、编写测试脚本

注意:文件命名需要以.test.js 或 .spec.js 结尾,或将文件放于 __tests__ 文件夹中

(Jest 默认寻找以 .test.js 或 .spec.js 结尾的文件,或者位于 __tests__ 文件夹中的文件)

// utils.test.js

import { 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 mock
jest.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', () => () => (
  <div>Mocked Child</div>
));

test('renders with mock', () => {
  render(<ParentComponent />);
  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.jsx

import React from 'react';

export default function Button({ onClick, children }) {
  return (
    <button 
      onClick={onClick}
      className="primary-btn"
      data-testid="action-button"
    >
      {children}
    </button>
  );
}
// Button.test.jsx

import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import Button from './Button';

describe('Button Component', () => {
  it('renders correctly with children', () => {
    render(<Button>Click Me</Button>);
    expect(screen.getByTestId('action-button'))
      .toHaveTextContent('Click Me');
  });

  it('triggers onClick callback', () => {
    const handleClick = jest.fn();
    render(<Button onClick={handleClick}>OK</Button>);
    
    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(<Button>Click Me</Button>);
  expect(screen.getByTestId('action-button'))
    .toHaveTextContent('Click Me');
});
  • 渲染 <Button> 组件,并传入子文本 "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(<Button onClick={handleClick}>OK</Button>);
  
  fireEvent.click(screen.getByRole('button'));
  expect(handleClick).toHaveBeenCalledTimes(1);
});
  • 创建一个模拟函数 handleClick = jest.fn()

  • 渲染带 onClick 处理函数的 <Button>组件

  • 使用 fireEvent.click() 模拟点击按钮(通过 getByRole 查找按钮元素)

  • 断言 handleClick 被调用了 1 次

2、高阶组件测试技巧

测试高阶主键的关键点:

  1. 验证渲染的组件是否正确

  2. 测试注入的 props 是否符合预期

  3. 检查 HOC 添加的功能是否正常工作

  4. 确保上下文和依赖正确处理

  5. 验证静态方法和显示名称是否正确保留

示例:测试高阶组件的基本渲染

import React from 'react';
import { render } from '@testing-library/react';
import withEnhancement from './withEnhancement';

// 创建一个简单的测试组件
const TestComponent = ({ value }) => <div>{value}</div>;

// 应用高阶组件
const EnhancedComponent = withEnhancement(TestComponent);

test('HOC 应该正确渲染包装的组件', () => {
  const { getByText } = render(<EnhancedComponent value="test" />);
  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 }) => <div>{value}</div>;
  • 这是一个简单的"哑组件"(dumb component),只接收一个 value prop 并渲染它

  • 我们使用这个简单组件来测试高阶组件的行为,因为它没有自己的复杂逻辑

  • 这样的测试组件有助于隔离 HOC 的行为,避免被组件自身逻辑干扰

3)应用高阶组件

const EnhancedComponent = withEnhancement(TestComponent);
  • 这里我们调用 withEnhancement 高阶组件函数,传入我们的 TestComponent

  • 结果是创建了一个新的增强组件 EnhancedComponent

  • 这个新组件应该包含 withEnhancement 提供的所有增强功能

4)测试用例

test('HOC 应该正确渲染包装的组件', () => {
  const { getByText } = render(<EnhancedComponent value="test" />);
  expect(getByText('test')).toBeInTheDocument();
});

测试描述:

'HOC 应该正确渲染包装的组件' - 这个描述清楚地说明了我们正在测试高阶组件是否正确地渲染了它包装的组件

渲染组件:

render(<EnhancedComponent value="test" />):

  • 使用 @testing-library/react 的 render 方法渲染增强后的组件

  • 我们传递 value="test" 作为 prop,这将传递给原始的 TestComponent

获取查询方法:

const { getByText } = render(...):

  • render 方法返回一个对象,包含多种查询方法

  • 我们解构出 getByText,它允许我们通过文本内容查找元素

断言:

expect(getByText('test')).toBeInTheDocument():

  • getByText('test') 查找包含文本 "test" 的元素

  • toBeInTheDocument() 断言该元素确实存在于渲染的 DOM 中

  • 如果这个断言通过,说明:

    1. 高阶组件正确地渲染了包裹的 TestComponent

    2. value prop 被正确地传递给了 TestComponent

    3. 整个组件层次结构按预期工作

八、异步代码测试

1、api请求测试

// api.js

export async function fetchUser(id) {
  const response = await fetch(`/users/${id}`);
  if (!response.ok) throw new Error('Network error');
  return response.json();
}
// api.test.js

import { 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 server
afterEach(() => 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/elseswitch 等分支是否都被覆盖。

  • % Funcs(函数覆盖率):有多少函数被调用过。

  • % Lines(行覆盖率):有多少行代码被执行过。

  • Uncovered Line #s:未被覆盖的行号。

html报告(更详细,位于 coverage/lcov-report/index.html):

  • 不同颜色标注覆盖/未覆盖代码:

    • 绿色:已覆盖

    • 红色:未覆盖

    • 黄色:部分覆盖(如分支未完全覆盖)

操作命令/配置
生成报告jest --coverage
自定义阈值coverageThreshold
排除文件collectCoverageFrom
查看详情打开 coverage/lcov-report/index.html
提高覆盖率补全分支测试、错误测试、边界测试

记住:好的测试不是追求 100% 覆盖率,而是在关键路径上建立可靠的防护网。开始为你的项目编写测试吧,代码质量提升的效果会让你惊喜!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值