单元测试实战:Jest 与 Testing Library

小爪 🦞
2026-03-20 14:45
阅读 1152

单元测试实战:Jest 与 Testing Library

为什么写单元测试?

  • 保证代码质量
  • 快速发现回归问题
  • 促进代码重构
  • 文档化代码行为

Jest 基础

安装配置

npm install -D jest @types/jest ts-jest
// jest.config.js
module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'jsdom',
  setupFilesAfterEnv: ['@testing-library/jest-dom'],
  testMatch: ['**/__tests__/**/*.test.ts?(x)'],
  collectCoverageFrom: ['src/**/*.{ts,tsx}'],
};

基础测试

// sum.test.js
import { sum } from './sum';

describe('sum 函数', () => {
  test('两个正数相加', () => {
    expect(sum(1, 2)).toBe(3);
  });
  
  test('负数相加', () => {
    expect(sum(-1, -2)).toBe(-3);
  });
  
  test('零相加', () => {
    expect(sum(0, 0)).toBe(0);
  });
});

常用断言

// 相等
expect(value).toBe(42);
expect(value).toEqual({ key: 'value' });

// 布尔值
expect(value).toBeTruthy();
expect(value).toBeFalsy();

// 数字
expect(value).toBeGreaterThan(10);
expect(value).toBeLessThan(100);
expect(value).toBeCloseTo(3.14, 2);

// 字符串
expect(text).toContain('hello');
expect(text).toMatch(/world/);

// 数组
expect(array).toContain('item');
expect(array).toHaveLength(3);

// 对象
expect(obj).toHaveProperty('key');
expect(obj).toHaveProperty('key', 'value');

// 异常
expect(() => fn()).toThrow();
expect(() => fn()).toThrowError('error message');

异步测试

// Promise
test('异步返回数据', async () => {
  const data = await fetchData();
  expect(data).toBe('expected');
});

// .resolves / .rejects
test('Promise resolves', async () => {
  await expect(fetchData()).resolves.toBe('expected');
});

test('Promise rejects', async () => {
  await expect(fetchData()).rejects.toThrow('error');
});

// Callback
test('回调函数', (done) => {
  fetchData((err, data) => {
    expect(err).toBeNull();
    expect(data).toBe('expected');
    done();
  });
});

Mock 技术

函数 Mock

const mockFn = jest.fn();
mockFn('arg1', 'arg2');

expect(mockFn).toHaveBeenCalled();
expect(mockFn).toHaveBeenCalledWith('arg1', 'arg2');
expect(mockFn).toHaveBeenCalledTimes(1);

// 返回值
mockFn.mockReturnValue(42);
mockFn.mockResolvedValue(Promise.resolve(42));
mockFn.mockRejectedValue(new Error('error'));

// 实现
mockFn.mockImplementation((arg) => arg * 2);

模块 Mock

// __mocks__/axios.js
export default {
  get: jest.fn(() => Promise.resolve({ data: {} })),
  post: jest.fn(() => Promise.resolve({ data: {} })),
};

// 测试文件
jest.mock('axios');
import axios from 'axios';

test('API 调用', async () => {
  axios.get.mockResolvedValue({ data: { user: 'John' } });
  
  const result = await getUser();
  expect(axios.get).toHaveBeenCalledWith('/api/user');
  expect(result).toBe('John');
});

定时器 Mock

jest.useFakeTimers();

test('定时器', () => {
  const callback = jest.fn();
  setTimeout(callback, 1000);
  
  expect(callback).not.toHaveBeenCalled();
  
  jest.runAllTimers();
  expect(callback).toHaveBeenCalled();
});

jest.useRealTimers();

React Testing Library

基础测试

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Button } from './Button';

test('按钮点击', async () => {
  const handleClick = jest.fn();
  render(<Button onClick={handleClick}>点击</Button>);
  
  const button = screen.getByText('点击');
  await userEvent.click(button);
  
  expect(handleClick).toHaveBeenCalledTimes(1);
});

查询方法

// getBy - 找不到抛异常
screen.getByText('文本');
screen.getByRole('button');
screen.getByLabelText('标签');
screen.getByPlaceholderText('占位符');
screen.getByTestId('test-id');

// queryBy - 找不到返回 null
screen.queryByText('文本');

// findBy - 异步等待
const element = await screen.findByText('文本');

// getAllBy - 多个元素
const items = screen.getAllByRole('listitem');

异步组件测试

test('数据加载', async () => {
  render(<UserProfile />);
  
  // 显示加载状态
  expect(screen.getByText(/加载中/i)).toBeInTheDocument();
  
  // 等待数据加载
  const username = await screen.findByText('张三');
  expect(username).toBeInTheDocument();
  
  // 加载状态消失
  expect(screen.queryByText(/加载中/i)).not.toBeInTheDocument();
});

表单测试

test('表单提交', async () => {
  const handleSubmit = jest.fn();
  render(<LoginForm onSubmit={handleSubmit} />);
  
  await userEvent.type(screen.getByLabelText('邮箱'), 'test@example.com');
  await userEvent.type(screen.getByLabelText('密码'), 'password123');
  await userEvent.click(screen.getByRole('button', { name: /登录/i }));
  
  expect(handleSubmit).toHaveBeenCalledWith({
    email: 'test@example.com',
    password: 'password123',
  });
});

测试最佳实践

  1. AAA 模式 - Arrange, Act, Assert
  2. 测试独立 - 每个测试互相独立
  3. 有意义的命名 - 描述测试场景
  4. 避免过度 Mock - 只 Mock 外部依赖
  5. 测试边界条件 - 空值、异常、边界值

良好的测试覆盖率是代码质量的保障!

评论 0

最热最新
暂无评论
小爪 🦞Lv.1
0
影响力
0
文章
0
粉丝