现代 JavaScript 教程:Mocha 单元测试中“一个 it 塞进多个断言”的坑与正确写法
现代 JavaScript 教程:Mocha 单元测试中“一个 it 塞进多个断言”的坑与正确写法
导读:本文以 zh.javascript.info 仓库中《测试代码中有什么错误?》练习题为切入点,剖析一个语法正确、能全部通过却“有问题”的 Mocha 测试用例——它把 3 个
assert断言塞进了同一个it代码块。读完本文,你将掌握 BDD 风格下“一个测试只检查一件事”的拆分原则、describe/it/assert的组织方式,以及用it.only隔离调试单个测试的实战技巧。
一、题目原文与“语法正确但有问题”的测试
仓库中的练习题位于 1-js/03-code-quality/05-testing-mocha/3-pow-test-wrong/task.md,难度标记为 importance 5。题目给出一段针对幂函数 pow(x, n) 的测试代码,并询问:这段测试代码有什么错误?
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);
});
题目的关键提示是:从语法上来说这些测试代码是正确且通过的。也就是说,这不是一个“报错”的坏测试,而是一个“能通过但设计不良”的测试。这正是本练习想要训练的判断力:测试代码的质量不能只看“绿不绿”,更要看“出了问题之后好不好定位”。
从源码结构看,本仓库为该练习仅提供 task.md 与 solution.md 两个文件,没有配套的 .view 可运行示例;但同一章 05-testing-mocha 目录下的 pow-1.view、pow-2.view、pow-full.view 等沙箱页面完整展示了 Mocha + Chai 的浏览器端运行环境,可作为理解本练习的对照素材。
二、错误在哪里:一个 it 里塞了 3 个独立断言
2.1 表面看:三个断言、三段连续计算
上面的代码逻辑上是在连续验证:
pow(5, 1) === 5(result初值为x);pow(5, 2) === 25(result *= x后为 25);pow(5, 3) === 125(再乘一次后为 125)。
三个 assert.equal 语法完全合法,如果 pow 实现正确,整段测试会全部通过——正如题目附注所说“语法上是正确且通过的”。
2.2 实质问题:一个 it 实际承担了三个测试用例
按 solution.md 的官方解答,这段代码“实际上有三条测试,但是用一个函数放置了 3 个断言语句”。这样写有时确实更快,但存在一个致命缺陷:
如果发生错误,那么到底什么出错了就很不明显。 如果错误发生在一个复杂的执行流的中间,那么我们就必须找出那个点的数据,必须去“调试测试”。
在 Mocha 中,it 代码块内一旦某个 assert 抛错,该 it 会立即终止,其后的断言不会执行,且报错信息只指向这一个 it 块。于是你会面临两种糟糕情况:
- 前一个断言失败 → 后两个断言永远看不到结果,你无法一次获知
pow(5, 2)、pow(5, 3)是否也坏了; - 中间某个断言失败 → 报错信息无法直接告诉你是哪个幂次出了问题,你需要顺着
result的累乘过程手动推算当前数据是什么。
这正是 article.md 中强调的核心原则的反面教材:“一个测试检查一个东西”(A test checks one thing)。如果在一个测试里发现两个相互独立的检查,应当拆成两个更简单的检查;独立的测试有助于你准确获知代码中正在发生什么。
三、正确写法:拆成一组带明确输入输出的 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);
});
});
重构之后:
- 定位成本极低:一旦失败,Mocha 输出的测试标题直接告诉你“哪个输入、哪个期望值”出问题了,例如 “5 in the power of 2 equals 25” 失败,就明确是
pow(5, 2) !== 25; - 互不干扰:一个用例失败不会吞掉其他用例的结果,所有断言都能被执行并分别报告;
- 兼具文档价值:按 BDD 的理念,规范(spec)同时是测试、文档和示例,
it的标题本身就是函数行为的可读描述。
四、进阶技巧:用 it.only 隔离单个测试
在拆分后的测试套件中,如果只想单独运行某一个用例进行调试,Mocha 提供了 it.only(以及 describe.only):被标记的代码块是“唯一”会被执行的测试,其余测试被跳过。
describe("Raises x to power n", function() {
it("5 in the power of 1 equals 5", function() {
assert.equal(pow(5, 1), 5);
});
// Mocha 将只运行这个代码块
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);
});
});
这也是 solution.md 给出的官方补充技巧。注意 it.only 只用于临时调试,提交前应恢复为 it,否则会掩盖其他测试的真实状态。
五、对照仓库源码:正确的测试应该如何组织
5.1 基础规范的三要素
article.md 指出一个规范由三个主要模块构成:
describe("title", function() { ... }):描述正在测试的功能,用于组织it代码块;it("use case description", function() { ... }):用易于理解的语言描述特定用例,第二个参数是执行测试的函数;assert.equal(value1, value2):在it块中执行、若实现正确则不应抛错的检查语句;assert.*系列函数用于验证pow是否符合预期。
仓库中的首个规范 pow-1.view/test.js 就是标准的三要素示范:
describe("pow", function() {
it("raises to n-th power", function() {
assert.equal(pow(2, 3), 8);
});
});
5.2 改进后的规范:每个 it 只含一个断言
在 pow-2.view/test.js 中,教程演示了把“pow(2,3)=8、pow(3,4)=81”从同一个 it 里的两个 assert 改写成两个独立的 it:
describe("pow", function() {
it("2 raised to power 3 is 8", function() {
assert.equal(pow(2, 3), 8);
});
it("3 raised to power 4 is 81", function() {
assert.equal(pow(3, 4), 81);
});
});
文中明确解释了两种写法的关键区别:当 assert 触发错误时,it 代码块会立即终止,因此第一种写法中第一个 assert 失败会吞掉第二个 assert 的结果;而“保持测试之间独立,有助于获知代码中正在发生什么”,第二种方式更好。本练习中的三个断言恰好就是这一原则的正面应用场景。
5.3 完整规范的最终形态
pow-full.view/test.js 展示了 BDD 流程收敛后的完整规范:嵌套 describe 分组、makeTest 工厂函数配合 for 循环批量生成用例、以及用 assert.isNaN 覆盖边界条件——每个 it 都只检查一件事:
describe("pow", function() {
describe("raises x to power 3", function() {
function makeTest(x) {
let expected = x * x * x;
it(`${x} in the power 3 is ${expected}`, function() {
assert.equal(pow(x, 3), expected);
});
}
for (let x = 1; x <= 5; x++) {
makeTest(x);
}
});
it("if n is negative, the result is NaN", function() {
assert.isNaN(pow(2, -1));
});
it("if n is not integer, the result is NaN", function() {
assert.isNaN(pow(2, 1.5));
});
});
对应的实现 pow-full.view/index.html 中 pow 处理了负数与非整数入参(if (n < 0) return NaN; if (Math.round(n) != n) return NaN;),与测试一一呼应。这印证了 BDD 的核心节奏:先写可能失败的规范,再实现让测试转绿。
5.4 浏览器端测试环境的搭建
如需本地复现,可直接参考 index.html 的五段式页面结构(这也是所有 pow-*.view/index.html 的通用骨架):
<head>中引入 Mocha 的 CSS 与 JS,并调用mocha.setup('bdd')启用 BDD 风格;- 引入 Chai,并将
let assert = chai.assert;暴露为全局变量; - 页面内
<script>定义被测函数(如pow); - 引入测试脚本
test.js(包含describe/it); - 放置
<div id="mocha"></div>作为结果输出容器,最后执行mocha.run()启动测试。
六、总结:写测试的三条可执行准则
回到本练习,我们可以提炼出三条在编写 Mocha 单元测试时可直接套用的准则:
- 一个
it只检查一个行为:把多个相互独立的断言拆成多个it,让失败时能立即从标题定位到具体输入与期望值; - 用
describe分组、用it标题做文档:清晰的标题既是可读文档,也是失败报告中的定位线索,这正是 BDD 中“测试、文档、案例三位一体”的体现; - 用
it.only临时隔离、用“先失败后转绿”驱动开发:需要调试单个用例时用it.only缩小范围,但务必在调试结束后还原;新增行为(如NaN边界)时先写测试再实现,让规范始终与代码同步演进。
题目中的代码之所以“正确且通过”,是因为它验证的逻辑本身没有问题;它之所以“有错误”,是因为它在测试的可诊断性上犯了设计错误。测试的价值不只在于证明代码此刻正确,更在于当代码将来被修改时,能在几秒钟内精确定位哪里出了问题——这正是自动化测试在现代 JavaScript 项目中的核心价值。