前端测试策略
问题
前端测试策略有哪些?如何选择合适的测试框架?单元测试、集成测试和端到端测试各有什么特点?
答案
前端测试是保障代码质量、防止回归缺陷的核心手段。一个成熟的前端项目通常会采用测试金字塔模型,从底层的单元测试到顶层的 E2E 测试分层覆盖,配合 Mock 技术、覆盖率工具和快照测试等手段,构建完整的质量保障体系。
测试金字塔
测试金字塔是 Mike Cohn 提出的经典模型,它指导我们如何分配不同层级测试的比例。越底层的测试数量越多、速度越快、成本越低;越顶层的测试数量越少、速度越慢、但覆盖面越广。
三层测试详解
| 层级 | 测试对象 | 典型工具 | 运行速度 | 维护成本 | 覆盖比例建议 |
|---|---|---|---|---|---|
| 单元测试 | 函数、工具方法、Hooks、Store | Jest、Vitest | 毫秒级 | 低 | 70% |
| 集成测试 | 组件交互、API 调用、页面模块 | Testing Library、MSW | 秒级 | 中 | 20% |
| E2E 测试 | 完整用户流程、跨页面场景 | Cypress、Playwright | 十秒级 | 高 | 10% |
面试时提到测试金字塔,关键要说明底层测试多、顶层测试少的原则,以及每一层的职责边界。过多的 E2E 测试会导致 CI 缓慢和维护噩梦,过少的单元测试会导致代码逻辑缺乏保障。
单元测试示例
单元测试专注于最小可测试单元(函数、类、模块),不依赖外部服务或 DOM:
export function formatPrice(cents: number): string {
if (cents < 0) throw new Error('Price cannot be negative');
return `¥${(cents / 100).toFixed(2)}`;
}
export function truncate(str: string, maxLen: number): string {
if (str.length <= maxLen) return str;
return str.slice(0, maxLen) + '...';
}
import { describe, it, expect } from 'vitest';
import { formatPrice, truncate } from '../format';
describe('formatPrice', () => {
it('should format cents to yuan', () => {
expect(formatPrice(1999)).toBe('¥19.99');
expect(formatPrice(0)).toBe('¥0.00');
expect(formatPrice(100)).toBe('¥1.00');
});
it('should throw for negative values', () => {
expect(() => formatPrice(-1)).toThrow('Price cannot be negative');
});
});
describe('truncate', () => {
it('should truncate long strings', () => {
expect(truncate('Hello World', 5)).toBe('Hello...');
});
it('should not truncate short strings', () => {
expect(truncate('Hi', 5)).toBe('Hi');
});
});
集成测试示例
集成测试验证多个模块的协作是否正确,通常涉及组件渲染、事件触发和 API 调用:
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect } from 'vitest';
import { LoginForm } from '../LoginForm';
describe('LoginForm Integration', () => {
it('should submit form and show success message', async () => {
const user = userEvent.setup();
render(<LoginForm />);
// 模拟用户输入
await user.type(screen.getByLabelText('邮箱'), 'test@example.com');
await user.type(screen.getByLabelText('密码'), 'password123');
await user.click(screen.getByRole('button', { name: '登录' }));
// 验证交互结果
await waitFor(() => {
expect(screen.getByText('登录成功')).toBeInTheDocument();
});
});
});
E2E 测试示例
E2E 测试模拟真实用户从打开浏览器到完成操作的完整流程:
import { test, expect } from '@playwright/test';
test('complete checkout flow', async ({ page }) => {
// 模拟完整的购物流程
await page.goto('/products');
await page.click('[data-testid="product-card"]:first-child');
await page.click('button:has-text("加入购物车")');
await page.goto('/cart');
// 填写结算信息
await page.fill('[name="address"]', '北京市朝阳区');
await page.fill('[name="phone"]', '13800138000');
await page.click('button:has-text("提交订单")');
// 验证结算成功
await expect(page.locator('.order-success')).toBeVisible();
await expect(page.locator('.order-number')).toHaveText(/ORD-\d+/);
});
测试框架对比:Jest vs Vitest
Jest 和 Vitest 是当前前端最主流的两个测试框架。Jest 由 Facebook 维护,生态成熟;Vitest 由 Vite 团队开发,原生支持 ESM 和 TypeScript,速度更快。
核心对比
| 特性 | Jest | Vitest |
|---|---|---|
| 运行环境 | Node.js(自定义转换) | 基于 Vite(原生 ESM) |
| TypeScript | 需要 ts-jest 或 @swc/jest | 原生支持(通过 Vite) |
| ESM 支持 | 实验性,配置复杂 | 原生支持 |
| 配置复杂度 | 较高(babel/swc 转换) | 低(复用 vite.config) |
| 运行速度 | 中等 | 快(HMR 级别的热重载) |
| 生态成熟度 | 非常成熟、社区庞大 | 快速增长、兼容 Jest API |
| Watch 模式 | 基于文件变更 | 基于 Vite HMR,更快 |
| 快照测试 | 支持 | 支持 |
| 覆盖率 | istanbul / c8 | c8 / istanbul |
| 并行执行 | Worker 级别 | 线程级别(更轻量) |
| 浏览器模式 | 无 | 支持(实验性) |
| API 兼容性 | — | 兼容 Jest 大部分 API |
Jest 配置示例
import type { Config } from 'jest';
const config: Config = {
preset: 'ts-jest',
testEnvironment: 'jsdom',
roots: ['<rootDir>/src'],
moduleNameMapper: {
// 处理路径别名
'^@/(.*)$': '<rootDir>/src/$1',
// 处理样式文件
'\\.(css|less|scss)$': 'identity-obj-proxy',
},
setupFilesAfterSetup: ['<rootDir>/src/setupTests.ts'],
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/**/*.d.ts',
'!src/**/index.ts',
],
};
export default config;
Vitest 配置示例
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
test: {
// 使用 jsdom 模拟浏览器环境
environment: 'jsdom',
globals: true,
setupFiles: './src/setupTests.ts',
// 覆盖率配置
coverage: {
provider: 'v8', // 或 'istanbul'
reporter: ['text', 'json', 'html'],
exclude: ['node_modules/', 'src/setupTests.ts'],
},
// 包含的测试文件
include: ['src/**/*.{test,spec}.{ts,tsx}'],
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});
- 新项目且使用 Vite:优先选择 Vitest,配置简单、速度快、原生 TS/ESM 支持
- 已有 Jest 的项目:迁移到 Vitest 成本低(API 几乎兼容),但非必需
- CRA/Next.js 项目:Jest 仍是默认选项,与框架集成更成熟
- 需要浏览器模式测试:Vitest 有实验性浏览器模式支持
组件测试
组件测试是前端集成测试的核心,验证组件的渲染输出、用户交互和状态变化是否符合预期。
React Testing Library
React Testing Library 的核心理念是按用户使用方式测试,鼓励通过角色、文本等用户可见信息查询 DOM,而非内部实现细节。
import { useState } from 'react';
interface CounterProps {
initialCount?: number;
onCountChange?: (count: number) => void;
}
export function Counter({ initialCount = 0, onCountChange }: CounterProps) {
const [count, setCount] = useState(initialCount);
const increment = () => {
const newCount = count + 1;
setCount(newCount);
onCountChange?.(newCount);
};
const decrement = () => {
const newCount = count - 1;
setCount(newCount);
onCountChange?.(newCount);
};
return (
<div>
<h2>计数器</h2>
<p data-testid="count-display">当前计数: {count}</p>
<button onClick={decrement}>减少</button>
<button onClick={increment}>增加</button>
</div>
);
}
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import { Counter } from '../Counter';
describe('Counter', () => {
it('should render with default count', () => {
render(<Counter />);
expect(screen.getByText('当前计数: 0')).toBeInTheDocument();
});
it('should render with initial count', () => {
render(<Counter initialCount={10} />);
expect(screen.getByText('当前计数: 10')).toBeInTheDocument();
});
it('should increment count on button click', async () => {
const user = userEvent.setup();
render(<Counter />);
await user.click(screen.getByText('增加'));
expect(screen.getByText('当前计数: 1')).toBeInTheDocument();
await user.click(screen.getByText('增加'));
expect(screen.getByText('当前计数: 2')).toBeInTheDocument();
});
it('should call onCountChange callback', async () => {
const user = userEvent.setup();
const handleChange = vi.fn();
render(<Counter onCountChange={handleChange} />);
await user.click(screen.getByText('增加'));
expect(handleChange).toHaveBeenCalledWith(1);
await user.click(screen.getByText('减少'));
expect(handleChange).toHaveBeenCalledWith(0);
expect(handleChange).toHaveBeenCalledTimes(2);
});
});
常用查询方法优先级
React Testing Library 推荐按照以下优先级选择查询方式:
| 优先级 | 查询方法 | 说明 |
|---|---|---|
| 1(推荐) | getByRole | 按 ARIA 角色查询,最贴近用户体验 |
| 2 | getByLabelText | 按 label 文本查询,适合表单元素 |
| 3 | getByPlaceholderText | 按 placeholder 查询 |
| 4 | getByText | 按可见文本查询 |
| 5 | getByDisplayValue | 按表单当前值查询 |
| 6(备选) | getByTestId | 按 data-testid 查询,兜底方案 |
避免使用 container.querySelector 等直接操作 DOM 的方式,这违背了 Testing Library 的设计哲学。测试应关注用户行为而非实现细节,这样在重构时测试不会轻易失败。
Vue Test Utils
Vue Test Utils 是 Vue 官方提供的组件测试工具库,配合 @vue/test-utils 使用:
import { mount } from '@vue/test-utils';
import { describe, it, expect } from 'vitest';
import TodoList from '../TodoList.vue';
describe('TodoList', () => {
it('should add a new todo item', async () => {
const wrapper = mount(TodoList);
const input = wrapper.find('input[type="text"]');
const form = wrapper.find('form');
await input.setValue('学习 Vitest');
await form.trigger('submit');
// 验证新 todo 被添加
const items = wrapper.findAll('[data-testid="todo-item"]');
expect(items).toHaveLength(1);
expect(items[0].text()).toContain('学习 Vitest');
});
it('should toggle todo completion', async () => {
const wrapper = mount(TodoList, {
props: {
initialTodos: [
{ id: 1, text: '写测试', completed: false },
],
},
});
const checkbox = wrapper.find('input[type="checkbox"]');
await checkbox.setValue(true);
expect(wrapper.find('.completed').exists()).toBe(true);
});
it('should emit delete event', async () => {
const wrapper = mount(TodoList, {
props: {
initialTodos: [
{ id: 1, text: '写测试', completed: false },
],
},
});
await wrapper.find('[data-testid="delete-btn"]').trigger('click');
expect(wrapper.emitted('delete')).toBeTruthy();
expect(wrapper.emitted('delete')![0]).toEqual([1]);
});
});
React Testing Library vs Vue Test Utils
| 特性 | React Testing Library | Vue Test Utils |
|---|---|---|
| 查询理念 | 基于用户视角(角色、文本) | 基于组件结构(find/findAll) |
| 事件模拟 | userEvent(推荐)/ fireEvent | trigger / setValue |
| 异步等待 | waitFor / findBy* | nextTick / flushPromises |
| Props 传递 | render(<Comp prop={val} />) | mount(Comp, { props }) |
| Emit 测试 | 通过回调函数 mock | wrapper.emitted() |
| 浅渲染 | 不推荐 | shallowMount |
E2E 测试:Cypress vs Playwright
E2E(端到端)测试模拟真实用户在浏览器中的完整操作流程,验证整个应用从前端到后端是否正常工作。
核心对比
| 特性 | Cypress | Playwright |
|---|---|---|
| 开发者 | Cypress.io | Microsoft |
| 浏览器支持 | Chrome、Firefox、Edge、Electron | Chrome、Firefox、Safari、Edge |
| 并行执行 | 付费功能(Cypress Cloud) | 内置免费支持 |
| 多标签页 | 不支持 | 支持 |
| iframe 支持 | 有限 | 完整支持 |
| 网络拦截 | cy.intercept | page.route |
| 调试体验 | 时间旅行 UI(优秀) | Trace Viewer / Inspector |
| 编程语言 | JavaScript/TypeScript | JS/TS/Python/Java/C# |
| 自动等待 | 内置 | 内置 |
| API 测试 | cy.request | request context |
| 移动端模拟 | 视口模拟 | 设备模拟 + 触摸事件 |
| CI 集成 | Docker 镜像 | Docker 镜像 |
| 运行速度 | 较慢 | 更快(无头模式优化) |
| 学习曲线 | 低(交互式 UI) | 中等 |
Cypress 示例
describe('Login Flow', () => {
beforeEach(() => {
// 拦截 API 请求
cy.intercept('POST', '/api/auth/login', {
statusCode: 200,
body: { token: 'fake-jwt-token', user: { name: '张三' } },
}).as('loginRequest');
});
it('should login successfully with valid credentials', () => {
cy.visit('/login');
cy.get('[data-cy="email-input"]').type('test@example.com');
cy.get('[data-cy="password-input"]').type('password123');
cy.get('[data-cy="login-button"]').click();
// 等待 API 请求完成
cy.wait('@loginRequest');
// 验证跳转到首页
cy.url().should('include', '/dashboard');
cy.get('[data-cy="welcome-message"]').should('contain', '张三');
});
it('should show error for invalid credentials', () => {
cy.intercept('POST', '/api/auth/login', {
statusCode: 401,
body: { message: '邮箱或密码错误' },
}).as('loginFailed');
cy.visit('/login');
cy.get('[data-cy="email-input"]').type('wrong@example.com');
cy.get('[data-cy="password-input"]').type('wrongpass');
cy.get('[data-cy="login-button"]').click();
cy.wait('@loginFailed');
cy.get('[data-cy="error-message"]').should('contain', '邮箱或密码错误');
});
});
Playwright 示例
import { test, expect } from '@playwright/test';
test.describe('Login Flow', () => {
test('should login successfully', async ({ page }) => {
// 拦截 API
await page.route('**/api/auth/login', (route) => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
token: 'fake-jwt-token',
user: { name: '张三' },
}),
});
});
await page.goto('/login');
await page.getByLabel('邮箱').fill('test@example.com');
await page.getByLabel('密码').fill('password123');
await page.getByRole('button', { name: '登录' }).click();
// 验证结果
await expect(page).toHaveURL(/\/dashboard/);
await expect(page.getByText('张三')).toBeVisible();
});
test('should work on mobile viewport', async ({ page }) => {
// Playwright 原生支持设备模拟
await page.setViewportSize({ width: 375, height: 667 });
await page.goto('/login');
await expect(page.getByRole('button', { name: '登录' })).toBeVisible();
});
});
- 需要跨浏览器测试(尤其 Safari):选 Playwright
- 团队前端经验较少,需要直观调试:选 Cypress(时间旅行 UI 非常友好)
- 需要多标签页、iframe、文件下载等复杂场景:选 Playwright
- 预算有限,需要免费并行:选 Playwright
- 已有 Cypress 且运行良好:无需迁移
Mock 与 Stub
在测试中,我们经常需要模拟外部依赖(API 请求、模块、定时器等),避免测试依赖外部环境。
MSW(Mock Service Worker)
MSW 通过 Service Worker 在网络层拦截请求,无论你使用 fetch、axios 还是其他 HTTP 客户端,都能统一 mock。它既可以用在测试环境,也可以用在开发环境。
import { http, HttpResponse } from 'msw';
// 定义类型
interface User {
id: number;
name: string;
email: string;
}
// 定义请求处理器
export const handlers = [
// GET 请求
http.get('/api/users', () => {
return HttpResponse.json<User[]>([
{ id: 1, name: '张三', email: 'zhangsan@example.com' },
{ id: 2, name: '李四', email: 'lisi@example.com' },
]);
}),
// POST 请求
http.post('/api/users', async ({ request }) => {
const body = await request.json() as Omit<User, 'id'>;
return HttpResponse.json<User>(
{ id: 3, ...body },
{ status: 201 }
);
}),
// 模拟错误
http.get('/api/users/:id', ({ params }) => {
const { id } = params;
if (id === '999') {
return HttpResponse.json(
{ message: '用户不存在' },
{ status: 404 }
);
}
return HttpResponse.json<User>({
id: Number(id),
name: '张三',
email: 'zhangsan@example.com',
});
}),
];
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
// 创建测试服务器
export const server = setupServer(...handlers);
import { beforeAll, afterAll, afterEach } from 'vitest';
import { server } from './mocks/setup';
// 在所有测试之前启动 mock server
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterAll(() => server.close());
afterEach(() => server.resetHandlers());
在测试中使用 MSW:
import { describe, it, expect } from 'vitest';
import { http, HttpResponse } from 'msw';
import { server } from '../../mocks/setup';
import { fetchUsers, createUser } from '../userService';
describe('userService', () => {
it('should fetch users', async () => {
const users = await fetchUsers();
expect(users).toHaveLength(2);
expect(users[0].name).toBe('张三');
});
it('should handle server error', async () => {
// 针对单个测试覆盖 handler
server.use(
http.get('/api/users', () => {
return HttpResponse.json(
{ message: 'Internal Server Error' },
{ status: 500 }
);
})
);
await expect(fetchUsers()).rejects.toThrow('请求失败');
});
});
vi.mock / jest.mock
vi.mock(Vitest)和 jest.mock(Jest)用于模拟整个模块,适合隔离被测模块的外部依赖:
import { describe, it, expect, vi, beforeEach } from 'vitest';
// 模拟整个模块
vi.mock('../api', () => ({
trackEvent: vi.fn(),
trackPageView: vi.fn(),
}));
import { trackEvent, trackPageView } from '../api';
import { logUserAction } from '../analytics';
describe('analytics', () => {
beforeEach(() => {
// 每次测试前重置 mock
vi.clearAllMocks();
});
it('should track button click event', () => {
logUserAction('click', 'submit-button');
expect(trackEvent).toHaveBeenCalledWith({
action: 'click',
target: 'submit-button',
timestamp: expect.any(Number),
});
});
it('should track page view with correct path', () => {
logUserAction('pageview', '/dashboard');
expect(trackPageView).toHaveBeenCalledWith('/dashboard');
expect(trackEvent).not.toHaveBeenCalled();
});
});
常用 Mock 技巧
import { describe, it, expect, vi } from 'vitest';
// 1. Mock 定时器
describe('Timer Mocks', () => {
it('should handle setTimeout', () => {
vi.useFakeTimers();
const callback = vi.fn();
setTimeout(callback, 1000);
expect(callback).not.toHaveBeenCalled();
vi.advanceTimersByTime(1000);
expect(callback).toHaveBeenCalledOnce();
vi.useRealTimers();
});
});
// 2. Mock Date
describe('Date Mocks', () => {
it('should mock current date', () => {
const mockDate = new Date('2025-01-01T00:00:00Z');
vi.setSystemTime(mockDate);
expect(new Date().getFullYear()).toBe(2025);
vi.useRealTimers();
});
});
// 3. Spy on method
describe('Spy', () => {
it('should spy on console.log', () => {
const consoleSpy = vi.spyOn(console, 'log');
console.log('test message');
expect(consoleSpy).toHaveBeenCalledWith('test message');
consoleSpy.mockRestore();
});
});
// 4. Mock implementation
describe('Mock Implementation', () => {
it('should mock with custom implementation', () => {
const mockFn = vi.fn<(a: number, b: number) => number>();
mockFn.mockImplementation((a, b) => a + b);
expect(mockFn(1, 2)).toBe(3);
expect(mockFn).toHaveBeenCalledWith(1, 2);
});
it('should mock return values', () => {
const mockFn = vi.fn<() => string>();
mockFn
.mockReturnValueOnce('first')
.mockReturnValueOnce('second')
.mockReturnValue('default');
expect(mockFn()).toBe('first');
expect(mockFn()).toBe('second');
expect(mockFn()).toBe('default');
});
});
- Mock:完全替换原始实现,可以验证调用情况(
vi.fn()) - Stub:替换原始实现,返回预设数据,不关心调用情况(
vi.fn().mockReturnValue()) - Spy:保留原始实现,但可以监控调用情况(
vi.spyOn())
测试覆盖率
测试覆盖率衡量代码被测试执行的比例,是评估测试充分性的重要指标。
覆盖率类型
| 类型 | 英文 | 说明 |
|---|---|---|
| 语句覆盖率 | Statement | 每条语句是否被执行 |
| 分支覆盖率 | Branch | 每个 if/else 分支是否被覆盖 |
| 函数覆盖率 | Function | 每个函数是否被调用 |
| 行覆盖率 | Line | 每一行是否被执行 |
Istanbul vs c8
| 特性 | Istanbul | c8 |
|---|---|---|
| 实现方式 | 代码插桩(instrumentation) | V8 内置覆盖率 |
| 性能 | 较慢(需要转换代码) | 更快(利用 V8 原生能力) |
| 准确性 | 高 | 高(V8 级别的精确度) |
| 配置复杂度 | 需要 babel 插件 | 零配置 |
| 支持 ESM | 需要额外配置 | 原生支持 |
Vitest 覆盖率配置
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
coverage: {
provider: 'v8', // 使用 c8(V8 原生覆盖率)
reporter: ['text', 'json', 'html', 'lcov'],
// 覆盖率阈值
thresholds: {
statements: 80,
branches: 75,
functions: 80,
lines: 80,
},
// 需要收集覆盖率的文件
include: ['src/**/*.{ts,tsx}'],
exclude: [
'src/**/*.d.ts',
'src/**/*.test.{ts,tsx}',
'src/**/*.spec.{ts,tsx}',
'src/**/index.ts',
'src/mocks/**',
],
},
},
});
运行覆盖率检查:
# Vitest
npx vitest run --coverage
# Jest
npx jest --coverage
覆盖率 100% 不等于测试充分。覆盖率只能说明代码被执行了,但不能保证断言的正确性。例如一个没有 expect 的测试也会增加覆盖率,但并没有真正验证行为。合理的覆盖率目标通常是 70%-90%,关键路径(支付、认证等)要求更高。
TDD vs BDD
TDD(Test-Driven Development)
TDD 即测试驱动开发,遵循 Red-Green-Refactor 循环:
TDD 示例 -- 实现一个 Stack 类:
import { describe, it, expect } from 'vitest';
import { Stack } from '../stack';
// 第一轮 Red: 先写测试
describe('Stack', () => {
it('should be empty when created', () => {
const stack = new Stack<number>();
expect(stack.isEmpty()).toBe(true);
expect(stack.size()).toBe(0);
});
it('should push and pop elements', () => {
const stack = new Stack<number>();
stack.push(1);
stack.push(2);
expect(stack.pop()).toBe(2);
expect(stack.pop()).toBe(1);
expect(stack.isEmpty()).toBe(true);
});
it('should peek without removing', () => {
const stack = new Stack<number>();
stack.push(42);
expect(stack.peek()).toBe(42);
expect(stack.size()).toBe(1); // peek 不会移除元素
});
it('should throw when popping empty stack', () => {
const stack = new Stack<number>();
expect(() => stack.pop()).toThrow('Stack is empty');
});
});
// 第二轮 Green: 写最少的代码让测试通过
export class Stack<T> {
private items: T[] = [];
push(item: T): void {
this.items.push(item);
}
pop(): T {
if (this.isEmpty()) {
throw new Error('Stack is empty');
}
return this.items.pop()!;
}
peek(): T {
if (this.isEmpty()) {
throw new Error('Stack is empty');
}
return this.items[this.items.length - 1];
}
isEmpty(): boolean {
return this.items.length === 0;
}
size(): number {
return this.items.length;
}
}
BDD(Behavior-Driven Development)
BDD 即行为驱动开发,关注用户行为和业务需求的描述,使用 Given-When-Then 格式编写测试,使非技术人员也能理解:
import { describe, it, expect } from 'vitest';
import { ShoppingCart } from '../ShoppingCart';
describe('购物车功能', () => {
describe('当用户添加商品到购物车时', () => {
it('应该显示正确的商品数量', () => {
// Given: 一个空购物车
const cart = new ShoppingCart();
// When: 用户添加两件商品
cart.addItem({ id: '1', name: 'TypeScript 入门', price: 5900, qty: 1 });
cart.addItem({ id: '2', name: 'React 进阶', price: 7900, qty: 2 });
// Then: 购物车应该显示 3 件商品
expect(cart.totalItems()).toBe(3);
expect(cart.itemCount()).toBe(2); // 2 种商品
});
it('应该计算正确的总价', () => {
// Given
const cart = new ShoppingCart();
// When
cart.addItem({ id: '1', name: 'TypeScript 入门', price: 5900, qty: 1 });
cart.addItem({ id: '2', name: 'React 进阶', price: 7900, qty: 2 });
// Then: 5900 + 7900 * 2 = 21700
expect(cart.totalPrice()).toBe(21700);
});
});
describe('当用户使用优惠券时', () => {
it('应该正确计算折扣后价格', () => {
// Given: 购物车有商品
const cart = new ShoppingCart();
cart.addItem({ id: '1', name: 'TypeScript 入门', price: 10000, qty: 1 });
// When: 用户使用 8 折优惠券
cart.applyCoupon({ code: 'SALE20', discount: 0.8 });
// Then: 总价应为 8000
expect(cart.finalPrice()).toBe(8000);
});
});
});
TDD vs BDD 对比
| 维度 | TDD | BDD |
|---|---|---|
| 关注点 | 代码实现正确性 | 业务行为正确性 |
| 测试语言 | 技术术语 | 自然语言(Given/When/Then) |
| 驱动方式 | 由测试驱动代码实现 | 由行为规格驱动开发 |
| 适用人群 | 开发者 | 开发者 + 产品 + 测试 |
| 粒度 | 细粒度(单元级别) | 粗粒度(功能级别) |
| 典型工具 | Jest、Vitest | Cucumber、Jest + describe |
| 开发流程 | Red -> Green -> Refactor | 规格 -> 实现 -> 验证 |
快照测试
快照测试(Snapshot Testing)将组件的渲染输出序列化为字符串并保存,后续测试运行时与保存的快照进行对比,检测意外的 UI 变化。
基本用法
import { render } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { Button } from '../Button';
describe('Button Snapshot', () => {
it('should match snapshot for primary button', () => {
const { container } = render(
<Button variant="primary" size="large">
提交
</Button>
);
expect(container.firstChild).toMatchSnapshot();
});
it('should match snapshot for disabled button', () => {
const { container } = render(
<Button variant="primary" disabled>
提交
</Button>
);
expect(container.firstChild).toMatchSnapshot();
});
});
生成的快照文件(自动创建):
exports[`Button Snapshot > should match snapshot for primary button 1`] = `
<button
class="btn btn-primary btn-large"
>
提交
</button>
`;
内联快照
内联快照将快照内容直接写在测试文件中,适合输出较短的场景:
import { describe, it, expect } from 'vitest';
import { formatDate } from '../format';
describe('formatDate', () => {
it('should format date correctly', () => {
// 内联快照:第一次运行后自动填充
expect(formatDate(new Date('2025-06-15'))).toMatchInlineSnapshot(
`"2025年06月15日"`
);
});
});
快照测试最佳实践
- 快照应该小:避免对大型组件做快照,快照过大难以 review
- 有意义的快照:确保快照包含有意义的输出,而非随机 ID 或时间戳
- 及时更新:快照失败时仔细检查变更是否符合预期,而非无脑
--update - 搭配其他测试:快照测试不能替代行为测试,它只检测输出变化,不验证正确性
快照测试的常见问题:
- 快照过大导致 Code Review 时被忽略
- 动态内容(时间戳、随机 ID)导致频繁失败
- 团队成员无脑执行
vitest -u更新快照,失去了检测意义
建议对动态内容使用 expect.any() 或在序列化器中过滤。
常见面试问题
Q1: 前端项目应该如何规划测试策略?不同类型的测试各负责什么?
答案:
前端测试策略应遵循测试金字塔模型,按照从底层到顶层的顺序规划:
1. 单元测试(约 70%): 测试最小代码单元 -- 工具函数、自定义 Hooks、Store 逻辑、纯组件。单元测试运行速度最快(毫秒级),应该覆盖项目中所有核心逻辑。
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { useDebounce } from '../useDebounce';
describe('useDebounce', () => {
it('should debounce value changes', () => {
vi.useFakeTimers();
const { result, rerender } = renderHook(
({ value }) => useDebounce(value, 300),
{ initialProps: { value: 'hello' } }
);
// 初始值
expect(result.current).toBe('hello');
// 更新值但未到延迟时间
rerender({ value: 'world' });
expect(result.current).toBe('hello'); // 还是旧值
// 快进时间
act(() => {
vi.advanceTimersByTime(300);
});
expect(result.current).toBe('world'); // 延迟后更新
vi.useRealTimers();
});
});
2. 集成测试(约 20%): 测试组件之间的交互、表单提交、API 调用等。使用 React Testing Library + MSW 模拟真实用户操作和网络请求。
3. E2E 测试(约 10%): 覆盖核心用户流程,如登录、支付、注册。使用 Playwright 或 Cypress 在真实浏览器中运行。
关键原则:
- 测试行为而非实现细节
- 关键路径的覆盖率要高于平均水平
- CI 中必须包含测试步骤,测试不通过则阻止合并
Q2: Jest 和 Vitest 有什么区别?为什么越来越多项目选择 Vitest?
答案:
Jest 和 Vitest 的核心区别在于底层架构和生态定位:
| 对比维度 | Jest | Vitest |
|---|---|---|
| 模块转换 | 通过 Babel/SWC 将 ESM 转为 CJS | 基于 Vite,原生支持 ESM |
| TypeScript | 需要 ts-jest 或 @swc/jest | 原生支持,零配置 |
| 配置 | 独立配置文件,需配置转换器和路径映射 | 复用 vite.config,配置统一 |
| 热重载 | 文件变更触发相关测试 | 基于 Vite HMR,速度更快 |
| 并发 | Worker 进程级隔离 | 线程级隔离,更轻量 |
Vitest 越来越流行的原因:
import { defineConfig } from 'vitest/config';
// 1. 复用 Vite 配置,不需要重复配置路径别名、插件等
export default defineConfig({
test: {
environment: 'jsdom',
globals: true,
// 2. 原生支持 ESM 和 TypeScript,无需转换器
// 3. API 与 Jest 几乎完全兼容,迁移成本低
// 4. 内置覆盖率支持(v8/istanbul)
coverage: {
provider: 'v8',
},
},
});
迁移建议:新的 Vite 项目直接使用 Vitest;已有 Jest 项目可以渐进迁移,因为 API 高度兼容,通常只需替换导入语句:
// Jest
// import { describe, it, expect, jest } from '@jest/globals';
// Vitest(兼容写法)
import { describe, it, expect, vi } from 'vitest';
// jest.fn() -> vi.fn()
// jest.mock() -> vi.mock()
// jest.spyOn() -> vi.spyOn()
Q3: 如何使用 MSW 做 API Mock?它相比 jest.mock 有什么优势?
答案:
MSW(Mock Service Worker)在网络层拦截 HTTP 请求,而 jest.mock/vi.mock 在模块层替换导入。两者的根本区别如下:
| 对比维度 | MSW | vi.mock / jest.mock |
|---|---|---|
| 拦截层级 | 网络层(Service Worker) | 模块导入层 |
| HTTP 客户端 | 无关(fetch/axios/XHR 通用) | 需要 mock 具体的客户端模块 |
| 请求验证 | 可以验证请求体、请求头等 | 只能验证模块函数的调用参数 |
| 复用性 | 测试 + 开发环境通用 | 仅测试环境 |
| 真实度 | 更接近真实网络请求 | 完全跳过网络层 |
MSW 的核心优势在于与 HTTP 客户端解耦:
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
// 无论业务代码使用 fetch 还是 axios,mock 方式完全相同
const server = setupServer(
http.get('/api/products', () => {
return HttpResponse.json([
{ id: 1, name: '商品A', price: 100 },
{ id: 2, name: '商品B', price: 200 },
]);
})
);
beforeAll(() => server.listen());
afterAll(() => server.close());
afterEach(() => server.resetHandlers());
// 测试 fetchProducts —— 不关心它内部用的是 fetch 还是 axios
import { fetchProducts } from '../productService';
describe('productService', () => {
it('should return product list', async () => {
const products = await fetchProducts();
expect(products).toHaveLength(2);
expect(products[0].name).toBe('商品A');
});
it('should handle network error', async () => {
// 针对这个测试模拟网络错误
server.use(
http.get('/api/products', () => {
return HttpResponse.error();
})
);
await expect(fetchProducts()).rejects.toThrow();
});
it('should handle specific HTTP status', async () => {
server.use(
http.get('/api/products', () => {
return HttpResponse.json(
{ message: 'Unauthorized' },
{ status: 401 }
);
})
);
await expect(fetchProducts()).rejects.toThrow('认证失败');
});
});
使用 vi.mock 实现相同功能则需要 mock 具体的 HTTP 模块,耦合度更高:
// 如果业务代码从 fetch 切换到 axios,这里也得改
vi.mock('axios', () => ({
default: {
get: vi.fn().mockResolvedValue({
data: [{ id: 1, name: '商品A', price: 100 }],
}),
},
}));
最佳实践:API 相关的测试优先使用 MSW,模块内部逻辑的隔离使用 vi.mock。两者结合使用能达到最好的效果。
Q4: 如何测试 React 组件?React Testing Library 的核心理念?
答案:
React Testing Library(RTL)是当前 React 组件测试的标准工具。它的核心理念是 "The more your tests resemble the way your software is used, the more confidence they can give you"——测试越接近用户的真实使用方式,测试就越有价值。
核心理念:
| 理念 | 说明 | 对比 Enzyme |
|---|---|---|
| 按用户行为测试 | 通过角色、文本等用户可感知的信息查询元素 | Enzyme 鼓励测试组件内部 state 和 props |
| 不测试实现细节 | 不关心组件内部状态、生命周期、方法名 | Enzyme 的 shallow / instance() 暴露内部 |
| 可访问性驱动 | 优先使用 getByRole、getByLabelText | Enzyme 通常用 CSS 选择器或组件名 |
| 真实 DOM | 渲染到真实 DOM(jsdom),更接近浏览器 | Enzyme 的 shallow 不渲染子组件 |
查询方法优先级(面试必考):
// 1. getByRole —— 最推荐,按 ARIA 角色查询
screen.getByRole('button', { name: '提交' });
screen.getByRole('heading', { level: 2 });
screen.getByRole('textbox', { name: '用户名' });
// 2. getByLabelText —— 表单元素首选
screen.getByLabelText('邮箱');
// 3. getByPlaceholderText —— 没有 label 时的备选
screen.getByPlaceholderText('请输入搜索关键词');
// 4. getByText —— 按可见文本查询
screen.getByText('登录成功');
screen.getByText(/欢迎.*张三/); // 支持正则
// 5. getByDisplayValue —— 按表单当前值
screen.getByDisplayValue('test@example.com');
// 6. getByTestId —— 兜底方案,不推荐滥用
screen.getByTestId('custom-element');
完整的组件测试示例:
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
interface SearchResult {
id: string;
title: string;
}
interface SearchFormProps {
onSearch: (query: string) => Promise<SearchResult[]>;
}
function SearchForm({ onSearch }: SearchFormProps) {
const [query, setQuery] = React.useState('');
const [results, setResults] = React.useState<SearchResult[]>([]);
const [loading, setLoading] = React.useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
const data = await onSearch(query);
setResults(data);
setLoading(false);
};
return (
<form onSubmit={handleSubmit}>
<label htmlFor="search">搜索</label>
<input
id="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<button type="submit" disabled={!query}>搜索</button>
{loading && <p>加载中...</p>}
<ul>
{results.map((r) => (
<li key={r.id}>{r.title}</li>
))}
</ul>
</form>
);
}
import React from 'react';
describe('SearchForm', () => {
it('should disable button when input is empty', () => {
render(<SearchForm onSearch={vi.fn()} />);
// 用 getByRole 查询按钮,而非 CSS 选择器
expect(screen.getByRole('button', { name: '搜索' })).toBeDisabled();
});
it('should call onSearch with query and display results', async () => {
const user = userEvent.setup();
const mockSearch = vi.fn().mockResolvedValue([
{ id: '1', title: 'React 入门' },
{ id: '2', title: 'React 进阶' },
]);
render(<SearchForm onSearch={mockSearch} />);
// 用 getByLabelText 查询输入框
await user.type(screen.getByLabelText('搜索'), 'React');
await user.click(screen.getByRole('button', { name: '搜索' }));
// 验证 onSearch 被正确调用
expect(mockSearch).toHaveBeenCalledWith('React');
// 等待异步结果渲染
await waitFor(() => {
expect(screen.getByText('React 入门')).toBeInTheDocument();
expect(screen.getByText('React 进阶')).toBeInTheDocument();
});
});
it('should show loading state during search', async () => {
const user = userEvent.setup();
// 模拟延迟返回
const mockSearch = vi.fn().mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve([]), 100))
);
render(<SearchForm onSearch={mockSearch} />);
await user.type(screen.getByLabelText('搜索'), 'test');
await user.click(screen.getByRole('button', { name: '搜索' }));
// 验证 loading 状态
expect(screen.getByText('加载中...')).toBeInTheDocument();
await waitFor(() => {
expect(screen.queryByText('加载中...')).not.toBeInTheDocument();
});
});
});
测试自定义 Hooks:
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { useLocalStorage } from '../useLocalStorage';
describe('useLocalStorage', () => {
it('should return initial value', () => {
const { result } = renderHook(() =>
useLocalStorage('key', 'default')
);
expect(result.current[0]).toBe('default');
});
it('should update value and persist to localStorage', () => {
const { result } = renderHook(() =>
useLocalStorage('theme', 'light')
);
// act 包裹状态更新
act(() => {
result.current[1]('dark');
});
expect(result.current[0]).toBe('dark');
expect(localStorage.getItem('theme')).toBe('"dark"');
});
});
面试中被问到"如何测试 React 组件"时,核心回答三点:
- 使用 React Testing Library,按用户行为测试,不测实现细节
- 查询优先级:
getByRole>getByLabelText>getByText>getByTestId - 使用
userEvent(而非fireEvent)模拟用户交互,因为它更接近真实浏览器行为
Q5: Mock 的最佳实践(什么该 Mock,什么不该 Mock)
答案:
Mock 的核心原则是:只 Mock 你无法控制的东西。过度 Mock 会让测试变成"测试 Mock 本身",失去对真实行为的验证。
应该 Mock 的:
| 类型 | 原因 | 示例 |
|---|---|---|
| 外部 API 请求 | 网络不可靠、速度慢、不想依赖外部服务 | fetch('/api/users') |
| 浏览器原生 API | 测试环境(jsdom)不完全支持 | navigator.geolocation、IntersectionObserver |
| 时间相关 | 定时器、日期会导致测试不稳定 | setTimeout、Date.now() |
| 随机数 | 每次结果不同导致断言不稳定 | Math.random() |
| 第三方服务 SDK | 不想在测试中真正调用付费服务 | Stripe、Firebase、Sentry |
| 复杂的计算密集模块 | 测试目标不在于该模块 | 图片处理、加密算法 |
不该 Mock 的:
| 类型 | 原因 | 正确做法 |
|---|---|---|
| 被测模块自身的方法 | Mock 了就不是在测真实代码 | 直接调用真实实现 |
| 纯函数 / 工具方法 | 执行快且确定,无需 Mock | 直接引入并使用 |
| 简单的数据转换 | 过度 Mock 让测试失去意义 | 使用真实数据 |
| React 组件的子组件 | RTL 理念不鼓励浅渲染 | 完整渲染并测试交互 |
| 状态管理逻辑 | 应该集成测试 Store 和组件 | 用真实 Store 配合组件测试 |
反模式示例:
import { describe, it, expect, vi } from 'vitest';
// ❌ 反模式1: Mock 被测函数的内部依赖导致测不到真实逻辑
function calculateTotal(items: { price: number; qty: number }[]): number {
return items.reduce((sum, item) => sum + item.price * item.qty, 0);
}
// 这样测没有意义 —— 你在测试 Mock 本身,而非 calculateTotal
vi.mock('./calculateTotal', () => ({
calculateTotal: vi.fn().mockReturnValue(100),
}));
// ✅ 正确做法:直接测试真实函数
it('should calculate total correctly', () => {
const items = [
{ price: 10, qty: 2 },
{ price: 20, qty: 1 },
];
expect(calculateTotal(items)).toBe(40);
});
import { describe, it, expect, vi } from 'vitest';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
// ✅ 最佳实践: 用 MSW Mock 外部 API,测试真实的业务逻辑
const server = setupServer(
http.get('/api/user/profile', () => {
return HttpResponse.json({
id: '1',
name: '张三',
vipLevel: 3,
});
})
);
// 被测函数内部有复杂逻辑(VIP 折扣计算),这些逻辑不该被 Mock
async function getDiscountedPrice(
productId: string,
basePrice: number
): Promise<number> {
const res = await fetch('/api/user/profile');
const user = await res.json() as { vipLevel: number };
// VIP 等级折扣:1级 95折,2级 9折,3级 85折
const discountMap: Record<number, number> = { 1: 0.95, 2: 0.9, 3: 0.85 };
const discount = discountMap[user.vipLevel] ?? 1;
return Math.round(basePrice * discount);
}
describe('getDiscountedPrice', () => {
beforeAll(() => server.listen());
afterAll(() => server.close());
afterEach(() => server.resetHandlers());
it('should apply VIP level 3 discount', async () => {
// API 被 MSW Mock,但折扣计算逻辑是真实的
const price = await getDiscountedPrice('prod-1', 10000);
expect(price).toBe(8500); // 10000 * 0.85
});
it('should handle non-VIP user', async () => {
server.use(
http.get('/api/user/profile', () => {
return HttpResponse.json({ id: '2', name: '李四', vipLevel: 0 });
})
);
const price = await getDiscountedPrice('prod-1', 10000);
expect(price).toBe(10000); // 无折扣
});
});
Mock 层级金字塔:
- 测试通过但生产环境出 bug(因为测试没有覆盖真实逻辑)
- 重构代码时大量测试需要同步修改(Mock 耦合了实现细节)
- 给人虚假的安全感(覆盖率很高但实际保障很低)
经验法则:如果一个测试文件中 Mock 代码比断言代码还多,说明 Mock 过度了。
Q6: E2E 测试框架选型(Playwright vs Cypress)
答案:
Playwright 和 Cypress 是当前最主流的两个 E2E 测试框架。它们的设计理念和架构有本质区别,适用于不同的场景。
架构差异:
| 维度 | Playwright | Cypress |
|---|---|---|
| 运行架构 | 通过 CDP/WebSocket 远程控制浏览器 | 在浏览器内部运行测试代码 |
| 进程模型 | 测试进程 ≠ 浏览器进程 | 测试代码与应用同进程 |
| 异步模型 | 原生 async/await | 自定义命令链(类似 Promise chain) |
| 浏览器引擎 | Chromium、Firefox、WebKit | Chromium、Firefox、Electron |
核心功能对比:
| 特性 | Playwright | Cypress |
|---|---|---|
| Safari 支持 | 支持(WebKit 引擎) | 不支持 |
| 多标签页 | 原生支持 | 不支持 |
| iframe | 完整支持 | 有限支持 |
| 文件下载/上传 | 完整支持 | 支持(需配置) |
| 网络拦截 | page.route() | cy.intercept() |
| 并行执行 | 内置免费支持 | 需 Cypress Cloud(付费) |
| 截图 / 视频 | 内置 | 内置 |
| 调试工具 | Trace Viewer、Inspector、Codegen | 时间旅行 UI(非常直观) |
| API 测试 | request context | cy.request() |
| 组件测试 | 实验性支持 | 支持(Component Testing) |
| 移动设备模拟 | 设备参数 + 触摸事件 | 仅视口大小 |
| 自动等待 | 内置智能等待 | 内置自动重试 |
| CI 运行速度 | 更快 | 较慢 |
Playwright 代码示例:
import { test, expect } from '@playwright/test';
test.describe('用户注册流程', () => {
test('应该成功注册新用户', async ({ page }) => {
// 拦截注册 API
await page.route('**/api/auth/register', (route) => {
route.fulfill({
status: 201,
contentType: 'application/json',
body: JSON.stringify({ id: '1', name: '新用户' }),
});
});
await page.goto('/register');
// Playwright 使用 Locator API,自动等待元素可见
await page.getByLabel('用户名').fill('newuser');
await page.getByLabel('邮箱').fill('new@example.com');
await page.getByLabel('密码').fill('Secure@123');
await page.getByLabel('确认密码').fill('Secure@123');
await page.getByRole('button', { name: '注册' }).click();
// 验证跳转到登录页
await expect(page).toHaveURL('/login');
await expect(page.getByText('注册成功')).toBeVisible();
});
// Playwright 原生支持多标签页
test('应该在新标签页打开服务条款', async ({ page, context }) => {
await page.goto('/register');
const [newPage] = await Promise.all([
context.waitForEvent('page'),
page.getByText('服务条款').click(),
]);
await expect(newPage).toHaveURL('/terms');
await newPage.close();
});
});
Cypress 代码示例:
describe('用户注册流程', () => {
it('应该成功注册新用户', () => {
cy.intercept('POST', '/api/auth/register', {
statusCode: 201,
body: { id: '1', name: '新用户' },
}).as('register');
cy.visit('/register');
// Cypress 使用链式命令,自动重试直到元素可用
cy.get('[data-cy="username"]').type('newuser');
cy.get('[data-cy="email"]').type('new@example.com');
cy.get('[data-cy="password"]').type('Secure@123');
cy.get('[data-cy="confirm-password"]').type('Secure@123');
cy.get('[data-cy="register-btn"]').click();
cy.wait('@register');
// 验证结果
cy.url().should('include', '/login');
cy.contains('注册成功').should('be.visible');
});
});
选型建议:
| 选择 Playwright 的场景 | 选择 Cypress 的场景 |
|---|---|
| 需要测试 Safari / WebKit | 团队前端经验较少,需要直观调试 |
| 需要多标签页、iframe 场景 | 已有 Cypress 测试且运行良好 |
| 需要免费的并行执行 | 需要组件测试能力 |
| CI 性能是首要考虑因素 | 喜欢时间旅行 UI 的调试体验 |
| 需要多语言支持(Python、Java) | 团队只使用 JavaScript/TypeScript |
| 项目从零开始搭建 | 项目对 Cypress 生态有依赖 |
2024 年以后的新项目,Playwright 是更推荐的选择,原因:
- 跨浏览器支持更完整(特别是 Safari)
- 并行执行免费,CI 更快
- 原生 async/await,代码更符合现代 TypeScript 习惯
- Microsoft 持续投入,社区增长迅速
但 Cypress 的时间旅行调试 UI 至今仍是独一无二的优势,如果团队 E2E 经验较少,Cypress 的交互式调试器能大幅降低上手门槛。