Back to Blog
2026-02-15ToolBox Team

前端单元测试与集成测试完全指南

🔧 返回工具箱 | Back to Tools

浏览所有工具 | View All Tools
testingfrontendjestquality-assurance

前端单元测试与集成测试完全指南

"不测试的代码是遗留代码"。一个拥有完善测试的项目,不仅代码质量更高,重构也更安心。本文将带你从零开始构建前端测试体系。

1. 为什么需要测试?

现实:没有测试的后果

修改了一行代码
    ↓
自己浏览器中测试通过
    ↓
推送到生产环境
    ↓
用户报告:某个功能在特定场景下崩溃
    ↓
紧急回滚 + 客户投诉 + 声誉受损

解决方案:自动化测试

修改了一行代码
    ↓
运行测试套件(自动覆盖所有场景)
    ↓
测试失败 → 代码修复 → 测试通过
    ↓
有信心地推送到生产

2. 测试金字塔

        端到端测试 (E2E)          ▲ 耗时
       /            \             │
      集成测试       集成测试      │
     /       \      /       \     │
    单元      单元 单元      单元  ▼ 快速
   测试      测试 测试      测试
   
   70%: 单元测试
   20%: 集成测试
   10%: E2E 测试

3. Jest 基础设置

安装与配置

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

保存 jest.config.js

module.exports = {
  testEnvironment: 'jsdom',
  setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1'
  },
  collectCoverageFrom: [
    'src/**/*.{js,jsx}',
    '!src/**/*.test.{js,jsx}',
    '!src/index.js'
  ]
};

Package.json 中添加脚本

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

4. 单元测试

测试函数逻辑

// math.js
export function add(a, b) {
  return a + b;
}

export function subtract(a, b) {
  return a - b;
}

// math.test.js
import { add, subtract } from './math';

describe('数学函数', () => {
  test('add 返回两数之和', () => {
    expect(add(2, 3)).toBe(5);
    expect(add(-1, 1)).toBe(0);
    expect(add(0, 0)).toBe(0);
  });

  test('subtract 返回两数之差', () => {
    expect(subtract(5, 2)).toBe(3);
    expect(subtract(1, 5)).toBe(-4);
  });
});

测试对象和数组

// user.js
export class User {
  constructor(name, email) {
    this.name = name;
    this.email = email;
  }

  getProfile() {
    return {
      name: this.name,
      email: this.email
    };
  }
}

// user.test.js
import { User } from './user';

describe('User 类', () => {
  test('创建用户并获取信息', () => {
    const user = new User('Alice', 'alice@example.com');
    
    expect(user.getProfile()).toEqual({
      name: 'Alice',
      email: 'alice@example.com'
    });
  });
});

测试异步代码

// api.js
export async function fetchUser(id) {
  const response = await fetch(`/api/users/${id}`);
  return response.json();
}

// api.test.js
import { fetchUser } from './api';

describe('API 函数', () => {
  test('fetchUser 返回用户数据', async () => {
    // Mock fetch
    global.fetch = jest.fn(() =>
      Promise.resolve({
        json: () => Promise.resolve({
          id: 1,
          name: 'Alice',
          email: 'alice@example.com'
        })
      })
    );

    const user = await fetchUser(1);
    
    expect(user).toEqual({
      id: 1,
      name: 'Alice',
      email: 'alice@example.com'
    });
    expect(fetch).toHaveBeenCalledWith('/api/users/1');
  });
});

5. React 组件测试

测试简单组件

// Button.jsx
export function Button({ label, onClick, disabled }) {
  return (
    <button onClick={onClick} disabled={disabled}>
      {label}
    </button>
  );
}

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

describe('Button 组件', () => {
  test('正确渲染按钮标签', () => {
    render(<Button label="点击我" />);
    
    const button = screen.getByRole('button', { name: /点击我/i });
    expect(button).toBeInTheDocument();
  });

  test('可以禁用按钮', () => {
    render(<Button label="点击我" disabled={true} />);
    
    const button = screen.getByRole('button');
    expect(button).toBeDisabled();
  });

  test('点击时调用 onClick 事件', () => {
    const handleClick = jest.fn();
    render(<Button label="点击我" onClick={handleClick} />);
    
    const button = screen.getByRole('button');
    fireEvent.click(button);
    
    expect(handleClick).toHaveBeenCalledTimes(1);
  });
});

测试带状态的组件

// Counter.jsx
import { useState } from 'react';

export function Counter({ initialValue = 0 }) {
  const [count, setCount] = useState(initialValue);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>加</button>
      <button onClick={() => setCount(count - 1)}>减</button>
    </div>
  );
}

// Counter.test.jsx
import { render, screen, fireEvent } from '@testing-library/react';
import { Counter } from './Counter';

describe('Counter 组件', () => {
  test('初始值为 0', () => {
    render(<Counter />);
    expect(screen.getByText('Count: 0')).toBeInTheDocument();
  });

  test('点击"加"按钮增加计数', () => {
    render(<Counter />);
    
    const addButton = screen.getByRole('button', { name: /加/i });
    fireEvent.click(addButton);
    
    expect(screen.getByText('Count: 1')).toBeInTheDocument();
  });

  test('点击"减"按钮减少计数', () => {
    render(<Counter />);
    
    const subtractButton = screen.getByRole('button', { name: /减/i });
    fireEvent.click(subtractButton);
    
    expect(screen.getByText('Count: -1')).toBeInTheDocument();
  });
});

测试 Hook

// useCounter.js
import { useState } from 'react';

export function useCounter(initialValue = 0) {
  const [count, setCount] = useState(initialValue);

  return {
    count,
    increment: () => setCount(c => c + 1),
    decrement: () => setCount(c => c - 1),
    reset: () => setCount(initialValue)
  };
}

// useCounter.test.js
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';

describe('useCounter Hook', () => {
  test('返回初始值', () => {
    const { result } = renderHook(() => useCounter(0));
    
    expect(result.current.count).toBe(0);
  });

  test('increment 增加计数', () => {
    const { result } = renderHook(() => useCounter());
    
    act(() => {
      result.current.increment();
    });
    
    expect(result.current.count).toBe(1);
  });

  test('reset 重置计数', () => {
    const { result } = renderHook(() => useCounter(5));
    
    act(() => {
      result.current.increment();
      result.current.increment();
      result.current.reset();
    });
    
    expect(result.current.count).toBe(5);
  });
});

6. Mock 与 Stub

Mock 函数和模块

// userService.js
export async function getUser(id) {
  const response = await fetch(`/api/users/${id}`);
  return response.json();
}

// component.test.js
import * as userService from './userService';
import { getUserInfo } from './component';

jest.mock('./userService');

test('当 API 失败时显示错误', async () => {
  userService.getUser.mockRejectedValue(
    new Error('API 失败')
  );

  const component = render(<UserInfo userId={1} />);
  
  await waitFor(() => {
    expect(screen.getByText(/API 失败/i)).toBeInTheDocument();
  });
});

Mock API 响应

// api.test.js
import axios from 'axios';
jest.mock('axios');

describe('API 调用', () => {
  test('处理成功响应', async () => {
    axios.get.mockResolvedValue({
      data: { id: 1, name: 'Alice' }
    });

    const data = await fetchUser(1);
    
    expect(data).toEqual({ id: 1, name: 'Alice' });
    expect(axios.get).toHaveBeenCalledWith('/api/users/1');
  });

  test('处理错误响应', async () => {
    axios.get.mockRejectedValue(
      new Error('网络错误')
    );

    await expect(fetchUser(1)).rejects.toThrow('网络错误');
  });
});

7. 覆盖率与缺陷检测

生成覆盖率报告

npm run test:coverage

输出示例:

TOTAL      | 85.2% | 78.5% | 92.1% | 84.8%
  • Statements:语句覆盖率(85.2%)
  • Branches:分支覆盖率(78.5%)
  • Functions:函数覆盖率(92.1%)
  • Lines:行覆盖率(84.8%)

目标:至少 80% 覆盖率。

8. 测试最佳实践

✅ 应该做

// 1. 描述清晰的测试名称
test('当输入无效时,验证函数应返回错误', () => {
  // ...
});

// 2. 遵循 Arrange-Act-Assert 模式
test('登录成功后应重定向到首页', () => {
  // Arrange
  const user = new User('alice', 'password123');
  
  // Act
  const result = user.login();
  
  // Assert
  expect(result.redirectTo).toBe('/home');
});

// 3. 一个测试只验证一个逻辑
test('add 函数应返回两数之和', () => {
  expect(add(2, 3)).toBe(5);
});

// 4. 使用有意义的断言
expect(user.age).toBeGreaterThanOrEqual(18);
expect(email).toMatch(/^[\w-]+@([\w-]+\.)+[\w-]{2,4}$/);
expect(items).toHaveLength(3);

❌ 不应该做

// 不:多个逻辑在一个测试中
test('用户功能', () => {
  const user = new User('alice');
  expect(user.name).toBe('alice');
  user.setAge(25);
  expect(user.age).toBe(25);
  user.email = 'alice@example.com';
  expect(user.email).toBe('alice@example.com');
});

// 不:模糊的断言
expect(result).toBeTruthy();  // 应该:expect(result).toBe(true)

// 不:过度 Mock
jest.spyOn(console, 'log');  // 通常不必要

相关工具推荐