当前位置: 首页 > news >正文

React单元测试

下面,我们来系统的梳理关于 React 单元测试:Jest + React Testing Library 的基本知识点:


一、单元测试概述

1.1 为什么需要单元测试?

  • 保证代码质量:提前发现并修复缺陷
  • 提高可维护性:测试即文档,帮助理解代码功能
  • 支持重构:确保重构不破坏现有功能
  • 减少回归错误:防止新代码破坏已有功能

1.2 React 测试金字塔

单元测试
集成测试
端到端测试
  • 单元测试:测试独立组件或函数(本指南重点)
  • 集成测试:测试多个组件的交互
  • 端到端测试:模拟用户操作测试完整流程

1.3 测试原则 (FIRST)

  • Fast(快速):测试应快速执行
  • Isolated(独立):测试之间互不影响
  • Repeatable(可重复):在任何环境结果一致
  • Self-validating(自验证):测试结果明确(通过/失败)
  • Timely(及时):测试与代码同步编写

二、测试环境搭建

2.1 安装依赖

npm install --save-dev jest @testing-library/react @testing-library/jest-dom @testing-library/user-event

2.2 配置文件

jest.config.js

module.exports = {testEnvironment: 'jsdom',setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],moduleNameMapper: {'\\.(css|less|scss)$': 'identity-obj-proxy',},transform: {'^.+\\.(js|jsx)$': 'babel-jest',},
};

jest.setup.js

import '@testing-library/jest-dom/extend-expect';

2.3 脚本配置

package.json

{"scripts": {"test": "jest","test:watch": "jest --watch","test:coverage": "jest --coverage"}
}

三、Jest 基础

3.1 测试结构

describe('测试套件描述', () => {test('测试用例描述', () => {// 准备 (Arrange)const a = 1;const b = 2;// 执行 (Act)const result = a + b;// 断言 (Assert)expect(result).toBe(3);});
});

3.2 常用匹配器

匹配器用途示例
toBe()严格相等expect(1).toBe(1)
toEqual()深度相等expect({a:1}).toEqual({a:1})
toBeTruthy()真值检查expect(true).toBeTruthy()
toBeFalsy()假值检查expect(false).toBeFalsy()
toContain()包含检查expect(['a','b']).toContain('a')
toHaveLength()长度检查expect('abc').toHaveLength(3)
toThrow()错误检查expect(() => {throw Error()}).toThrow()

3.3 模拟函数

const mockFn = jest.fn();// 基础用法
mockFn('arg1', 'arg2');
expect(mockFn).toHaveBeenCalledWith('arg1', 'arg2');// 返回值模拟
mockFn.mockReturnValue(42);
expect(mockFn()).toBe(42);// 实现模拟
mockFn.mockImplementation((a, b) => a + b);
expect(mockFn(1, 2)).toBe(3);

四、React Testing Library 核心

4.1 核心原则

  • 测试用户行为:而不是实现细节
  • 查询方式:按用户查找元素(文本、标签等)
  • 无障碍优先:使用与用户相同的访问方式

4.2 渲染组件

import { render } from '@testing-library/react';const { container, getByText } = render(<MyComponent />);

4.3 查询方法

查询类型方法说明
getBygetByText获取匹配元素(不存在时报错)
getByRole通过ARIA角色查询
getByLabelText通过标签文本查询
queryByqueryBy...同上,但元素不存在时返回null
findByfindBy...异步查询,返回Promise
getAllBygetAllBy...查询多个元素
queryAllByqueryAllBy...查询多个元素,不存在时返回空数组
findAllByfindAllBy...异步查询多个元素

4.4 用户交互

import userEvent from '@testing-library/user-event';// 模拟点击
userEvent.click(buttonElement);// 模拟输入
userEvent.type(inputElement, 'Hello World');// 模拟键盘操作
userEvent.keyboard('{Enter}');

4.5 常用断言扩展

import '@testing-library/jest-dom';expect(element).toBeInTheDocument();
expect(element).toBeVisible();
expect(element).toHaveClass('active');
expect(input).toHaveValue('test');
expect(button).toBeDisabled();

五、组件测试实战

5.1 基础组件测试

// Button.jsx
export default function Button({ onClick, children }) {return <button onClick={onClick}>{children}</button>;
}// Button.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Button from './Button';test('渲染按钮并响应点击', () => {const handleClick = jest.fn();render(<Button onClick={handleClick}>Click Me</Button>);const button = screen.getByRole('button', { name: 'Click Me' });expect(button).toBeInTheDocument();userEvent.click(button);expect(handleClick).toHaveBeenCalledTimes(1);
});

5.2 表单组件测试

// LoginForm.jsx
export default function LoginForm({ onSubmit }) {const [username, setUsername] = useState('');const [password, setPassword] = useState('');const handleSubmit = (e) => {e.preventDefault();onSubmit({ username, password });};return (<form onSubmit={handleSubmit}><label>Username:<input type="text" value={username} onChange={(e) => setUsername(e.target.value)} /></label><label>Password:<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} /></label><button type="submit">Login</button></form>);
}// LoginForm.test.jsx
test('提交表单数据', async () => {const handleSubmit = jest.fn();render(<LoginForm onSubmit={handleSubmit} />);const usernameInput = screen.getByLabelText('Username:');const passwordInput = screen.getByLabelText('Password:');const submitButton = screen.getByRole('button', { name: 'Login' });// 输入数据userEvent.type(usernameInput, 'testuser');userEvent.type(passwordInput, 'password123');// 提交表单userEvent.click(submitButton);// 验证提交数据expect(handleSubmit).toHaveBeenCalledWith({username: 'testuser',password: 'password123'});
});

5.3 路由组件测试

// 安装依赖:npm install react-router-dom
import { BrowserRouter as Router } from 'react-router-dom';test('显示导航链接', () => {render(<Router><Navbar /></Router>);const homeLink = screen.getByRole('link', { name: 'Home' });expect(homeLink).toHaveAttribute('href', '/');
});

六、异步测试

6.1 数据获取组件

// UserList.jsx
export default function UserList() {const [users, setUsers] = useState([]);const [loading, setLoading] = useState(false);useEffect(() => {const fetchUsers = async () => {setLoading(true);const response = await fetch('/api/users');const data = await response.json();setUsers(data);setLoading(false);};fetchUsers();}, []);if (loading) return <div>Loading...</div>;return (<ul>{users.map(user => (<li key={user.id}>{user.name}</li>))}</ul>);
}// UserList.test.jsx
import { render, screen, waitFor } from '@testing-library/react';
import UserList from './UserList';// 模拟fetch API
global.fetch = jest.fn();test('加载并显示用户列表', async () => {const mockUsers = [{ id: 1, name: 'User 1' },{ id: 2, name: 'User 2' }];fetch.mockResolvedValueOnce({json: async () => mockUsers,});render(<UserList />);// 验证加载状态expect(screen.getByText('Loading...')).toBeInTheDocument();// 等待数据加载完成const listItems = await screen.findAllByRole('listitem');// 验证用户列表expect(listItems).toHaveLength(2);expect(screen.getByText('User 1')).toBeInTheDocument();expect(screen.getByText('User 2')).toBeInTheDocument();
});

6.2 使用 Mock Service Worker (MSW)

npm install msw --save-dev
// src/mocks/server.js
import { setupServer } from 'msw/node';
import { rest } from 'msw';const server = setupServer(rest.get('/api/users', (req, res, ctx) => {return res(ctx.json([{ id: 1, name: 'Mock User 1' },{ id: 2, name: 'Mock User 2' }]));})
);export default server;// jest.setup.js
import server from './mocks/server';beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
// UserList.test.jsx
test('使用MSW加载用户列表', async () => {render(<UserList />);const listItems = await screen.findAllByRole('listitem');expect(listItems).toHaveLength(2);
});

七、高级测试技巧

7.1 Context 测试

// ThemeContext.jsx
import { createContext, useContext } from 'react';const ThemeContext = createContext('light');export function ThemeProvider({ children, theme }) {return (<ThemeContext.Provider value={theme}>{children}</ThemeContext.Provider>);
}export function ThemedButton() {const theme = useContext(ThemeContext);return <button className={`btn-${theme}`}>Themed Button</button>;
}// ThemedButton.test.jsx
test('使用深色主题渲染按钮', () => {render(<ThemeProvider theme="dark"><ThemedButton /></ThemeProvider>);const button = screen.getByRole('button');expect(button).toHaveClass('btn-dark');
});

7.2 Redux 测试

// 安装:npm install @reduxjs/toolkit react-redux
import { render } from '@testing-library/react';
import { Provider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import userReducer from './userSlice';
import UserProfile from './UserProfile';test('显示用户信息', () => {const store = configureStore({reducer: {user: userReducer,},preloadedState: {user: { name: 'Test User', email: 'test@example.com' }}});render(<Provider store={store}><UserProfile /></Provider>);expect(screen.getByText('Test User')).toBeInTheDocument();expect(screen.getByText('test@example.com')).toBeInTheDocument();
});

7.3 自定义 Hook 测试

// useCounter.js
import { useState, useCallback } from 'react';export function useCounter(initialValue = 0) {const [count, setCount] = useState(initialValue);const increment = useCallback(() => setCount(c => c + 1), []);const decrement = useCallback(() => setCount(c => c - 1), []);return { count, increment, decrement };
}// useCounter.test.js
import { renderHook, act } from '@testing-library/react-hooks';
import { useCounter } from './useCounter';test('计数器hook正常工作', () => {const { result } = renderHook(() => useCounter(5));// 初始值expect(result.current.count).toBe(5);// 增加act(() => result.current.increment());expect(result.current.count).toBe(6);// 减少act(() => result.current.decrement());expect(result.current.count).toBe(5);
});

八、最佳实践

8.1 测试编写原则

  1. 测试用户行为:而不是组件内部实现
  2. 避免测试实现细节
    • ❌ 不要测试组件内部状态
    • ❌ 不要测试组件生命周期方法
    • ✅ 测试用户可见的输出和行为
  3. 使用合适的查询方法
    • 优先使用 getByRole
    • 其次使用 getByLabelTextgetByText
    • 避免使用 container.querySelector
  4. 保持测试简单:每个测试只验证一个行为

8.2 测试组织策略

src/
├── components/
│   ├── Button/
│   │   ├── Button.jsx
│   │   ├── Button.test.jsx
│   │   └── index.js
│   └── ...
└── hooks/├── useCounter.js└── useCounter.test.js

8.3 可访问性测试

import { axe } from 'jest-axe';test('组件应满足无障碍要求', async () => {const { container } = render(<MyComponent />);const results = await axe(container);expect(results).toHaveNoViolations();
});

8.4 快照测试(谨慎使用)

test('组件渲染快照', () => {const { asFragment } = render(<MyComponent />);expect(asFragment()).toMatchSnapshot();
});

适用场景

  • 配置型组件
  • 静态内容组件
  • 不经常变更的UI

注意事项

  • 避免过度使用
  • 不要替代功能测试
  • 审查每次快照变更

九、测试覆盖率与持续集成

9.1 生成覆盖率报告

npm test -- --coverage

配置 jest.config.js:

module.exports = {collectCoverage: true,coverageReporters: ['html', 'text'],collectCoverageFrom: ['src/**/*.{js,jsx}','!src/**/*.test.{js,jsx}','!src/index.js','!src/serviceWorker.js',],
};

9.2 持续集成(GitHub Actions)

.github/workflows/test.yml:

name: Teston: [push, pull_request]jobs:test:runs-on: ubuntu-lateststeps:- uses: actions/checkout@v2- uses: actions/setup-node@v2with:node-version: '16'- run: npm ci- run: npm test -- --coverage- name: Upload coverageuses: codecov/codecov-action@v2

十、常见问题与解决方案

10.1 测试失败:"act" warning

问题:测试中看到 “An update to Component inside a test was not wrapped in act(…)” 警告

解决方案

// 使用 waitFor 处理异步更新
await waitFor(() => {expect(screen.getByText('Loaded data')).toBeInTheDocument();
});// 或者使用 findBy 查询
const dataElement = await screen.findByText('Loaded data');

10.2 模拟模块依赖

// 模拟第三方模块
jest.mock('axios', () => ({get: jest.fn().mockResolvedValue({ data: 'mocked data' }),
}));// 模拟本地模块
jest.mock('../utils/api', () => ({fetchData: jest.fn().mockResolvedValue('mock data'),
}));

10.3 测试使用 useEffect 的组件

test('正确使用 useEffect', async () => {const { rerender } = render(<TimerComponent interval={1000} />);// 初始状态expect(screen.getByText('Count: 0')).toBeInTheDocument();// 等待效果执行await waitFor(() => {expect(screen.getByText('Count: 1')).toBeInTheDocument();}, { timeout: 1500 });// 更新 props 重新渲染rerender(<TimerComponent interval={500} />);// 验证新间隔生效await waitFor(() => {expect(screen.getByText('Count: 2')).toBeInTheDocument();}, { timeout: 1000 });
});

十一、总结

测试目标
组件渲染
用户交互
异步操作
渲染内容
事件响应
数据加载

核心要点

  1. 测试用户行为:而不是实现细节
  2. 使用合适的查询方法:优先按角色和文本查询
  3. 模拟真实环境:使用MSW模拟API请求
  4. 保持测试简单:每个测试只验证一个功能
  5. 自动化测试流程:集成到CI/CD流程
http://www.lryc.cn/news/623315.html

相关文章:

  • 云安全 - The Big IAM Challenge
  • XSS攻击:从原理入门到实战精通详解
  • JCTools 无锁并发队列基础:ConcurrentCircularArrayQueue
  • 深入解析C++ STL链表(List)模拟实现
  • 如何得知是Counter.razor通过HTTP回调处理的还是WASM处理的,怎么检测?
  • 基于Python的电影评论数据分析系统 Python+Django+Vue.js
  • qt vs2019编译QXlsx
  • Qt QDateTime时间部分显示为全0,QTime赋值后显示无效问题【已解决】
  • ML307C 4G通信板:工业级DTU固件,多协议支持,智能配置管理
  • 随机整数列表处理:偶数索引降序排序
  • 数据库索引视角:对比二叉树到红黑树再到B树
  • 《探索IndexedDB实现浏览器端UTXO模型的前沿技术》
  • 使用影刀RPA实现快递信息抓取
  • C++ 最短路Dijkstra
  • 9.从零开始写LINUX内核——设置中断描述符表
  • Python 类元编程(元类的特殊方法 __prepare__)
  • Flink Stream API 源码走读 - 总结
  • 楼宇自控系统赋能建筑全维度管理,实现环境、安全与能耗全面监管
  • STM32硬件SPI配置为全双工模式下不要单独使用HAL_SPI_Transmit API及HAL_SPI_TransmitReceive改造方法
  • 【时时三省】(C语言基础)共用体类型数据的特点
  • Langfuse2.60.3:独立数据库+docker部署及环境变量详细说明
  • Java 中重载与重写的全面解析(更新版)
  • Mybatis-3自己实现MyBatis底层机制
  • 从冒泡到快速排序:探索经典排序算法的奥秘(二)
  • PHP反序列化的CTF题目环境和做题复现第1集
  • 企业运维规划及Linux介绍虚拟环境搭建
  • python---包
  • 一文速通Python并行计算:14 Python异步编程-协程的管理和调度
  • CF每日3题(1500-1700)
  • P2169 正则表达式