跳到主要内容

前端测试策略

问题

前端测试策略有哪些?如何选择合适的测试框架?单元测试、集成测试和端到端测试各有什么特点?

答案

前端测试是保障代码质量、防止回归缺陷的核心手段。一个成熟的前端项目通常会采用测试金字塔模型,从底层的单元测试到顶层的 E2E 测试分层覆盖,配合 Mock 技术、覆盖率工具和快照测试等手段,构建完整的质量保障体系。


测试金字塔

测试金字塔是 Mike Cohn 提出的经典模型,它指导我们如何分配不同层级测试的比例。越底层的测试数量越多、速度越快、成本越低;越顶层的测试数量越少、速度越慢、但覆盖面越广。

三层测试详解

层级测试对象典型工具运行速度维护成本覆盖比例建议
单元测试函数、工具方法、Hooks、StoreJest、Vitest毫秒级70%
集成测试组件交互、API 调用、页面模块Testing Library、MSW秒级20%
E2E 测试完整用户流程、跨页面场景Cypress、Playwright十秒级10%
面试要点

面试时提到测试金字塔,关键要说明底层测试多、顶层测试少的原则,以及每一层的职责边界。过多的 E2E 测试会导致 CI 缓慢和维护噩梦,过少的单元测试会导致代码逻辑缺乏保障。

单元测试示例

单元测试专注于最小可测试单元(函数、类、模块),不依赖外部服务或 DOM:

utils/format.ts
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) + '...';
}
utils/__tests__/format.test.ts
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 调用:

components/__tests__/LoginForm.test.tsx
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 测试模拟真实用户从打开浏览器到完成操作的完整流程:

e2e/checkout.spec.ts
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

JestVitest 是当前前端最主流的两个测试框架。Jest 由 Facebook 维护,生态成熟;Vitest 由 Vite 团队开发,原生支持 ESM 和 TypeScript,速度更快。

核心对比

特性JestVitest
运行环境Node.js(自定义转换)基于 Vite(原生 ESM)
TypeScript需要 ts-jest@swc/jest原生支持(通过 Vite)
ESM 支持实验性,配置复杂原生支持
配置复杂度较高(babel/swc 转换)低(复用 vite.config)
运行速度中等快(HMR 级别的热重载)
生态成熟度非常成熟、社区庞大快速增长、兼容 Jest API
Watch 模式基于文件变更基于 Vite HMR,更快
快照测试支持支持
覆盖率istanbul / c8c8 / istanbul
并行执行Worker 级别线程级别(更轻量)
浏览器模式支持(实验性)
API 兼容性兼容 Jest 大部分 API

Jest 配置示例

jest.config.ts
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 配置示例

vitest.config.ts
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,而非内部实现细节。

components/Counter.tsx
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>
);
}
components/__tests__/Counter.test.tsx
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 角色查询,最贴近用户体验
2getByLabelText按 label 文本查询,适合表单元素
3getByPlaceholderText按 placeholder 查询
4getByText按可见文本查询
5getByDisplayValue按表单当前值查询
6(备选)getByTestIddata-testid 查询,兜底方案
注意

避免使用 container.querySelector 等直接操作 DOM 的方式,这违背了 Testing Library 的设计哲学。测试应关注用户行为而非实现细节,这样在重构时测试不会轻易失败。

Vue Test Utils

Vue Test Utils 是 Vue 官方提供的组件测试工具库,配合 @vue/test-utils 使用:

components/__tests__/TodoList.test.ts
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 LibraryVue Test Utils
查询理念基于用户视角(角色、文本)基于组件结构(find/findAll)
事件模拟userEvent(推荐)/ fireEventtrigger / setValue
异步等待waitFor / findBy*nextTick / flushPromises
Props 传递render(<Comp prop={val} />)mount(Comp, { props })
Emit 测试通过回调函数 mockwrapper.emitted()
浅渲染不推荐shallowMount

E2E 测试:Cypress vs Playwright

E2E(端到端)测试模拟真实用户在浏览器中的完整操作流程,验证整个应用从前端到后端是否正常工作。

核心对比

特性CypressPlaywright
开发者Cypress.ioMicrosoft
浏览器支持Chrome、Firefox、Edge、ElectronChrome、Firefox、Safari、Edge
并行执行付费功能(Cypress Cloud)内置免费支持
多标签页不支持支持
iframe 支持有限完整支持
网络拦截cy.interceptpage.route
调试体验时间旅行 UI(优秀)Trace Viewer / Inspector
编程语言JavaScript/TypeScriptJS/TS/Python/Java/C#
自动等待内置内置
API 测试cy.requestrequest context
移动端模拟视口模拟设备模拟 + 触摸事件
CI 集成Docker 镜像Docker 镜像
运行速度较慢更快(无头模式优化)
学习曲线低(交互式 UI)中等

Cypress 示例

cypress/e2e/login.cy.ts
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 示例

e2e/login.spec.ts
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 在网络层拦截请求,无论你使用 fetchaxios 还是其他 HTTP 客户端,都能统一 mock。它既可以用在测试环境,也可以用在开发环境。

mocks/handlers.ts
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',
});
}),
];
mocks/setup.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

// 创建测试服务器
export const server = setupServer(...handlers);
setupTests.ts
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:

services/__tests__/userService.test.ts
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)用于模拟整个模块,适合隔离被测模块的外部依赖:

utils/__tests__/analytics.test.ts
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 技巧

mock-examples.test.ts
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 vs Stub vs Spy 区别
  • Mock:完全替换原始实现,可以验证调用情况(vi.fn()
  • Stub:替换原始实现,返回预设数据,不关心调用情况(vi.fn().mockReturnValue()
  • Spy:保留原始实现,但可以监控调用情况(vi.spyOn()

测试覆盖率

测试覆盖率衡量代码被测试执行的比例,是评估测试充分性的重要指标。

覆盖率类型

类型英文说明
语句覆盖率Statement每条语句是否被执行
分支覆盖率Branch每个 if/else 分支是否被覆盖
函数覆盖率Function每个函数是否被调用
行覆盖率Line每一行是否被执行

Istanbul vs c8

特性Istanbulc8
实现方式代码插桩(instrumentation)V8 内置覆盖率
性能较慢(需要转换代码)更快(利用 V8 原生能力)
准确性高(V8 级别的精确度)
配置复杂度需要 babel 插件零配置
支持 ESM需要额外配置原生支持

Vitest 覆盖率配置

vitest.config.ts
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 类:

data-structures/__tests__/stack.test.ts
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');
});
});
data-structures/stack.ts
// 第二轮 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 格式编写测试,使非技术人员也能理解:

features/__tests__/shopping-cart.test.ts
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 对比

维度TDDBDD
关注点代码实现正确性业务行为正确性
测试语言技术术语自然语言(Given/When/Then)
驱动方式由测试驱动代码实现由行为规格驱动开发
适用人群开发者开发者 + 产品 + 测试
粒度细粒度(单元级别)粗粒度(功能级别)
典型工具Jest、VitestCucumber、Jest + describe
开发流程Red -> Green -> Refactor规格 -> 实现 -> 验证

快照测试

快照测试(Snapshot Testing)将组件的渲染输出序列化为字符串并保存,后续测试运行时与保存的快照进行对比,检测意外的 UI 变化。

基本用法

components/__tests__/Button.test.tsx
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();
});
});

生成的快照文件(自动创建):

__snapshots__/Button.test.tsx.snap
exports[`Button Snapshot > should match snapshot for primary button 1`] = `
<button
class="btn btn-primary btn-large"
>
提交
</button>
`;

内联快照

内联快照将快照内容直接写在测试文件中,适合输出较短的场景:

utils/__tests__/format.test.ts
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日"`
);
});
});

快照测试最佳实践

要点
  1. 快照应该小:避免对大型组件做快照,快照过大难以 review
  2. 有意义的快照:确保快照包含有意义的输出,而非随机 ID 或时间戳
  3. 及时更新:快照失败时仔细检查变更是否符合预期,而非无脑 --update
  4. 搭配其他测试:快照测试不能替代行为测试,它只检测输出变化,不验证正确性
注意

快照测试的常见问题:

  • 快照过大导致 Code Review 时被忽略
  • 动态内容(时间戳、随机 ID)导致频繁失败
  • 团队成员无脑执行 vitest -u 更新快照,失去了检测意义

建议对动态内容使用 expect.any() 或在序列化器中过滤。


常见面试问题

Q1: 前端项目应该如何规划测试策略?不同类型的测试各负责什么?

答案

前端测试策略应遵循测试金字塔模型,按照从底层到顶层的顺序规划:

1. 单元测试(约 70%): 测试最小代码单元 -- 工具函数、自定义 Hooks、Store 逻辑、纯组件。单元测试运行速度最快(毫秒级),应该覆盖项目中所有核心逻辑。

hooks/__tests__/useDebounce.test.ts
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 的核心区别在于底层架构和生态定位

对比维度JestVitest
模块转换通过 Babel/SWC 将 ESM 转为 CJS基于 Vite,原生支持 ESM
TypeScript需要 ts-jest@swc/jest原生支持,零配置
配置独立配置文件,需配置转换器和路径映射复用 vite.config,配置统一
热重载文件变更触发相关测试基于 Vite HMR,速度更快
并发Worker 进程级隔离线程级隔离,更轻量

Vitest 越来越流行的原因:

vitest.config.ts
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模块层替换导入。两者的根本区别如下:

对比维度MSWvi.mock / jest.mock
拦截层级网络层(Service Worker)模块导入层
HTTP 客户端无关(fetch/axios/XHR 通用)需要 mock 具体的客户端模块
请求验证可以验证请求体、请求头等只能验证模块函数的调用参数
复用性测试 + 开发环境通用仅测试环境
真实度更接近真实网络请求完全跳过网络层

MSW 的核心优势在于与 HTTP 客户端解耦

services/__tests__/api.test.ts
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 模块,耦合度更高:

对比:vi.mock 方式
// 如果业务代码从 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() 暴露内部
可访问性驱动优先使用 getByRolegetByLabelTextEnzyme 通常用 CSS 选择器或组件名
真实 DOM渲染到真实 DOM(jsdom),更接近浏览器Enzyme 的 shallow 不渲染子组件

查询方法优先级(面试必考)

testing/query-priority.ts
// 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');

完整的组件测试示例

components/__tests__/SearchForm.test.tsx
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

hooks/__tests__/useLocalStorage.test.ts
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 组件"时,核心回答三点:

  1. 使用 React Testing Library,按用户行为测试,不测实现细节
  2. 查询优先级:getByRole > getByLabelText > getByText > getByTestId
  3. 使用 userEvent(而非 fireEvent)模拟用户交互,因为它更接近真实浏览器行为

Q5: Mock 的最佳实践(什么该 Mock,什么不该 Mock)

答案

Mock 的核心原则是:只 Mock 你无法控制的东西。过度 Mock 会让测试变成"测试 Mock 本身",失去对真实行为的验证。

应该 Mock 的

类型原因示例
外部 API 请求网络不可靠、速度慢、不想依赖外部服务fetch('/api/users')
浏览器原生 API测试环境(jsdom)不完全支持navigator.geolocationIntersectionObserver
时间相关定时器、日期会导致测试不稳定setTimeoutDate.now()
随机数每次结果不同导致断言不稳定Math.random()
第三方服务 SDK不想在测试中真正调用付费服务Stripe、Firebase、Sentry
复杂的计算密集模块测试目标不在于该模块图片处理、加密算法

不该 Mock 的

类型原因正确做法
被测模块自身的方法Mock 了就不是在测真实代码直接调用真实实现
纯函数 / 工具方法执行快且确定,无需 Mock直接引入并使用
简单的数据转换过度 Mock 让测试失去意义使用真实数据
React 组件的子组件RTL 理念不鼓励浅渲染完整渲染并测试交互
状态管理逻辑应该集成测试 Store 和组件用真实 Store 配合组件测试

反模式示例

testing/mock-antipatterns.test.ts
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);
});
testing/mock-best-practices.test.ts
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 层级金字塔

过度 Mock 的危害
  • 测试通过但生产环境出 bug(因为测试没有覆盖真实逻辑)
  • 重构代码时大量测试需要同步修改(Mock 耦合了实现细节)
  • 给人虚假的安全感(覆盖率很高但实际保障很低)

经验法则:如果一个测试文件中 Mock 代码比断言代码还多,说明 Mock 过度了。

Q6: E2E 测试框架选型(Playwright vs Cypress)

答案

PlaywrightCypress 是当前最主流的两个 E2E 测试框架。它们的设计理念和架构有本质区别,适用于不同的场景。

架构差异

维度PlaywrightCypress
运行架构通过 CDP/WebSocket 远程控制浏览器在浏览器内部运行测试代码
进程模型测试进程 ≠ 浏览器进程测试代码与应用同进程
异步模型原生 async/await自定义命令链(类似 Promise chain)
浏览器引擎Chromium、Firefox、WebKitChromium、Firefox、Electron

核心功能对比

特性PlaywrightCypress
Safari 支持支持(WebKit 引擎)不支持
多标签页原生支持不支持
iframe完整支持有限支持
文件下载/上传完整支持支持(需配置)
网络拦截page.route()cy.intercept()
并行执行内置免费支持需 Cypress Cloud(付费)
截图 / 视频内置内置
调试工具Trace Viewer、Inspector、Codegen时间旅行 UI(非常直观)
API 测试request contextcy.request()
组件测试实验性支持支持(Component Testing)
移动设备模拟设备参数 + 触摸事件仅视口大小
自动等待内置智能等待内置自动重试
CI 运行速度更快较慢

Playwright 代码示例

e2e/playwright/user-flow.spec.ts
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 代码示例

e2e/cypress/user-flow.cy.ts
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 是更推荐的选择,原因:

  1. 跨浏览器支持更完整(特别是 Safari)
  2. 并行执行免费,CI 更快
  3. 原生 async/await,代码更符合现代 TypeScript 习惯
  4. Microsoft 持续投入,社区增长迅速

但 Cypress 的时间旅行调试 UI 至今仍是独一无二的优势,如果团队 E2E 经验较少,Cypress 的交互式调试器能大幅降低上手门槛。


相关链接