测试金字塔
┌──────────────────────────────────────────────────────────────┐
│ 测试金字塔 │
├──────────────────────────────────────────────────────────────┤
│ │
│ ▲ │
│ ╱ ╲ │
│ ╱ ╲ │
│ ╱ 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
}
}
这一章想说的
测试是代码质量保障的重要手段:
- 测试金字塔:单元测试为基础,E2E 测试为补充
- Jest/Vitest:单元测试框架,配置简单,生态丰富
- Testing Library:React 组件测试的首选,关注用户行为
- Cypress/Playwright:E2E 测试,真实浏览器环境
- 覆盖率:作为质量指标,但不能作为唯一目标
好的测试不是追求覆盖率数字,而是测试关键路径和边界情况。