React单元测试

发布于:2025-08-17 ⋅ 阅读:(18) ⋅ 点赞:(0)

下面,我们来系统的梳理关于 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 查询方法

查询类型 方法 说明
getBy getByText 获取匹配元素(不存在时报错)
getByRole 通过ARIA角色查询
getByLabelText 通过标签文本查询
queryBy queryBy... 同上,但元素不存在时返回null
findBy findBy... 异步查询,返回Promise
getAllBy getAllBy... 查询多个元素
queryAllBy queryAllBy... 查询多个元素,不存在时返回空数组
findAllBy findAllBy... 异步查询多个元素

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: Test

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-node@v2
        with:
          node-version: '16'
      - run: npm ci
      - run: npm test -- --coverage
      - name: Upload coverage
        uses: 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流程

网站公告

今日签到

点亮在社区的每一天
去签到