测试策略与实践

深入理解前端测试策略:单元测试、集成测试、E2E 测试,以及 Jest、Vitest、Cypress、Playwright 等测试工具的使用。


测试金字塔

┌──────────────────────────────────────────────────────────────┐
│                       测试金字塔                               │
├──────────────────────────────────────────────────────────────┤
│                                                              │
│                      ▲                                       │
│                     ╱ ╲                                      │
│                    ╱   ╲                                     │
│                   ╱ E2E ╲         少量,耗时                 │
│                  ╱ (10%) ╲        真实场景                   │
│                 ╱─────────╲                                │
│                ╱ Integration ╲      适量,适中               │
│               ╱   (20-30%)  ╲      模块交互                  │
│              ╱────────────────╲                            │
│             ╱    Unit Tests     ╲    大量,快速               │
│            ╱      (60-70%)       ╲   独立函数/组件            │
│           ╱────────────────────────╲                          │
│                                                              │
│   成本:低 ←────────────────────────→ 高                      │
│   速度:快 ←────────────────────────→ 慢                      │
│   数量:多 ←────────────────────────→ 少                      │
│                                                              │
└──────────────────────────────────────────────────────────────┘

各层测试特点

层级 覆盖率目标 运行速度 维护成本 工具
单元测试 70-80% < 1s Jest, Vitest
集成测试 20-30% 1-10s Jest, Testing Library
E2E 测试 10% 10s+ Cypress, Playwright

单元测试(Jest / Vitest)

Jest 配置

// jest.config.js
module.exports = {
  // 测试环境
  testEnvironment: 'jsdom',

  // 文件匹配
  testMatch: [
    '**/__tests__/**/*.test.[jt]s?(x)',
    '**/?(*.)+(spec|test).[jt]s?(x)'
  ],

  // 忽略文件
  testPathIgnorePatterns: [
    '/node_modules/',
    '/dist/'
  ],

  // 覆盖率
  collectCoverageFrom: [
    'src/**/*.{js,jsx,ts,tsx}',
    '!src/**/*.d.ts',
    '!src/**/index.{js,ts}'
  ],

  // 覆盖率阈值
  coverageThreshold: {
    global: {
      branches: 70,
      functions: 70,
      lines: 70,
      statements: 70
    }
  },

  // 模块解析
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
    '\\.(css|less|scss|sass)$': 'identity-obj-proxy'
  },

  // 转换
  transform: {
    '^.+\\.(js|jsx|ts|tsx)$': 'babel-jest'
  },

  // 测试设置文件
  setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],

  // 模拟定时器
  fakeTimers: {
    enableGlobally: false
  }
};

Vitest 配置

// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],

  test: {
    // 环境
    environment: 'jsdom',

    // 全局测试
    globals: true,

    // 文件匹配
    include: ['src/**/*.{test,spec}.{js,ts,jsx,tsx}'],

    // 覆盖率
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      thresholds: {
        lines: 70,
        functions: 70,
        branches: 70,
        statements: 70
      }
    },

    // 模拟
    mock: {
      '@vueuse/core': '<rootDir>/__mocks__/vueuse.js'
    }
  }
});

单元测试编写

测试结构(AAA 模式)

// sum.test.js
describe('sum', () => {
  // 准备(Arrange)
  const numbers = [1, 2, 3];

  // 执行(Act)
  const result = sum(numbers);

  // 断言(Assert)
  expect(result).toBe(6);
});

常用断言

// 基础断言
expect(value).toBe(6);              // 严格相等
expect(value).toEqual({a: 1});     // 值相等
expect(value).toBeNull();           // null
expect(value).toBeUndefined();      // undefined
expect(value).toBeTruthy();         // 真值
expect(value).toBeFalsy();          // 假值

// 数字断言
expect(value).toBeGreaterThan(5);
expect(value).toBeLessThanOrEqual(10);
expect(value).toBeCloseTo(0.3, 5);  // 浮点数比较

// 字符串断言
expect('hello').toMatch(/^hel/);
expect('hello').toContain('ell');

// 数组断言
expect([1, 2, 3]).toContain(2);
expect([1, 2, 3]).toHaveLength(3);
expect.arrayContaining([1, 2]);      // 包含元素

// 对象断言
expect(obj).toHaveProperty('name');
expect(obj).toMatchObject({name: 'John'});
expect({a: 1, b: 2}).toStrictEqual({b: 2, a: 1});

// 异步断言
expect(Promise.resolve('success')).resolves.toBe('success');
expect(Promise.reject(new Error('fail'))).rejects.toThrow('fail');

// 组合使用
expect(value).toBeGreaterThan(0).and.toBeLessThan(100);

模拟(Mock)

// 模拟函数
const mockFn = jest.fn();
mockFn.mockReturnValue('result');
mockFn.mockResolvedValue('async result');
mockFn.mockRejectedValue(new Error('error'));

// 模拟实现
mockFn.mockImplementation((x) => x * 2);

// 清除调用记录
mockFn.mockClear();

// 验证调用
expect(mockFn).toHaveBeenCalled();
expect(mockFn).toHaveBeenCalledTimes(2);
expect(mockFn).toHaveBeenCalledWith(1, 'hello');
expect(mockFn).toHaveBeenLastCalledWith(2, 'world');

模拟模块

// __mocks__/axios.js
export default {
  get: jest.fn().mockResolvedValue({ data: { id: 1 } })
};

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

test('fetches user', async () => {
  axios.get.mockResolvedValue({ data: { id: 1, name: 'John' } });

  const user = await fetchUser(1);

  expect(axios.get).toHaveBeenCalledWith('/users/1');
  expect(user.name).toBe('John');
});

模拟定时器

test('delays execution', () => {
  jest.useFakeTimers();

  const callback = jest.fn();

  // 设置一个在 1000ms 后执行的定时器
  setTimeout(callback, 1000);

  // 快进时间
  jest.advanceTimersByTime(1000);

  // 验证被调用
  expect(callback).toHaveBeenCalled();

  jest.useRealTimers();
});

React 组件测试(Testing Library)

基础测试

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

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

  test('handles click', () => {
    const handleClick = jest.fn();
    render(<Button onClick={handleClick}>Click</Button>);

    fireEvent.click(screen.getByRole('button', { name: 'Click' }));

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

  test('is disabled when loading', () => {
    render(<Button loading>Click</Button>);

    expect(screen.getByRole('button')).toBeDisabled();
  });
});

查询方法

// 按文本查询
screen.getByText('Submit');        // 精确文本
screen.getByText(/submit/i);       // 正则匹配
screen.getByText((content, element) => {
  return element.textContent.includes('Submit');
});

// 按角色查询
screen.getByRole('button', { name: 'Submit' });
screen.getByRole('textbox', { name: 'Email' });
screen.getByRole('heading', { level: 1 });

// 按标签查询
screen.getByLabelText('Email address');
screen.getByPlaceholderText('Enter email');
screen.getByAltText('User avatar');

// 按测试 ID
screen.getByTestId('submit-button');

异步查询

import { render, screen } from '@testing-library/react';
import { waitFor, within } from '@testing-library/dom';
import { fetchUser } from './api';

test('displays user data', async () => {
  render(<UserProfile userId={1} />);

  // 等待元素出现
  await waitFor(() => {
    expect(screen.getByText('John')).toBeInTheDocument();
  });

  // 或者使用 findBy
  const userName = await screen.findByText('John');
  expect(userName).toBeInTheDocument();
});

用户交互

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

test('form submission', async () => {
  const user = userEvent.setup();

  render(<LoginForm />);

  // 输入
  await user.type(screen.getByLabelText('Email'), '[email protected]');
  await user.type(screen.getByLabelText('Password'), 'password123');

  // 点击
  await user.click(screen.getByRole('button', { name: 'Login' }));

  // 验证
  expect(screen.getByText('Welcome!')).toBeInTheDocument();
});

状态测试

test('toggles state', () => {
  const { getByRole, getByText } = render(<Toggle />);

  // 初始状态
  expect(getByText('Off')).toBeInTheDocument();

  // 点击切换
  fireEvent.click(getByRole('button'));

  // 验证状态变化
  expect(getByText('On')).toBeInTheDocument();
});

test('increments counter', () => {
  render(<Counter />);

  const button = screen.getByRole('button', { name: /increment/i });
  const display = screen.getByTestId('count');

  expect(display).toHaveTextContent('0');

  fireEvent.click(button);
  expect(display).toHaveTextContent('1');

  fireEvent.click(button);
  expect(display).toHaveTextContent('2');
});

集成测试

API 集成测试

// tests/integration/api.test.ts
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
import { render, waitFor } from '@testing-library/react';
import { userApi } from '@/api/user';

const server = setupServer(
  http.get('/api/users/:id', ({ params }) => {
    return HttpResponse.json({
      id: params.id,
      name: 'John',
      email: '[email protected]'
    });
  })
);

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

test('fetches and displays user', async () => {
  const { getByTestId } = render(<UserPage userId="1" />);

  await waitFor(() => {
    expect(getByTestId('user-name')).toHaveTextContent('John');
  });
});

路由集成测试

// tests/integration/router.test.tsx
import { render, screen } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import App from '@/App';

const renderWithRouter = (initialPath: string) => {
  return render(
    <MemoryRouter initialEntries={[initialPath]}>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/about" element={<About />} />
        <Route path="/users/:id" element={<User />} />
      </Routes>
    </MemoryRouter>
  );
};

test('navigates between pages', async () => {
  renderWithRouter('/');

  expect(screen.getByText('Home')).toBeInTheDocument();

  // 模拟导航
  const user = userEvent.setup();
  await user.click(screen.getByText('Go to About'));

  expect(screen.getByText('About')).toBeInTheDocument();
});

E2E 测试(Cypress / Playwright)

Cypress 基础

// cypress/e2e/login.cy.js
describe('Login', () => {
  beforeEach(() => {
    cy.visit('/login');
  });

  it('shows login form', () => {
    cy.get('[data-testid=email-input]').should('be.visible');
    cy.get('[data-testid=password-input]').should('be.visible');
    cy.get('[data-testid=submit-button]').should('be.visible');
  });

  it('logs in successfully', () => {
    cy.get('[data-testid=email-input]').type('[email protected]');
    cy.get('[data-testid=password-input]').type('password123');
    cy.get('[data-testid=submit-button]').click();

    cy.url().should('include', '/dashboard');
    cy.contains('Welcome').should('be.visible');
  });

  it('shows validation errors', () => {
    cy.get('[data-testid=submit-button]').click();

    cy.contains('Email is required').should('be.visible');
  });

  it('handles API errors', () => {
    cy.intercept('POST', '/api/login', {
      statusCode: 401,
      body: { error: 'Invalid credentials' }
    }).as('loginRequest');

    cy.get('[data-testid=email-input]').type('[email protected]');
    cy.get('[data-testid=password-input]').type('wrongpassword');
    cy.get('[data-testid=submit-button]').click();

    cy.wait('@loginRequest');
    cy.contains('Invalid credentials').should('be.visible');
  });
});

Playwright 基础

// tests/e2e/login.spec.ts
import { test, expect } from '@playwright/test';

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

  test('shows login form', async ({ page }) => {
    await expect(page.getByTestId('email-input')).toBeVisible();
    await expect(page.getByTestId('password-input')).toBeVisible();
  });

  test('logs in successfully', async ({ page }) => {
    await page.getByTestId('email-input').fill('[email protected]');
    await page.getByTestId('password-input').fill('password123');
    await page.getByTestId('submit-button').click();

    await expect(page).toHaveURL('/dashboard');
    await expect(page.getByText('Welcome')).toBeVisible();
  });

  test('handles API errors', async ({ page }) => {
    // 模拟 API 错误
    await page.route('**/api/login', async (route) => {
      await route.fulfill({
        status: 401,
        contentType: 'application/json',
        body: JSON.stringify({ error: 'Invalid credentials' })
      });
    });

    await page.getByTestId('email-input').fill('[email protected]');
    await page.getByTestId('password-input').fill('wrongpassword');
    await page.getByTestId('submit-button').click();

    await expect(page.getByText('Invalid credentials')).toBeVisible();
  });
});

页面对象模式

// tests/e2e/pages/LoginPage.ts
export class LoginPage {
  constructor(private page: Page) {}

  async goto() {
    await this.page.goto('/login');
  }

  async fillEmail(email: string) {
    await this.page.getByTestId('email-input').fill(email);
  }

  async fillPassword(password: string) {
    await this.page.getByTestId('password-input').fill(password);
  }

  async submit() {
    await this.page.getByTestId('submit-button').click();
  }

  async login(email: string, password: string) {
    await this.fillEmail(email);
    await this.fillPassword(password);
    await this.submit();
  }

  get errorMessage() {
    return this.page.locator('[data-testid=error-message]');
  }
}

// 使用
test('login flow', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login('[email protected]', 'password123');
  await expect(page).toHaveURL('/dashboard');
});

CI 中的测试

GitHub Actions 配置

# .github/workflows/test.yml
name: Test

on: [push, pull_request]

jobs:
  unit-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:unit -- --coverage
      - uses: codecov/codecov-action@v4
        with:
          token: ${{ secrets.CODECOV_TOKEN }}

  e2e-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'

      - run: npm ci
      - run: npm run build

      - name: Run E2E tests
        uses: cypress-io/github-action@v5
        with:
          install-command: npm ci
          run-tests: npm run test:e2e
        env:
          CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}

测试覆盖率

生成覆盖率报告

# Jest
npm run test -- --coverage

# Vitest
npm run test:coverage

解读覆盖率

指标 含义
Statements 语句覆盖率 - 执行了多少语句
Branches 分支覆盖率 - 执行了多少分支(如 if/else)
Functions 函数覆盖率 - 调用了多少函数
Lines 行覆盖率 - 执行了多少行代码

覆盖率目标

// jest.config.js
coverageThreshold: {
  global: {
    branches: 80,      // 80% 分支
    functions: 80,     // 80% 函数
    lines: 80,         // 80% 行
    statements: 80     // 80% 语句
  },

  // 特定文件可以设置不同阈值
  './src/utils/**/*.js': {
    branches: 90,
    statements: 90
  }
}

这一章想说的

测试是代码质量保障的重要手段:

  1. 测试金字塔:单元测试为基础,E2E 测试为补充
  2. Jest/Vitest:单元测试框架,配置简单,生态丰富
  3. Testing Library:React 组件测试的首选,关注用户行为
  4. Cypress/Playwright:E2E 测试,真实浏览器环境
  5. 覆盖率:作为质量指标,但不能作为唯一目标

好的测试不是追求覆盖率数字,而是测试关键路径和边界情况。


延展阅读