单元测试说到底是让自己能安全改代码的保险,覆盖率不是目标。
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 秒内知道有没有破坏已有功能。就像给车装了刹车自检系统,每次启动前自动检测刹车是否正常。
Q2: Jest/Vitest 核心原理(断言、Mock、快照测试)
测试框架的本质是:运行代码 → 捕获结果 → 与预期对比,核心逻辑只有 3 步: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 定义了一套查询优先级,核心思想是优先使用无障碍友好的查询方式:为什么不要测试实现细节?
测试实现细节会导致两个致命问题:- 重构即失败 — 内部实现变了(比如从 class 改为 hooks),即使功能完全一样,测试也会报错。
- 虚假的安全感 — 测试全部通过,但用户实际使用时发现功能坏了(因为你测的是内部状态,不是用户行为)。
Q4: Mock 策略(手动 Mock、自动 Mock、Mock 模块)
手动 Mock vs 自动 Mock 的区别: 手动 Mock 是你自己写一个假实现,精确控制每个细节。自动 Mock 是框架帮你生成一个空壳,所有方法返回undefined。选错策略会导致测试要么太脆弱(耦合实现),要么太宽松(什么都测不出来)。
关键区别:
❌/✅ 代码对比(展示错误做法和正确做法):
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 的区别是什么?为什么 Vitest 更快?
Jest 和 Vitest 的区别是什么?为什么 Vitest 更快?
答题要点:
这道题考察你对测试工具链演进的理解。核心差异在于模块解析方式——Jest 需要自己的转换层,Vitest 直接复用 Vite 的原生 ESM 支持。高分回答:Jest 在 Node.js 环境中运行,但前端代码使用的是 ES Modules 语法(另外 Vitest 支持按需转换——只转换被测试文件实际 import 的模块,而 Jest 会转换所有匹配的文件。在大型项目中,这个差异会明显影响启动和执行耗时。
import/export)。Jest 需要通过 Babel 或 SWC 把 import 转成 require(CommonJS),这个转换过程是 Jest 慢的主要原因之一。Vitest 直接复用 Vite 的模块解析能力——Vite 在开发模式下使用原生 ESM,浏览器直接加载 ES Modules,不需要转换。Vitest 利用同样的机制,省去了 Babel/SWC 的编译步骤。快照测试什么时候该用?什么时候不该用?
快照测试什么时候该用?什么时候不该用?
答题要点:
快照测试是最容易被滥用的测试方式。核心判断标准是:这个组件的渲染结果是否需要频繁变更?高分回答:快照测试适合稳定且复杂的 UI 输出——比如一个配置面板有 20 个字段,你不想写 20 个断言,快照测试可以一次性捕获整个渲染结果。当有人改了某个字段的默认值,快照会报错,提醒你确认变更是否预期。快照测试不适合频繁变化的组件——比如一个列表组件,数据来自 API,每次测试 Mock 的数据不同,快照就会频繁失败。这种”习惯性失败”会导致开发者机械地运行
--updateSnapshot 更新快照,快照测试彻底失去意义。如何测试异步代码?Promise、setTimeout、async/await 分别怎么处理?
如何测试异步代码?Promise、setTimeout、async/await 分别怎么处理?
答题要点:
异步测试是前端单元测试最常见的坑。关键是区分”真异步”和”假异步”,选择对应的测试策略。高分回答:测试异步代码有三种方式,对应不同的异步场景:最常见的错误是忘记
await——测试函数返回了 Promise 但没有等待它完成,测试总是”通过”但实际上什么都没测到。如何测试 React 组件的事件处理和状态变更?
如何测试 React 组件的事件处理和状态变更?
答题要点:
必须分场景讨论——简单交互用 fireEvent,复杂交互用 userEvent。核心原则是模拟真实用户行为。高分回答:关键区别:
fireEvent.change 直接触发 change 事件,而 userEvent.type 会依次触发 focus → keydown → keypress → input → keyup,更接近真实用户行为。如果组件监听了 keydown 事件做快捷键处理,用 fireEvent.change 测不出来。如何测试自定义 Hooks?
如何测试自定义 Hooks?
答题要点:
Hooks 不能在普通函数中调用,必须用
renderHook 包裹。重点是测试 Hook 的返回值和副作用,而不是内部实现。高分回答:act() 包裹的状态变更是必须的——React 要求所有会导致 re-render 的操作都在 act() 中执行,否则会报警告。TDD(测试驱动开发)在前端项目中真的可行吗?
TDD(测试驱动开发)在前端项目中真的可行吗?
答题要点:
这是一道争议性问题。表面答案是”当然可行”,但实际落地有很多限制。要给出分场景的判断。高分回答:TDD 的标准流程是”红 → 绿 → 重构”:先写失败的测试,再写最少的代码让它通过,最后重构优化。这个流程在纯逻辑层(工具函数、业务规则、数据转换)非常有效——先定义输入输出的契约,再实现逻辑。但在UI 层,TDD 几乎不可行。你很难在没看到页面之前就写出”这个按钮应该是蓝色的、点击后弹出 300ms 的动画”这样的测试。UI 开发的天然流程是”先看效果,再固化行为”,这和 TDD 的”先定契约,再实现”是矛盾的。实际项目中,核心业务逻辑用 TDD,UI 用”先开发后补测试” 是最务实的策略。
如何为一个遗留项目从零开始添加单元测试?
如何为一个遗留项目从零开始添加单元测试?
答题要点:
这是实际项目中的高频问题。关键策略是”先测核心路径,逐步扩大覆盖”,而不是试图一次性覆盖所有代码。高分回答:为遗留项目添加测试的策略分四步:第一步:从 bug 开始。 每次修复 bug 时,先写一个能复现 bug 的测试,确认测试失败后再修复代码,确认测试通过。这是成本最低、收益最高的切入点——你同时修了 bug 还留下了回归测试。第二步:从核心业务逻辑开始。 不要从 UI 组件开始,先测工具函数和业务规则。这些函数通常没有外部依赖,不需要 Mock,测试编写成本最低。第三步:设置覆盖率阈值,但只看增量。 CI 中对新提交的代码设置 80% 覆盖率阈值(Jest 用
--changedSince、Vitest 用 --changed 只检查本次修改的文件),而不是强制全局 80%。这样不阻塞现有代码,但确保新代码有测试。第四步:引入测试文化。 Code Review 时要求新功能必须附带测试,而不是靠制度强制。当团队习惯了”改代码 → 跑测试 → 安心提交”的流程后,测试就变成了自然习惯。📚 延伸阅读与引用参考
- 🔗 Martin Fowler: TestPyramid:测试金字塔的经典定义,明确了单元测试、集成测试、E2E 测试的比例关系。[1]
- 📖 Kent C. Dodds: Testing Implementation Details:深入解释为什么不要测试实现细节,Testing Library 的设计哲学来源。[2]
- 🟢 Testing Library 官方文档:Testing Library 的查询优先级、最佳实践和 API 参考。[3]
- 📖 Kent C. Dodds: Write tests. Not too many. Mostly integration.:重新定义了测试金字塔,提出集成测试比单元测试更有价值的观点。[4]
- 🔗 Vitest 官方文档:Vitest 的设计理念和与 Jest 的差异对比。[5]
- 📖 阮一峰: JavaScript 测试框架 Jest 入门教程:国内最通俗易懂的 Jest 入门教程。[6]
