跳到主要内容

前端测试策略

问题​

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

面试速答版

前端测试策略有哪些? 经典「测试金字塔」分三层,比例大致 70/20/10:

  • 底层单元测试(70%):测函数/工具/Hooks/Store,毫秒级,数量最多。
  • 中层集成测试(20%):测组件交互和模块协作,用 Testing Library + MSW Mock 接口。
  • 顶层 E2E 测试(10%):模拟真实用户走完整流程,用 Playwright/Cypress,慢但覆盖广。
  • 配套手段:Mock、覆盖率(Istanbul)、快照测试、视觉回归(Chromatic)。
  • 反模式:倒金字塔(E2E 太多)会让 CI 慢、维护成本爆炸。

如何选择合适的测试框架? 按场景对号入座:

  • 单元/集成测试:Vite 项目首选 Vitest(开箱即用、和 Vite 共用配置、ESM 友好),其他用 Jest(生态成熟)。
  • 组件测试:React Testing Library / @testing-library/vue,遵循「像用户一样测」原则,优先用 getByRole 等可访问性查询。
  • E2E 测试:Playwright(多浏览器、并发好、API 现代)或 Cypress(开发者体验好、调试方便)。
  • 接口 Mock:MSW 用 Service Worker 拦截,开发和测试通用。

单元、集成、E2E 各有什么特点? 三个维度对比:

  • 单元测试:隔离测最小单元,无 DOM/网络依赖,毫秒级,定位 bug 最精准。
  • 集成测试:测多个模块协作(组件 + Store + 接口),秒级,覆盖真实使用路径。
  • E2E 测试:跑真实浏览器走完整流程(登录→下单→支付),十秒到分钟级,最贴近用户但最脆弱。
  • 取舍原则:能用单测覆盖的逻辑别上集成,能用集成覆盖的别上 E2E,E2E 只测核心黄金路径。

答案​

前端测试是保障代码质量、防止回归缺陷的核心手段。一个成熟的前端项目通常会采用测试金字塔模型,从底层的单元测试到顶层的 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​

Jest 和 Vitest 是当前前端最主流的两个测试框架。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(备选)getByTestId按 data-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 在网络层拦截请求,无论你使用 fetch、axios 还是其他 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() 暴露内部
可访问性驱动优先使用 getByRole、getByLabelTextEnzyme 通常用 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.geolocation、IntersectionObserver
时间相关定时器、日期会导致测试不稳定setTimeout、Date.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)​

答案:

Playwright 和 Cypress 是当前最主流的两个 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 的交互式调试器能大幅降低上手门槛。


相关链接​