CLASS 02 · 2026-02-04 · 基础
测试
测试是验证软件正确性的核心手段,但穷举测试不可行。本讲介绍如何通过「先写规格、再写测试、最后实现」的流程,系统化地划分输入空间、选取边界值,设计出既正确又精简的测试套件。
为什么软件测试很难
测试是更一般的验证(validation)过程的一个实例。理论上我们想验证「程序对所有合法输入都给出正确结果」,但输入空间往往大到无法穷举——穷举测试是不可行的,因此测试用例必须经过精心而系统的挑选。
测试优先编程
正确的开发顺序不是「先实现再补测试」,而是反过来:
- 规格(Spec):先写出函数的规格说明(它应该做什么)。
- 测试(Test):根据规格编写能检验该规格的测试用例。
- 实现(Implement):最后才写让测试通过的代码。
这样做的好处是:测试用例源自规格而非实现细节,因此即便将来重写实现,测试依然有效。
好测试套件的三条标准
- 正确(Correct):接受所有符合规格的合法实现。
- 充分(Thorough):能够发现实际存在的 bug。
- 精简(Small):用例数量尽量少,避免冗余。
这三者之间存在张力:充分往往意味着更多用例,而精简要求压缩用例。系统化的划分方法是平衡它们的关键。
通过划分选取测试用例
把程序的输入空间划分为若干子域(subdomains),每个子域是一组具有相似行为的输入,然后从每个子域中至少选取一个代表作为测试用例。
以求绝对值函数为例,输入空间可以划分为两个子域:\(x \ge 0\) 和 \(x < 0\)。
/**
* 返回一个数的绝对值。
* @param x 任意实数
* @returns x 的绝对值
*/
function abs(x: number): number {
if (x < 0) return -x;
else return x;
}
// 测试用例:从每个子域各取一个代表
// 子域 x >= 0 → 取 5,期望返回 5
// 子域 x < 0 → 取 -3,期望返回 3
划分必须包含边界
bug 经常出现在子域的边界上。因此划分不仅要覆盖每个子域内部,还必须显式地包含边界点。对于 abs,边界点 \(x = 0\) 必须单独测试:
// 边界用例:x = 0,期望返回 0
// 这条用例最容易暴露 "x <= 0" 和 "x < 0" 的混淆
黑盒测试与白盒测试
- 黑盒测试(Black box):仅根据规格选取测试用例,不需要看实现代码。
- 白盒测试(Glass box):根据函数的内部实现来设计测试用例,以覆盖更多代码路径。
白盒测试仍需遵循规格——它的作用是补充规格可能遗漏的路径覆盖,而不是替代规格。
代码覆盖率
衡量测试充分程度的常用指标:
- 语句覆盖(Statement coverage):每条语句是否都被执行过?
- 分支覆盖(Branch coverage):每个分支的两侧是否都被取到?
- 路径覆盖(Path coverage):程序中所有可能的路径是否都被走过?
分支覆盖强于语句覆盖,路径覆盖最强但往往不可行(循环会引入无穷多条路径)。实践中通常以分支覆盖为最低要求。
单元测试与集成测试
- 单元测试(Unit test):测试单个模块。
- 集成测试(Integration test):测试多个模块组合后的行为。
JavaScript/TypeScript 生态中,Mocha 是流行的单元测试框架。
自动化回归测试
每次修改代码后自动运行全部测试,称为回归测试(regression testing)。它的目的是确保新改动没有破坏原有功能——这正是「免于错误」和「便于修改」的保障。
迭代式测试优先编程
实际开发中,「规格 → 测试 → 实现」是一个迭代循环。先实现和测试一部分功能,获取反馈,再逐步扩展。不要试图一次写出完整的规格和全部测试。
随机化测试
除了手工选取用例,还可以借助自动化手段:
- 模糊测试(Fuzz testing):生成大量随机输入,看程序是否崩溃或违反规格。
- 属性测试(Property-based testing):不检查具体输出,而是检查结果是否满足某个性质(例如「数组排序后长度不变」)。
核心要点
测试优先先写规格,再写测试,最后写实现;测试用例应源自规格。
系统化划分把输入空间划分为子域,从每个子域取代表,并显式包含边界。
回归测试每次改动后自动运行全部测试,防止破坏已有功能。