Lec 2 测试
本节学习后结束后,你应该能够:
- 理解测试的价值,并了解测试优先编程的过程;
- 能够判断测试套件的正确性、全面性和规模;
- 能够通过划分输入空间和选择良好的测试用例来设计函数的测试套件;
- 能够通过测量代码覆盖率来评估测试套件;
- 理解并知道何时使用黑盒测试与白盒测试、单元测试与集成测试,以及自动回归测试。
验证
测试是一个更广泛过程的一部分,称为验证。验证的目的是揭示程序中的问题,从而增加你对程序正确性的信心。验证包括:
- 对程序的形式化推理(Formal reasoning),通常称为验证(Verification)。验证通过构建一个程序正确性的形式证明来实现。手工进行验证非常繁琐,而且自动化工具支持验证仍然是一个活跃的研究领域。然而,程序中的一些小而关键的部分可能会被正式验证,例如操作系统中的调度器、虚拟机中的字节码解释器或操作系统中的文件系统。
- 代码审查。让其他人仔细阅读你的代码,并对其进行非正式的推理,可以是一种发现错误的好方法。这就像让别人校对你写的文章一样。在另一篇阅读材料中,我们会讨论代码审查。
- 测试。在精心选择的输入上运行程序并检查结果。
自动化单元测试
测试策略文档化实践
将测试策略明确记录在测试套件中至关重要,这能使测试的完备性对阅读者透明,并帮助开发者快速识别分区覆盖的遗漏。推荐在测试文件的 describe() 函数顶部通过注释说明策略框架:
describe("max", function() {
/*
* 测试策略
*
* 分区依据:
* a < b
* a > b
* a = b
*/
it(...); // 测试用例
it(...);
});每个测试用例应明确标注其覆盖的子域,例如:
it("covers a < b", function() {
assert.strictEqual(Math.max(1, 2), 2);
});复杂场景的多维度分区
对于涉及多个分区的复杂操作(如大整数乘法),可采用笛卡尔积组合策略:
describe("乘法运算", function() {
/*
* 测试策略
*
* 覆盖以下分区的笛卡尔积:
* a的取值:正数、负数、0
* b的取值:正数、负数、0
* a是否为1:=1, ≠1
* b是否为1:=1, ≠1
* a的数值范围:可存入number类型 / 超出number范围
* b的数值范围:可存入number类型 / 超出number范围
*
* 额外覆盖以下符号组合:
* 同号(均正/均负)
* 异号
* 含零
*/
it("cover a=1, b≠1, 同号场景", function() {
assert.strictEqual(BigInt(1) * BigInt(33), BigInt(33));
});
it("cover a为正数, b为负数, " +
"a/b均未溢出, 异号场景", function() {
assert.strictEqual(BigInt(73) * BigInt(-2), BigInt(-146));
});
});测试覆盖率
衡量测试套件的完备性通常从覆盖率维度切入,主要有三种层级:
- 语句覆盖率
- 是否每条语句都至少被执行一次?
- 分支覆盖率
- 每个控制分支(如
if/while/三元表达式)是否都被执行过所有可能路径?
- 每个控制分支(如
- 路径覆盖率
- 是否覆盖所有可能的分支组合(即程序所有执行路径)?

图示:Istanbul覆盖率工具的输出示例
覆盖率层级关系
- 严格性:路径覆盖率 > 分支覆盖率 > 语句覆盖率
- 工业实践:
- 100%语句覆盖率是常见目标(但防御性代码如"不应执行到此"的断言常导致未达标)
- 100%分支覆盖率是理想状态,安全关键系统(如航空软件)需满足更严苛标准(如MC/DC覆盖)
- 100%路径覆盖率理论上不可行(需要指数级测试用例)
覆盖率工具实践
标准工作流:
- 使用代码覆盖率工具(如TypeScript的c8)量化测试覆盖情况
- 通过白盒测试补充用例,直到关键语句全部标记为已执行
工具输出解读(以c8为例):
- 绿色标记:已覆盖的代码行(边缘数字表示执行次数)
- 红色标记:未覆盖的代码行
- I/E图标:分支仅覆盖单一路径(I=if路径未覆盖,E=else路径未覆盖)
当发现红色行或I/E标记时,应补充测试用例使对应分支被触发
单元测试与集成测试
我们目前讨论的单元测试主要针对独立模块进行隔离测试。这种隔离性让调试更高效——当某个模块的单元测试失败时,可以快速锁定问题在该模块内部,而非整个程序范围。
与之相对的集成测试则验证多个模块的组合或整个系统的运行。如果仅依赖集成测试,当测试失败时,开发者需要像"大海捞针"般在全系统寻找问题根源。但集成测试不可或缺,因为模块间的接口协作常出问题(例如模块实际接收的输入与预期不符)。若已通过全面的单元测试确保各模块正确性,故障排查范围将大幅缩小。
自动化回归测试
当你拥有自动化测试能力后,在代码修改后重新运行测试至关重要。软件工程师从惨痛经验中认识到:对大型或复杂程序的任何改动都充满风险。无论是修复缺陷、新增功能还是性能优化,一个能维护正确行为基线的自动化测试套件(即使只包含少量测试用例)都将成为你的救命稻草。在代码变更过程中频繁运行测试,能有效防止程序退化(即在修复旧缺陷或添加新功能时引入新问题)。这种在每次修改后运行全部测试的做法称为回归测试。
迭代测试驱动开发
高效的软件工程从来不是线性过程,而应采用迭代式测试驱动开发——随时准备回溯并修正前期工作:
- 编写函数规格说明
- 基于规格设计测试用例 → 发现问题时迭代修正规格和测试
- 编写实现代码 → 发现问题时迭代修正规格、测试和实现
每个步骤都在验证前序工作的有效性:
- 编写测试是理解规格的最佳方式,能早期暴露规格中的错误、遗漏、歧义或边界情况缺失
- 实现过程可能揭示测试用例的不足,或促使重新审视规格
由于回溯修正不可避免,不必强求每个步骤"完美"后才进入下一阶段:
- 对复杂规格:先完成核心部分 → 测试实现 → 逐步扩展完整规格
- 对复杂功能:先选择关键等价类建立精简测试集 → 通过简单实现验证 → 逐步补充测试分区
- 对复杂实现:先编写暴力实现验证规格和测试 → 确保基础正确后再优化
迭代是现代软件工程(如敏捷开发)的核心实践,其有效性已获实证支持。它要求开发者转变思维:
- 学生思维:追求从零到完美的线性解答(如作业/考试场景)
- 工程思维:快速产出初级方案 → 通过持续迭代优化 → 预留推翻重做的余地
当问题复杂且解决方案不明确时,迭代能最大化时间利用率。
随机化测试技术
项目后期阶段,在系统化测试完成基础验证后,我们可以通过在特定场景随机生成的测试用例。
模糊测试(Fuzz Testing):为函数或完整程序生成随机输入,旨在发现缺陷或安全漏洞。
基于属性的测试(Property-Based Testing):相比模糊测试更具方法论性,其工作流程:
- 生成随机输入(通常通过定制代码模拟真实数据,并经过前置条件过滤)
- 验证结果是否满足特定属性(弱于完整后置条件)
前者像用随机炮弹轰炸城墙,只要有一发击穿就证明防御有漏洞。后者像质检员,不关心产品颜色,但确保所有出厂产品都符合「重量≥100g」的标准。
总结
本次阅读涵盖以下关键概念:
- 测试驱动开发:编写代码前先设计测试用例
- 系统化测试:通过等价类划分和边界值分析,构建正确、全面且精简的测试集
- 白盒测试与语句覆盖:确保测试集覆盖所有代码路径
- 模块单元测试:尽可能隔离测试每个模块
- 自动化回归测试:防止已修复缺陷复发
- 迭代式开发:预留重构优化的空间
与软件三大核心特性的关联
减少错误(Safe from bugs)
- 测试的核心目标是发现代码缺陷
- 测试驱动开发能在引入错误后立即暴露问题
易于理解(Easy to understand)
- 系统化的测试策略文档化测试用例选择逻辑
- 清晰展示测试集的覆盖完整性
便于修改(Ready for change)
- 规范的测试集仅依赖规格说明中的行为定义,允许实现方式自由变更
- 自动化回归测试保障代码修改时不引发历史缺陷复现