主题
第 19 章 · 面试题集(测试)
Q1:什么是测试金字塔?为什么要这样分配?
考察点:测试策略
标准答案:
测试金字塔(Testing Pyramid)由 Mike Cohn 提出,描述测试在不同层级的合理分布:
△ E2E (10%) 慢、贵、最接近真实
△△△ 集成 (20%) 多模块协作
△△△△△△ 单元 (70%) 快、便宜、最多为什么这样分配:
- 底层快:单元测试毫秒级运行,能秒反馈
- 底层定位准:单元失败一眼看出是哪个函数错
- 顶层稳定性差:E2E 依赖整个应用栈,网络抖动、动画时序都可能失败
- 顶层贵:E2E 几秒到几十秒一个 case,多了 CI 时间不可接受
反模式:"冰淇淋甜筒"——E2E 占 70%,单元占 10%——慢、脆、定位难。
通俗理解:
像餐厅出品质检——每个食材新鲜度(单元)由切配工自检(每天几百次),上桌前主厨试味(集成),客人评价(E2E)只有少数被关注。
代码示例:
单元: 测试 add(1,2) === 3
集成: 测试 LoginForm 提交后调用了 login API 并跳转
E2E: 测试用户从首页登录、看到个人中心、退出登录的完整流程加分回答:
- Kent C. Dodds 提出更现代的"Testing Trophy"模型,重视集成测试
- 现代前端工具(RTL)让"集成测试也很快",比例可以提高到 30~40%
- 单元测试不应该测无意义的 getter/setter
- 关键支付链路 E2E 必须有,是上线前的最后防线
追问:
- 你写测试的比例是多少?
- 一个组件的测试该写多少 case 合适?
2:Vitest 和 Jest 有什么区别?
考察点:现代测试工具
标准答案:
| 维度 | Jest | Vitest |
|---|---|---|
| 出品方 | Meta | Vite 团队(Vue 社区) |
| 速度 | 慢 | 极快(基于 esbuild + Vite) |
| ESM 支持 | 实验性、需配置 | 原生 |
| TS 支持 | 需 ts-jest / babel-jest | 内置 |
| API | describe/it/expect | 几乎一致 |
| 配置 | 单独 jest.config | 与 vite.config 共用 |
| 浏览器模式 | 仅 jsdom/node | 支持真实浏览器(experimental) |
| 出现年份 | 2016 | 2021 |
核心差异:Vitest 利用 Vite 的转换管线,测试代码经过的转换和生产代码完全一致——避免了 Jest 中"测试时能跑、构建后报错"的尴尬。
通俗理解:
Jest 像独立翻译公司(自己一套流程),Vitest 像直接接到 Vite 流水线上的质检站(共用一套工具、零适配)。
代码示例:
ts
// 同一份测试代码在 Jest 和 Vitest 都能跑
import { describe, it, expect } from 'vitest'; // 仅这一行不一样
// import { describe, it, expect } from '@jest/globals';
describe('add', () => {
it('1 + 2 = 3', () => {
expect(1 + 2).toBe(3);
});
});ts
// vitest.config.ts —— 与 vite 共用配置
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'jsdom',
globals: true, // 不用 import describe/it/expect
setupFiles: ['./tests/setup.ts'],
},
});加分回答:
- Vitest 用 happy-dom 替代 jsdom 比 jsdom 快 2~3 倍
- Vitest 支持 in-source testing:测试写在源文件里(适合工具库)
- Vitest UI(
--ui)可视化运行测试,体验比 Jest 优秀 - Vitest 监听模式下只跑相关测试(基于依赖图分析)
- 大型 Jest 项目迁移成本低(API 几乎一致)
追问:
- Vitest 的 in-source testing 适合什么场景?
- 怎么从 Jest 迁移到 Vitest?
3:什么是 Mock?什么时候应该 Mock?
考察点:测试隔离
标准答案:
Mock = 在测试中用"假实现"替换真实依赖,目的是隔离被测代码。
该 Mock 的场景:
- 外部 API(网络请求、数据库)
- 时间相关(Date、setTimeout)
- 文件系统、环境变量
- 第三方 SDK(支付、地图)
- 不稳定的依赖(随机数)
不该 Mock 的场景:
- 自己写的纯函数(直接调用就行)
- 简单的工具方法
- React 子组件(除非很重的子组件)
- 测试本身要验证的协作逻辑
Mock 的三种类型:
ts
// 1. Mock 函数
const fn = vi.fn(() => 'mocked');
// 2. Mock 模块
vi.mock('./api', () => ({
fetchUser: vi.fn().mockResolvedValue({ id: 1 }),
}));
// 3. Mock 时间
vi.useFakeTimers();
vi.setSystemTime(new Date('2025-01-01'));通俗理解:
测试一道菜的味道,但不可能每次都用真的金枪鱼(贵、慢、不稳定)。Mock 就是"演员替身"——用罐头金枪鱼替代,专注测试"调味方法"而不是"金枪鱼品质"。
代码示例:
ts
// 测试一个发送邮件的功能
vi.mock('./mailer', () => ({
sendMail: vi.fn().mockResolvedValue({ success: true }),
}));
import { sendOrderConfirmation } from './order';
import { sendMail } from './mailer';
it('下单成功后发送邮件', async () => {
await sendOrderConfirmation({ orderId: 'A001', email: 'a@b.com' });
expect(sendMail).toHaveBeenCalledWith({
to: 'a@b.com',
subject: expect.stringContaining('订单 A001'),
});
});加分回答:
- 过度 Mock 会让测试失去意义("测试的全是 mock")
- 接近用户角度的"集成测试"应该少用 mock
- MSW(Mock Service Worker)拦截真实网络请求,比 mock 模块更接近真实
- Spy = 监视真实实现的调用情况(不替换);Mock = 替换实现
- Stub = 提供预设返回值;Fake = 简化的真实实现
追问:
- vi.spyOn 和 vi.fn 有什么区别?
- 怎么 mock 一个 ESM 模块的 default export?
Q4:什么是快照测试(Snapshot)?什么时候用?
考察点:测试技巧与权衡
标准答案:
快照测试:第一次运行时把输出存档(.snap 文件),后续运行自动对比是否一致,不一致即失败。
适用场景:
- 组件渲染输出(HTML 结构)
- 配置对象、序列化结果
- 大型不变的 JSON 响应
不适用:
- 频繁变化的 UI(每次都要 update,失去检测价值)
- 业务关键路径的核心断言(应该用显式 expect)
通俗理解:
第一次做菜拍照存档,下次自动跟存档对比——颜色、摆盘、份量一变就报警。但如果菜本身经常调整菜谱,照片每次都更新,"对比"就没意义了。
代码示例:
ts
import { render } from '@testing-library/react';
it('Button 渲染快照', () => {
const { container } = render(<Button>提交</Button>);
expect(container).toMatchSnapshot();
});
// 第一次:__snapshots__/Button.test.tsx.snap
// exports[`Button 渲染快照 1`] = `
// <div><button>提交</button></div>
// `;
// 故意更新:
// vitest --update加分回答:
- Inline Snapshot:把 snap 写在测试文件里(
expect(x).toMatchInlineSnapshot()),review 时更直观 - 大快照难 review,团队往往"--update 一把梭",失去价值
- 配合 jest-image-snapshot 可做视觉回归测试
- 序列化前可自定义 serializer 移除噪声(如 className hash)
- Storybook + Chromatic 是另一种视觉快照方案
追问:
- snap 失败时你怎么决定 update 还是修代码?
- 怎么避免 snap 文件越积越多?
Q5:React Testing Library 推崇的"测试用户行为而非实现"是什么意思?
考察点:测试哲学
标准答案:
核心原则:测试应该验证用户能感知的行为,而非组件内部的实现细节。
反例(测试实现):
tsx
const wrapper = shallow(<Counter />);
expect(wrapper.state('count')).toBe(0);
wrapper.instance().increment();
expect(wrapper.state('count')).toBe(1);问题:把 state 改名为 useReducer,测试就崩了——但用户行为完全没变。
正例(测试行为):
tsx
render(<Counter />);
expect(screen.getByText('计数:0')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: '+1' }));
expect(screen.getByText('计数:1')).toBeInTheDocument();无论内部用 useState / useReducer / Redux,只要用户看到的结果一致就通过。
通俗理解:
测试一道菜,不应该问厨师"你用了什么品牌的盐"(实现细节),而应该问客人"咸淡合适吗"(用户感知)。
代码示例:
tsx
// ❌ 测试实现
it('调用了 setUser', () => {
const setUser = jest.fn();
render(<UserForm setUser={setUser} />);
fireEvent.change(input, { target: { value: 'Alice' }});
expect(setUser).toHaveBeenCalled();
});
// ✅ 测试行为
it('输入用户名后显示欢迎语', async () => {
const user = userEvent.setup();
render(<UserForm />);
await user.type(screen.getByLabelText('用户名'), 'Alice');
expect(screen.getByText('你好,Alice')).toBeInTheDocument();
});RTL 推荐的查询优先级(语义化优先):
getByRole(最优,符合可访问性)getByLabelText(表单元素)getByPlaceholderTextgetByTextgetByDisplayValuegetByAltText/getByTitlegetByTestId(兜底)
加分回答:
- RTL 之父 Kent C. Dodds 的名言:"The more your tests resemble the way your software is used, the more confidence they can give you."
- 优先按 ARIA role 查询天然鼓励写可访问的代码
- testing-library 跨框架(react/vue/angular/svelte)API 几乎一致
- 配合 jest-axe 可做无障碍性自动检查
追问:
- 怎么测试一个完全无 UI 的自定义 hook?
- userEvent 比 fireEvent 强在哪?
Q6:Playwright 和 Cypress 有什么区别?
考察点:E2E 工具选型
标准答案:
| 维度 | Playwright | Cypress |
|---|---|---|
| 出品方 | 微软 | Cypress.io |
| 浏览器 | Chromium / Firefox / WebKit | Chromium 系(Edge/Chrome)+ 实验 Firefox |
| 多 tab / 多域名 | ✅ 原生 | ❌ 受限(同源策略) |
| 并行执行 | ✅ 自带 | 需付费云服务 |
| 速度 | 极快(无 iframe 限制) | 较快 |
| 调试体验 | Trace Viewer(强大) | 时间旅行 + DOM 快照(直观) |
| 文档/社区 | 后起之秀,增长极快 | 老牌、文档完善 |
| API 风格 | async/await | 链式 promise |
| 录制脚本 | playwright codegen | Cypress Studio |
选型建议(2026):
- 大多数新项目首选 Playwright(多浏览器 + 并行 + 免费)
- 团队已有 Cypress 经验且无多浏览器需求 → 继续用
- 需测多 tab、跨域、文件下载、移动端 emulation → Playwright
通俗理解:
Cypress 像"高级模拟驾驶舱"(直观、好用,但只能开自家品牌的车);Playwright 像"全能驾驶教练"(什么车都能开、还能开两辆)。
代码示例:
ts
// Playwright
test('登录', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('用户名').fill('alice');
await page.getByLabel('密码').fill('123');
await page.getByRole('button', { name: '登录' }).click();
await expect(page).toHaveURL(/dashboard/);
});
// Cypress
it('登录', () => {
cy.visit('/login');
cy.get('[name=username]').type('alice');
cy.get('[name=password]').type('123');
cy.contains('登录').click();
cy.url().should('include', 'dashboard');
});加分回答:
- Playwright 的 trace viewer 能精确还原每一步的 DOM/网络/截图
- Playwright 的 component testing 模式可替代部分 RTL 测试(真实浏览器渲染)
- Cypress 的
cy.intercept和 Playwright 的page.route都能拦截 API - 微软在 VSCode 提供 Playwright 官方扩展,运行/调试一键搞定
- E2E 测试不要测细节,专注关键用户旅程
追问:
- 怎么处理 E2E 中的登录态共享?
- 怎么避免 E2E 测试的 flaky(偶发失败)?
Q7:覆盖率多少合适?高覆盖率就等于高质量吗?
考察点:测试度量与权衡
标准答案:
实用目标:
- 整体 80%(行 + 分支)
- 核心业务模块 90%+
- 工具函数库 95%+
- UI 组件 70%+
- 配置/类型文件 不计
高覆盖率 ≠ 高质量,原因:
- 覆盖率只测"代码被执行",不验证"行为正确"——可以没断言或断言无意义
- 追求 100% 会写很多低价值测试(getter/setter、构造函数)
- 逻辑复杂处覆盖率高也可能漏 case(边界条件没测)
有意义的测试 > 高覆盖率。
通俗理解:
工人完成 100% 工作清单 ≠ 工作质量 100% ——可能每件事都做了但都做得草率。覆盖率像"工作日志的完成度",质量要靠"具体做得怎么样"判断。
代码示例:
ts
// ❌ 覆盖率 100% 但毫无价值
it('调用 add', () => {
add(1, 2); // 跑了,覆盖率 ✓
// 没有断言!返回 999 也通过
});
// ✅ 覆盖率不变,价值大
it.each([
[1, 2, 3],
[-1, 1, 0],
[Number.MAX_SAFE_INTEGER, 1, Number.MAX_SAFE_INTEGER + 1],
])('add(%i, %i) = %i', (a, b, expected) => {
expect(add(a, b)).toBe(expected);
});ts
// vitest.config.ts —— 设置覆盖率阈值
test: {
coverage: {
provider: 'v8',
thresholds: {
lines: 80,
branches: 70,
functions: 80,
},
exclude: ['**/*.test.ts', '**/types.ts', '**/*.d.ts'],
},
}加分回答:
- 四大指标:Statements / Branches / Functions / Lines
- Branches 比 Lines 更难达标,更能反映质量
- Mutation Testing(变异测试,如 Stryker)能更准确衡量"测试是否真的能发现 bug"
- CI 中可设置"覆盖率不能下降"作为合并条件
- 覆盖率报告(HTML)能看到"红色未覆盖代码",引导补测试
追问:
- 什么是 Mutation Testing?
- 覆盖率从 80% 升到 95% 边际成本为什么高?
Q8:如何测试一个自定义 React Hook?
考察点:现代 React 测试
标准答案:
自定义 hook 没有 UI,无法直接 render。两种做法:
A. 用 renderHook(推荐)
tsx
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';
it('useCounter 增减', () => {
const { result } = renderHook(() => useCounter(0));
expect(result.current.count).toBe(0);
act(() => result.current.increment());
expect(result.current.count).toBe(1);
act(() => result.current.decrement());
expect(result.current.count).toBe(0);
});B. 包成测试组件(适合复杂场景)
tsx
function TestComponent() {
const { count, increment } = useCounter(0);
return (
<div>
<span>{count}</span>
<button onClick={increment}>+1</button>
</div>
);
}
it('UI 行为', async () => {
const user = userEvent.setup();
render(<TestComponent />);
await user.click(screen.getByText('+1'));
expect(screen.getByText('1')).toBeInTheDocument();
});通俗理解:
hook 像"机器零件",不能直接展示。要么用 jig(夹具,renderHook)单独测它,要么装到一台简化机器里(包装组件)联调测试。
代码示例:
tsx
// 测试异步 hook
it('useFetch 加载数据', async () => {
const { result } = renderHook(() => useFetch('/api/user'));
expect(result.current.loading).toBe(true);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toEqual({ name: 'Alice' });
});加分回答:
- 老版
@testing-library/react-hooks已被合并到@testing-library/reactv13+ act用于包裹会触发 state 更新的代码- 异步 hook 用
waitFor等待断言通过 - hook 内部用 React Context 时,
renderHook支持wrapper参数注入 Provider - 测试 useEffect 的清理函数:
unmount()后断言副作用被清理
追问:
- act 是干什么的,不写会怎样?
- 怎么测试一个依赖 Redux 的 hook?