Skip to content

第 19 章 · 面试题集(测试)

Q1:什么是测试金字塔?为什么要这样分配?

考察点:测试策略

标准答案

测试金字塔(Testing Pyramid)由 Mike Cohn 提出,描述测试在不同层级的合理分布:

        △  E2E (10%)         慢、贵、最接近真实
       △△△  集成 (20%)        多模块协作
    △△△△△△  单元 (70%)       快、便宜、最多

为什么这样分配

  1. 底层快:单元测试毫秒级运行,能秒反馈
  2. 底层定位准:单元失败一眼看出是哪个函数错
  3. 顶层稳定性差:E2E 依赖整个应用栈,网络抖动、动画时序都可能失败
  4. 顶层贵: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 有什么区别?

考察点:现代测试工具

标准答案

维度JestVitest
出品方MetaVite 团队(Vue 社区)
速度极快(基于 esbuild + Vite)
ESM 支持实验性、需配置原生
TS 支持需 ts-jest / babel-jest内置
APIdescribe/it/expect几乎一致
配置单独 jest.config与 vite.config 共用
浏览器模式仅 jsdom/node支持真实浏览器(experimental)
出现年份20162021

核心差异: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 推荐的查询优先级(语义化优先):

  1. getByRole(最优,符合可访问性)
  2. getByLabelText(表单元素)
  3. getByPlaceholderText
  4. getByText
  5. getByDisplayValue
  6. getByAltText / getByTitle
  7. getByTestId(兜底)

加分回答

  • 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 工具选型

标准答案

维度PlaywrightCypress
出品方微软Cypress.io
浏览器Chromium / Firefox / WebKitChromium 系(Edge/Chrome)+ 实验 Firefox
多 tab / 多域名✅ 原生❌ 受限(同源策略)
并行执行✅ 自带需付费云服务
速度极快(无 iframe 限制)较快
调试体验Trace Viewer(强大)时间旅行 + DOM 快照(直观)
文档/社区后起之秀,增长极快老牌、文档完善
API 风格async/await链式 promise
录制脚本playwright codegenCypress 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%+
  • 配置/类型文件 不计

高覆盖率 ≠ 高质量,原因:

  1. 覆盖率只测"代码被执行",不验证"行为正确"——可以没断言或断言无意义
  2. 追求 100% 会写很多低价值测试(getter/setter、构造函数)
  3. 逻辑复杂处覆盖率高也可能漏 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/react v13+
  • act 用于包裹会触发 state 更新的代码
  • 异步 hook 用 waitFor 等待断言通过
  • hook 内部用 React Context 时,renderHook 支持 wrapper 参数注入 Provider
  • 测试 useEffect 的清理函数:unmount() 后断言副作用被清理

追问

  • act 是干什么的,不写会怎样?
  • 怎么测试一个依赖 Redux 的 hook?