前端测试策略:从单元测试到 E2E

测试写了不少,从”不写测试”到”过度测试”再到”合理测试”,总结一套策略。

测试金字塔

        /\
       /E2E\
      /------\
     /  集成  \
    /----------\
   /   单元测试  \
  /--------------\
层级数量成本价值
单元测试最多
集成测试中等
E2E 测试最少

单元测试

测试最小单元:函数、组件。

工具选择

工具特点推荐场景
Jest全功能、生态好通用
Vitest快、Vite 原生Vite 项目
Testing Library关注用户行为React/Vue

函数测试

// utils.test.js
import { formatPrice, debounce } from './utils';

describe('formatPrice', () => {
  it('should format number to currency', () => {
    expect(formatPrice(1234.5)).toBe('¥1,234.50');
  });

  it('should handle zero', () => {
    expect(formatPrice(0)).toBe('¥0.00');
  });

  it('should handle negative', () => {
    expect(formatPrice(-100)).toBe('-¥100.00');
  });
});

describe('debounce', () => {
  beforeEach(() => {
    jest.useFakeTimers();
  });

  it('should debounce function calls', () => {
    const fn = jest.fn();
    const debounced = debounce(fn, 100);

    debounced();
    debounced();
    debounced();

    expect(fn).not.toHaveBeenCalled();

    jest.advanceTimersByTime(100);

    expect(fn).toHaveBeenCalledTimes(1);
  });
});

组件测试

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

describe('Button', () => {
  it('should render with text', () => {
    render(<Button>Click me</Button>);
    expect(screen.getByText('Click me')).toBeInTheDocument();
  });

  it('should call onClick when clicked', () => {
    const handleClick = jest.fn();
    render(<Button onClick={handleClick}>Click me</Button>);

    fireEvent.click(screen.getByText('Click me'));

    expect(handleClick).toHaveBeenCalledTimes(1);
  });

  it('should be disabled when loading', () => {
    render(<Button loading>Click me</Button>);
    expect(screen.getByRole('button')).toBeDisabled();
  });
});

测试运行

集成测试

测试模块间的交互。

API 测试

// api.test.js
import { getUser, createUser } from './api';
import { server } from './mocks/server';

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

describe('User API', () => {
  it('should fetch user by id', async () => {
    const user = await getUser(1);
    expect(user.name).toBe('John Doe');
  });

  it('should handle not found', async () => {
    await expect(getUser(999)).rejects.toThrow('User not found');
  });

  it('should create user', async () => {
    const newUser = await createUser({ name: 'Jane' });
    expect(newUser.id).toBeDefined();
  });
});

Mock Service Worker

// mocks/handlers.js
import { rest } from 'msw';

export const handlers = [
  rest.get('/api/users/:id', (req, res, ctx) => {
    const { id } = req.params;
    if (id === '999') {
      return res(ctx.status(404));
    }
    return res(
      ctx.json({
        id: Number(id),
        name: 'John Doe',
        email: 'john@example.com',
      })
    );
  }),
];

E2E 测试

模拟真实用户操作。

Playwright

// login.spec.js
import { test, expect } from '@playwright/test';

test.describe('Login', () => {
  test('should login successfully', async ({ page }) => {
    await page.goto('/login');

    await page.fill('[name="email"]', 'user@example.com');
    await page.fill('[name="password"]', 'password123');
    await page.click('button[type="submit"]');

    await expect(page).toHaveURL('/dashboard');
    await expect(page.locator('.user-name')).toContainText('John');
  });

  test('should show error for invalid credentials', async ({ page }) => {
    await page.goto('/login');

    await page.fill('[name="email"]', 'wrong@example.com');
    await page.fill('[name="password"]', 'wrongpassword');
    await page.click('button[type="submit"]');

    await expect(page.locator('.error-message')).toBeVisible();
  });
});

测试关键路径

路径优先级
用户登录P0
核心业务流程P0
支付流程P0
用户注册P1
搜索功能P1

测试覆盖率

配置

// vitest.config.js
export default {
  test: {
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html'],
      exclude: [
        'node_modules/',
        '**/*.test.js',
        '**/*.config.js',
      ],
    },
  },
};

合理目标

类型目标覆盖率
工具函数90%
业务逻辑70%
UI 组件50%
整体70%

不要追求 100%,边际成本太高。

覆盖率报告

测试技巧

测试用户行为,不要测试实现细节

// 不好:测试内部状态
expect(component.state.count).toBe(1);

// 好:测试可见结果
expect(screen.getByText('Count: 1')).toBeInTheDocument();

用 beforeEach 隔离

describe('UserService', () => {
  let service;

  beforeEach(() => {
    service = new UserService();
  });

  test('should create user', () => {
    // service 是干净的实例
  });
});

快照测试要谨慎

// 不要对整个组件做快照
expect(container).toMatchSnapshot();

// 只对关键输出做快照
expect(screen.getByRole('alert')).toMatchSnapshot();

快照测试容易变成”更新快照”的无意义操作。

CI 集成

# GitHub Actions
name: Test

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npm run test:coverage
      - run: npm run test:e2e

常见反模式

反模式问题改进
不写测试没有保障从关键路径开始
过度 mock测试无意义减少不必要的 mock
测试实现细节重构就挂测试行为和输出
追求 100% 覆盖成本太高关注关键路径

总结

测试的目的是提高信心,不是为了覆盖率数字。

建议:

  1. 关键功能必须有测试
  2. 新功能写测试,老功能逐步补
  3. 单元测试为主,E2E 为辅
  4. CI 跑测试,提交前本地也要跑

测试写多了,重构才敢放心做。