Skip to main content
单元测试说到底是让自己能安全改代码的保险,覆盖率不是目标。

Q1: 单元测试的历史?为什么要有它?

1

1950 年代: 人工验证阶段

早期软件开发中,程序员通过打印输出或手动检查来验证代码正确性。Dijkstra 有句名言:“测试只能证明 bug 存在,不能证明 bug 不存在。“这个阶段的测试完全是手动的、不可重复的。
2

1990 年代: xUnit 框架兴起

Kent Beck 在 Smalltalk 上创建了 SUnit,随后演化为 JUnit(Java)、NUnit(.NET)等。xUnit 体系确立了”断言 + 测试套件 + 测试运行器”的标准范式,让自动化测试成为可能。Martin Fowler 等人将单元测试纳入敏捷开发的核心实践。[1]
3

2010 年代: 前端测试工具成熟

Mocha + Chai + Sinon 成为 Node.js 时代的主流组合。Jest 由 Facebook 在 2014 年开源,凭借”零配置 + 内置 Mock + 快照测试”迅速统治 React 生态。Kent C. Dodds 提出的 Testing Library 理念彻底改变了前端测试的思维方式。[2]
4

2020 年+ Vitest 与 AI 辅助测试

Vitest 基于 Vite 的按需转换能力,在大型项目里通常比 Jest 启动和执行得更快,但具体差距取决于项目结构。AI 代码补全工具开始能自动生成测试用例,但核心的测试设计思路仍然需要工程师自己把控。
单元测试解决的核心问题:
单元测试不是”写完代码后的额外工作”,而是”让你能安全修改代码的保险”。

🚫 没有单元测试

你写了一个价格计算函数,上线后发现”满减优惠”在边界条件下算错了。修复后又引入了新 bug。每次发版都像在拆炸弹——你不知道哪根线会触发爆炸。就像一辆没有刹车检测的车,你只能祈祷每次刹车都有效。

✅ 引入单元测试

价格计算函数有 15 个测试用例,覆盖了正常场景、边界值、异常输入。修改满减逻辑后跑一次测试,3 秒内知道有没有破坏已有功能。就像给车装了刹车自检系统,每次启动前自动检测刹车是否正常。
回答时先说”为什么”(手动回归的痛点),再说”怎么做”(自动化测试的收益)。想听的是你对测试本质的理解——它降低的是修改代码的风险,而不是追求 100% 覆盖率。

Q2: Jest/Vitest 核心原理(断言、Mock、快照测试)

测试框架的本质是:运行代码 → 捕获结果 → 与预期对比,核心逻辑只有 3 步:
断言是测试的”判断标准”,选错粒度会导致测试要么太脆弱要么太宽松:
  • toBe — 引用相等(Object.is),适合原始类型。expect(1 + 1).toBe(2)。
  • toEqual — 深度比较(递归遍历),适合对象/数组。expect({ a: 1 }).toEqual({ a: 1 })。
  • toMatchObject — 部分匹配,只检查你关心的字段。适合忽略时间戳等动态字段。
断言的本质是拿”实际结果”和”预期结果”做对比。就像装修验收:toBe 是精确到毫米的测量(墙角必须是 90 度),toEqual 是整体结构对比(房间布局要和图纸一致),toMatchObject 是关键点检查(只要水管没漏、电线没短路就行,墙面细微色差可以忽略)。

Mock 的实现原理

Mock 的核心是用可控的假行为替换不可控的真实行为。Jest 内部通过模块拦截实现自动 Mock:

快照测试的存储机制

快照测试将组件的渲染输出序列化为字符串,存储在 __snapshots__ 目录下的 .snap 文件中。首次运行生成基准快照,后续运行自动对比:

Q3: Testing Library 设计哲学(测试行为不测试实现)

很多人只会写 expect(component.state.count).toBe(1),但实际更在意的是:你是否理解”测试行为”和”测试实现”的本质区别? Kent C. Dodds 提出 Testing Library 的核心理念是:The more your tests resemble the way your software is used, the more confidence they can give you.(你的测试越像用户使用软件的方式,它给你的信心就越大。)[2]

查询优先级:Accessibility First

Testing Library 定义了一套查询优先级,核心思想是优先使用无障碍友好的查询方式:

为什么不要测试实现细节?

测试实现细节会导致两个致命问题:
  1. 重构即失败 — 内部实现变了(比如从 class 改为 hooks),即使功能完全一样,测试也会报错。
  2. 虚假的安全感 — 测试全部通过,但用户实际使用时发现功能坏了(因为你测的是内部状态,不是用户行为)。

Q4: Mock 策略(手动 Mock、自动 Mock、Mock 模块)

手动 Mock vs 自动 Mock 的区别: 手动 Mock 是你自己写一个假实现,精确控制每个细节。自动 Mock 是框架帮你生成一个空壳,所有方法返回 undefined。选错策略会导致测试要么太脆弱(耦合实现),要么太宽松(什么都测不出来)。 关键区别: ❌/✅ 代码对比(展示错误做法和正确做法):
Mock 的黄金法则是:只 Mock 你无法控制的东西(网络请求、数据库、时间、随机数)。内部的工具函数、业务逻辑、数据转换都应该走真实代码。这样测试既稳定(外部依赖可控)又有意义(内部逻辑被真实验证)。

Mock 第三方模块

Mock 定时器

Jest 与 Vitest 的 API 对应关系

两者的断言 API 基本一致,迁移时主要替换全局对象和模块 Mock: 在 Vitest 中,describe/it/expect/vi 默认需要从 vitest 显式导入;开启 test.globals 后才和 Jest 一样作为全局变量使用。 jest.mock 和 vi.mock 都有**提升(hoist)**行为:调用会被移到文件顶部、先于 import 执行。因此 Mock 工厂里不能直接引用外部变量——Jest 只允许引用以 mock 开头的变量,Vitest 则提供 vi.hoisted():

Q5: 测试覆盖率(语句覆盖、分支覆盖、路径覆盖)

覆盖率是衡量”测试有多全面”的量化指标,但高覆盖率不等于高质量测试。

三种覆盖率的差异

覆盖率的陷阱

覆盖率数字很容易被”刷高”,但这样的高覆盖率毫无意义:
覆盖率是”发现问题的工具”,不是”追求的目标”。80% 的有意义测试比 100% 的刷数测试有价值得多。

十、及格回答 vs 满分回答

问题:“你们项目中是怎么做单元测试的?覆盖率多少?”

及格回答(60-70分):
“我们项目用 Jest 写单元测试,覆盖率大概 70% 左右。核心业务逻辑都会写测试,用 Mock 处理外部依赖。”
问题出在:知道工具但不理解策略。 满分回答(90-100分):
“单元测试的价值在于降低修改代码的风险,而不是追求覆盖率数字。我在项目中会区分’值得测试’和’不值得测试’的代码——核心业务逻辑(价格计算、权限判断、数据转换)必须测试,纯 UI 样式和第三方库封装不测。“[2]
“测试策略上,我遵循 Testing Library 的理念:测试行为不测试实现。比如一个购物车组件,我不会去断言 state.items.length === 3,而是模拟用户操作——点击添加按钮、检查商品列表是否多了 1 条。这样即使重构内部实现(从 class 换成 hooks),测试也不用改。Mock 的边界放在网络请求和浏览器 API 上,内部工具函数走真实逻辑。“[3]
“覆盖率方面,我们设置了 80% 的语句覆盖率阈值,CI 上低于这个数会阻止合并。但我更关注分支覆盖率——一个 if-else 两边都要测到,否则表面覆盖率 100% 实际只走了一半的逻辑。在一次重构支付模块时,正是因为分支覆盖全面的测试用例发现了优惠券叠加计算的边界 bug,避免了线上资损。“[1]

常见追问

题目来源于真实面经(字节/腾讯/阿里前端面试、牛客网、haizlin/fe-interview),按难度递进。每个模块 8 个 Accordion,覆盖 4 个难度层级。
答题要点: 很多人混淆单元测试和集成测试的边界。核心区别在于”测试范围”和”外部依赖的处理方式”。高分回答:单元测试测试的是一个函数或一个模块的逻辑,所有外部依赖(网络、数据库、其他模块)都用 Mock 替换。它的特点是执行快(毫秒级)、隔离性强(一个测试失败不影响其他)、定位 bug 精准。集成测试测试的是多个模块协作的结果,可能包含真实的网络请求或数据库操作。它的特点是执行慢(秒级)、能发现模块之间的接口问题。
实际项目中,单元测试占 70%、集成测试占 20%、E2E 测试占 10% 是经典的测试金字塔比例。
答题要点: 这道题考察你对测试工具链演进的理解。核心差异在于模块解析方式——Jest 需要自己的转换层,Vitest 直接复用 Vite 的原生 ESM 支持。高分回答:Jest 在 Node.js 环境中运行,但前端代码使用的是 ES Modules 语法(import/export)。Jest 需要通过 Babel 或 SWC 把 import 转成 require(CommonJS),这个转换过程是 Jest 慢的主要原因之一。Vitest 直接复用 Vite 的模块解析能力——Vite 在开发模式下使用原生 ESM,浏览器直接加载 ES Modules,不需要转换。Vitest 利用同样的机制,省去了 Babel/SWC 的编译步骤。
另外 Vitest 支持按需转换——只转换被测试文件实际 import 的模块,而 Jest 会转换所有匹配的文件。在大型项目中,这个差异会明显影响启动和执行耗时。
答题要点: 快照测试是最容易被滥用的测试方式。核心判断标准是:这个组件的渲染结果是否需要频繁变更?高分回答:快照测试适合稳定且复杂的 UI 输出——比如一个配置面板有 20 个字段,你不想写 20 个断言,快照测试可以一次性捕获整个渲染结果。当有人改了某个字段的默认值,快照会报错,提醒你确认变更是否预期。快照测试不适合频繁变化的组件——比如一个列表组件,数据来自 API,每次测试 Mock 的数据不同,快照就会频繁失败。这种”习惯性失败”会导致开发者机械地运行 --updateSnapshot 更新快照,快照测试彻底失去意义。
答题要点: 异步测试是前端单元测试最常见的坑。关键是区分”真异步”和”假异步”,选择对应的测试策略。高分回答:测试异步代码有三种方式,对应不同的异步场景:
最常见的错误是忘记 await——测试函数返回了 Promise 但没有等待它完成,测试总是”通过”但实际上什么都没测到。
答题要点: 必须分场景讨论——简单交互用 fireEvent,复杂交互用 userEvent。核心原则是模拟真实用户行为。高分回答:
关键区别:fireEvent.change 直接触发 change 事件,而 userEvent.type 会依次触发 focus → keydown → keypress → input → keyup,更接近真实用户行为。如果组件监听了 keydown 事件做快捷键处理,用 fireEvent.change 测不出来。
答题要点: Hooks 不能在普通函数中调用,必须用 renderHook 包裹。重点是测试 Hook 的返回值和副作用,而不是内部实现。高分回答:
act() 包裹的状态变更是必须的——React 要求所有会导致 re-render 的操作都在 act() 中执行,否则会报警告。
答题要点: 这是一道争议性问题。表面答案是”当然可行”,但实际落地有很多限制。要给出分场景的判断。高分回答:TDD 的标准流程是”红 → 绿 → 重构”:先写失败的测试,再写最少的代码让它通过,最后重构优化。这个流程在纯逻辑层(工具函数、业务规则、数据转换)非常有效——先定义输入输出的契约,再实现逻辑。但在UI 层,TDD 几乎不可行。你很难在没看到页面之前就写出”这个按钮应该是蓝色的、点击后弹出 300ms 的动画”这样的测试。UI 开发的天然流程是”先看效果,再固化行为”,这和 TDD 的”先定契约,再实现”是矛盾的。
实际项目中,核心业务逻辑用 TDD,UI 用”先开发后补测试” 是最务实的策略。
答题要点: 这是实际项目中的高频问题。关键策略是”先测核心路径,逐步扩大覆盖”,而不是试图一次性覆盖所有代码。高分回答:为遗留项目添加测试的策略分四步:第一步:从 bug 开始。 每次修复 bug 时,先写一个能复现 bug 的测试,确认测试失败后再修复代码,确认测试通过。这是成本最低、收益最高的切入点——你同时修了 bug 还留下了回归测试。第二步:从核心业务逻辑开始。 不要从 UI 组件开始,先测工具函数和业务规则。这些函数通常没有外部依赖,不需要 Mock,测试编写成本最低。第三步:设置覆盖率阈值,但只看增量。 CI 中对新提交的代码设置 80% 覆盖率阈值(Jest 用 --changedSince、Vitest 用 --changed 只检查本次修改的文件),而不是强制全局 80%。这样不阻塞现有代码,但确保新代码有测试。第四步:引入测试文化。 Code Review 时要求新功能必须附带测试,而不是靠制度强制。当团队习惯了”改代码 → 跑测试 → 安心提交”的流程后,测试就变成了自然习惯。

📚 延伸阅读与引用参考