不要在单个 `it` 里堆砌多个 `assert`:Mocha 测试拆分的正确姿势(Modern JavaScript Tutorial 实战解析)
不要在单个 it 里堆砌多个 assert:Mocha 测试拆分的正确姿势(Modern JavaScript Tutorial 实战解析)
本篇文章以 en.javascript.info 仓库中 "What's wrong in the test?" 这道习题及其参考答案为核心,剖析 BDD 测试中的一个典型反模式——"一个
it块里塞三个assert",并给出两种最佳实践:把测试拆分为多个独立的it块、以及用it.only隔离单个用例调试。读完本文,你将能写出失败信息清晰、可单独运行、易于定位问题的 Mocha 测试。
问题测试:语法正确、运行通过,但暗藏陷阱
题目给出的 pow 测试如下(源码见 task.md):
it("Raises x to the power n", function() {
let x = 5;
let result = x;
assert.equal(pow(x, 1), result);
result *= x;
assert.equal(pow(x, 2), result);
result *= x;
assert.equal(pow(x, 3), result);
});
表面上看,这段代码没有任何语法问题,三个 assert.equal 也都能通过。它"看起来"测了三个用例:pow(5,1)、pow(5,2)、pow(5,3)。但正如参考答案开篇指出的——它实际上是 3 个测试,却被塞进了 1 个函数、用 3 个 assert 拼凑而成:
"The test demonstrates one of the temptations a developer meets when writing tests. What we have here is actually 3 tests, but layed out as a single function with 3 asserts."
这种写法之所以诱人,是因为"有时这样写更省事"("Sometimes it's easier to write this way")。然而一旦某个断言失败,问题就暴露了。
为什么是反模式:失败时你不得不"调试测试本身"
参考答案给出了核心论点:错误发生时,这个 it 块立刻终止,后面所有断言都不会执行,你能获取的诊断信息极其有限。
设想一个更复杂的场景——执行流程中途出错:
- 你只知道"某个用例失败了",却不知道是哪个参数、哪组输入、失败时的中间数据是什么;
- 由于断言之间共享变量(如题目里的
result),你甚至要逆向推算走到第几个assert时数据变成了什么; - 结果是:你不得不去调试测试代码本身("We'll actually have to debug the test"),而不是调试被测函数。
这正是参考文档反复强调的要点:单个 it 内多个断言,会让"哪个用例坏了"变得模糊,诊断成本直线上升。反观一个断言对应一个用例的写法,"if an error occurs, it's much less obvious what went wrong" 的问题就消失了。
正确姿势一:拆成多个 it 块,一测一事
参考答案给出的改进版本,是用一个 describe 包裹一组 it 块,每个 it 拥有清晰描述语和独立断言:
describe("Raises x to power n", function() {
it("5 in the power of 1 equals 5", function() {
assert.equal(pow(5, 1), 5);
});
it("5 in the power of 2 equals 25", function() {
assert.equal(pow(5, 2), 25);
});
it("5 in the power of 3 equals 125", function() {
assert.equal(pow(5, 3), 125);
});
});
改动只有一处:把单个 it 换成 describe 加一组 it("We replaced the single it with describe and a group of it blocks")。收益立竿见影:
- 失败信息自带上下文:一旦
pow(5, 2)出错,测试报告会直接显示 "5 in the power of 2 equals 25" 这条用例失败,输入输出一目了然; - 互不牵连:一个断言失败不会终止同组其他断言,你能一次性看到所有失败项;
- 符合"一个测试只测一件事"(One test checks one thing)的 BDD 原则——这条原则在 05-testing-mocha/article.md 的 "Improving the spec" 一节中有详细论述:看到两个相互独立的检查出现在同一个
it里,就应该拆成两个更简单的测试。
正确姿势二:it.only 隔离单个用例,独立调试
参考答案还给出了另一个实战技巧:用 it.only 代替 it,Mocha 就只会运行这一个测试块("Mocha will run only this block"):
describe("Raises x to power n", function() {
it("5 in the power of 1 equals 5", function() {
assert.equal(pow(5, 1), 5);
});
// Mocha will run only this block
it.only("5 in the power of 2 equals 25", function() {
assert.equal(pow(5, 2), 25);
});
it("5 in the power of 3 equals 125", function() {
assert.equal(pow(5, 3), 125);
});
});
执行这段测试时,Mocha 会跳过其他所有 it,只跑标记了 it.only 的那个用例。这在两种场景下尤其有用:
- 定位失败:大规模测试失败时,先用
it.only圈定可疑用例,把注意力收敛到单个输入输出上; - 迭代开发:BDD 工作流中"先写 spec、再写实现、反复让测试变绿"(见 article.md 的 "The development flow"),
it.only让你在修改实现时只关注当前用例,跑得更快、反馈更直接。
注意:it.only 是调试手段,提交代码前务必移除,否则其余用例会被静默跳过,等于整个测试套件失去回归保护。
仓库源码印证:拆分思想贯穿整套测试体系
本仓库围绕 pow 演示了完整的 BDD 演进流程,测试拆分的思想在其中一以贯之。对照各阶段的真实测试文件,可以更直观地理解"一测一事"的分量:
- 最小 spec:pow-1.view/test.js 只有一个
it("raises to n-th power"),断言pow(2, 3) === 8,用于验证测试框架本身能跑通; - 拆分为两个用例:pow-2.view/test.js 把
pow(2,3)与pow(3,4)拆成两个独立it,结果第二个用例失败、而第一个仍通过——这正是"拆开比塞一起"的信息优势:你能立刻看到失败的是3 raised to power 4 is 81,而不是整组测试一起红; - 循环生成用例:pow-3.view/test.js 用
for循环 +makeTest辅助函数批量生成it,每个it依然只含一个断言; - 嵌套 describe 分组:pow-4.view/test.js 把
makeTest和for收进嵌套的describe("raises x to power 3")子分组,输出报告自带缩进层级; - 完整 spec:pow-full.view/test.js 在此基础上新增
assert.isNaN(pow(2, -1))与assert.isNaN(pow(2, 1.5))两个独立用例,分别验证负指数与非整数指数的边界行为。
再看运行环境:index.html 展示了浏览器端的完整测试页结构——<head> 引入 Mocha 与 Chai 后执行 mocha.setup('bdd')、<div id="mocha"> 承载结果输出、末尾 mocha.run() 启动测试;pow-full.view/index.html 则是把完整 pow 实现(含 n < 0 与 Math.round(n) != n 的 NaN 防护)与测试脚本组装起来的最终可运行版本。
这些文件共同印证了一个结论:无论是最小 spec 还是完整回归套件,每一个 it 都只承载一个独立断言——这既是"测试即文档"(it 的标题用自然语言描述用例)的基础,也是失败报告可读性的前提。
延伸:何时该拆分、何时可合写
拆分不是教条,而是基于信息论的成本权衡。参考文档的表述可以帮助你建立判断标准:
- 当两个断言描述的是同一件事的两个阶段(比如先初始化再验证),可以留在同一个
it里;但题目这种"三个独立幂次用例"显然属于三个独立行为,必须拆分; - 当你想从失败中获取最大信息量时,拆分是唯一正解——
assert抛错会立即终止it块,合写的测试在第一个断言失败后就"沉默"了(见 article.md 对assert行为与"One test checks one thing"的说明); - 当某个
it中出现了两个互不依赖的检查,即使现在能通过,也要拆——因为将来重构时,它们失败的原因往往不同,合在一起会让你无从下手。
小结
这道习题的结论可以浓缩为三条可直接落地的规范:
- 一个
it只测一件事:独立用例拆成独立it,标题用自然语言写明输入输出(如 "5 in the power of 2 equals 25"); - 失败信息优先:断言失败会中断当前
it,合写断言等于主动丢弃诊断信息,还会迫使你"调试测试本身"; - 善用
it.only隔离:定位问题或迭代实现时用它单跑一个用例,完成后务必移除。
把这三条应用到实际项目中,配合 describe 分组、beforeEach/afterEach 等 Mocha 机制(完整介绍见 05-testing-mocha/article.md),你就能写出既快又稳、失败时一眼定位问题的自动化测试。