Reading 3: 测试(Testing)
Reading 3: 测试(Testing)
说明:本讲 sp22 原版使用 TypeScript 与 Mocha,本笔记按用户要求提供 Java 代码示例与 JUnit 写法;方法与 API 与 sp21(6.031 Java 版)原文保持一致。
概述
本讲回答一个问题:怎样选择测试用例,才能用最少的测试抓到最多的 bug? 答案是系统性测试(systematic testing):先对规格说明做输入空间划分(partitioning),取每个子域中的边界值(boundary values),再把测试写成自动化(automated) 的单元测试,用代码覆盖率(code coverage) 检查遗漏,用回归测试(regression testing) 锁住已修复的 bug。本讲的全部内容都服务于三大目标:测试直接对应 Safe from bugs;把测试策略写下来对应 Easy to understand;而「测试只依赖规格说明、不依赖实现」正是 Ready for change 的保证——因为实现可以在规格允许的范围内自由变化。
核心概念与设计原则详解
验证(Validation)的三种手段
- 定义与目的:验证是「发现程序中的问题、从而提高对正确性的信心」这一更一般的过程。测试只是其中一种。它服务的质量目标是 Safe from bugs。
- 直观解释(”它是什么?”):验证就像确认一份文稿没问题:你可以形式化证明它正确(verification,构造正确性的形式化证明)、请别人仔细通读(code review,非形式化推理)、或者实际跑一遍看结果(testing)。
- 关键规则与最佳实践:
- 形式化验证(verification) 手工做很繁琐,自动化工具支持仍是活跃研究领域;只有少数关键部件会被形式化验证(操作系统的调度器、虚拟机的字节码解释器、操作系统的文件系统)。
- 代码审查(code review) 让另一个人仔细读代码并做非形式化推理,成本低、收益高(详见 Reading 04)。
- 测试(testing) 在精心选择的输入上运行程序并检查结果。
- 即使做了最好的验证,软件仍然很难达到完美质量。典型残余缺陷率(residual defect rate):工业软件 1–10 缺陷/千行,高质量验证 0.1–1 缺陷/千行(成熟的浏览器 JS 库可达此水平),最顶级的安全关键系统 0.01–0.1 缺陷/千行(NASA 与 Praxis 这类公司可达)。
- 这个数字对大系统很打击人:100 万行工业代码按 1 缺陷/千行算,等于漏掉了 1000 个 bug。
为什么软件测试很难(Why Software Testing Is Hard)
- 定义与目的:理解三条「行不通的路」,才能理解为什么必须系统化地选测试用例。
- 直观解释(”它是什么?”):软件不像硬件——硬件可以抽样(测 1% 硬盘推断整批),可以用「加速老化」推断寿命(24 小时开关冰箱 1000 次);软件的失败点分布不连续、不离散,抽样统计完全失效。
- 关键规则与最佳实践:
- 穷举测试(exhaustive testing)不可行:测试空间通常太大。穷举测试一个 32 位浮点乘法 a*b 需要 2⁶⁴ 个用例。
- 随意测试(haphazard testing)(「随便跑一下看看行不行」)找到 bug 的概率低,也不增加我们对正确性的信心——它没有告诉我们任何关于未测部分的信息。
- 随机/统计测试(random or statistical testing)不适用于软件:物理系统的缺陷在空间上连续或均匀分布,所以抽样有意义;而软件行为在输入空间上是不连续、离散的。「系统在很大范围内的输入上都工作正常,然后在某一个边界点上突然失败」是常态。著名的 Pentium 除法 bug 大约每 90 亿次除法才出现一次;栈溢出、内存耗尽、数值溢出都是突然发生且每次都以同样方式发生,没有概率性变化。
- 因此:测试用例必须被仔细地、系统地选择——这正是本讲的主线。
测试优先编程(Test-First Programming)
- 定义与目的:先写规格说明与测试,再写实现。它把「发现 bug」提前到「刚引入 bug 的那一刻」,是本讲最重要的工程实践。
- 直观解释(”它是什么?”):先想清楚「这个函数该做什么、怎么算做对了」,再动手写实现。就像建筑先出图纸再施工。
- 关键规则与最佳实践:
- 模块(module):软件中可以独立设计、实现、测试、推理的一部分。本讲聚焦以方法(函数)为单位的模块,后续 Reading 会扩展到类。
- 规格说明(specification / spec):描述模块的行为——参数类型与附加约束(例如
sqrt的参数必须非负)、返回类型以及返回值与输入的关系。在 Java 中,规格说明由方法签名 + 上方注释组成。 - 实现(implementation) 提供行为,客户(client) 使用模块;规格说明同时约束这两方。
- 测试用例(test case):一组具体输入 + 规格说明要求的预期输出行为。测试套件(test suite):一组测试用例。
- 开发顺序:Spec(写规格说明)→ Test(写测试)→ Implement(写实现)。实现通过了测试,就完成了。
- 最大收益是 Safe from bugs:不要把所有测试留到最后,那时你有一大堆未经检验的代码,bug 可能在任何地方。
系统性测试的三个性质:正确、彻底、小(Correct, Thorough, Small)
- 定义与目的:系统性测试意味着「有原则地」选择用例,目标是设计出同时满足三个性质的测试套件。
- 直观解释(”它是什么?”):像选代表团的成员——每个族群(子域)都要有代表(彻底),但代表总数要少(小),而且不能把不该来的人算进来(正确)。
- 关键规则与最佳实践:
- 正确(Correct):测试套件是规格说明的合法客户,接受该规格的所有合法实现而不抱怨。这让你可以自由改动内部实现而无需改测试。
- 彻底(Thorough):能找出实现中真实存在的、程序员很可能犯的错误。
- 小(Small):用例少 → 写起来快、规格变化时好改、运行快 → 你会跑得更频繁。
- 对照三性质:穷举测试彻底但大得不可行;随意测试小但不彻底;随机测试要达到彻底必须付出巨大规模的代价。
- 心态转变:写代码时你的目标是「让它工作」;设计测试套件时你的目标是「让它失败」。好的测试者会故意去戳程序最脆弱的地方。测试优先编程有助于获得这种「残酷的视角」,因为你在还没写出代码、不会把代码当作脆弱蛋壳的时候就已经戴上了测试的帽子。
输入空间划分(Partitioning the Input Space)
- 定义与目的:把输入空间拆成若干子域(subdomain),使每个子域内部的输入行为相似,然后从每个子域取一个代表作为测试用例。这是「用少量用例覆盖大量行为」的核心技术。
- 直观解释(”它是什么?”):不去检查沙滩上的每一粒沙,而是按「沙粒大小」分成几类,每类抓一把看看。子域的名称来自数学:它是函数定义域(domain)的子集。
- 关键规则与最佳实践:
- 划分(partition) 必须满足两点:子域两两不相交(disjoint),且完全覆盖(complete) 输入空间——每个输入恰好落在一个子域中。
- 紧凑写法就是列出一串谓词:
// partition: a >= 0; a < 0。 - 划分必须「正确」:每个子域都要能被合法的测试用例覆盖。例如对规格要求
x非负的sqrt,划分成x < 0; x >= 0虽不相交且完整,但x < 0子域无法用合法输入覆盖,因此不是一个好划分。 - 划分应当围绕规格说明中行为发生变化的地方:
Math.max(a, b)的规格对不同子域有不同要求,所以取a < b; a > b; a = b(注意必须有a = b,否则不完整)。 - 有时按输出表达划分更方便:
// partition on a.multiply(b): 0, positive, negative是{(a,b) \| a*b = 0}、> 0、< 0三个子域的简写。 - 对于多参数函数,把每个参数的关注点写成多个独立划分,让一个测试用例同时覆盖来自不同划分的多个子域,可以避免笛卡尔积爆炸(6×6 = 36 个用例降到 6 个)。但要注意:独立划分会漏掉参数间的交互,所以要额外补一个捕捉交互的划分(如「a 与 b 同号 / 异号 / 有一个为 0」)。
边界值(Boundary Values)
- 定义与目的:bug 常常出现在子域之间的边界上,因此边界值必须被写成单元素子域,从而保证测试套件必然包含它们。
- 直观解释(”它是什么?”):边界是行为「换挡」的地方,换挡处最容易出错。
- 关键规则与最佳实践:
- 常见边界:0(正负数之间)、数值类型的最大/最小值(
Integer.MAX_VALUE、Integer.MIN_VALUE、Long.MAX_VALUE)、集合类型的空值(空字符串、空列表、空集合)、序列的第一个与最后一个元素。 - 为什么边界容易出 bug:程序员常犯 off-by-one(写
<=而不是<、计数器初值写 0 而不是 1);某些边界需要被当作特例处理;边界是行为的不连续点。 - 加入边界后,原来的子域要收缩以排除边界。以
Math.abs为例,完整划分是:a = Integer.MIN_VALUE;Integer.MIN_VALUE < a < 0;a = 0;0 < a < Integer.MAX_VALUE;a = Integer.MAX_VALUE,五个子域不相交且完整覆盖。 - Java 的
Math.abs在Integer.MIN_VALUE上有一个反直觉行为:规格说明明确写「若参数等于Integer.MIN_VALUE,结果是同一个(负)值」。原因是 -2³¹ 的相反数 2³¹ 超出了int的范围。这类「规格里写明的怪行为」正是最该测的边界。 - 对「字符串长度至多 5 且只含 ‘W’/’L’」的
winLossRatio,合适的边界包括""(空串)、"WWWWW"(全胜)、"LLLLL"(全负)。
- 常见边界:0(正负数之间)、数值类型的最大/最小值(
自动化单元测试与 JUnit(Automated Unit Testing)
- 定义与目的:单元测试(unit test) 测试单个模块,尽可能隔离。自动化让测试真正被执行——没人愿意手动跑一百次。
- 直观解释(”它是什么?”):把测试写成一个可执行的方法,断言(assert)期望结果;运行测试类,得到「全部通过」或「这些用例失败了:……」。
- 关键规则与最佳实践:
- Java 使用 JUnit:测试方法用
@Test注解标注,方法体内调用被测量模块,然后用assertEquals、assertTrue、assertFalse等断言方法检查结果。 - 参数顺序至关重要:
assertEquals的第一个参数是期望值(expected),通常是个常量;第二个参数是实际值(actual),即代码真正算出来的东西。所有比较值的 JUnit 断言都遵循「expected 在前,actual 在后」。写反了会导致失败信息令人困惑。 - TypeScript 对照(sp22 原版写法):Mocha 的
assert.strictEqual顺序正好相反——第一个参数是 actual,第二个是 expected。这是从 TypeScript 切到 Java 时最容易搞错的一点。 - 断言可以带可选的消息字符串作为最后一个参数,让失败信息更有用;一个测试方法里的断言失败后该方法立即返回,但其他
@Test方法仍会运行。 - 对返回结构(集合、数组)的比较,需要注意
assertEquals只对内置类型可靠;补充说明:JUnit 5 的assertIterableEquals可以比较可迭代对象的内容,而手写断言应当检查结构的关键性质(如「集合大小为 1 且包含 hello」)而不是逐元素比对。
- Java 使用 JUnit:测试方法用
记录测试策略(Documenting Your Testing Strategy)
- 定义与目的:把用到的划分、子域,以及每个测试用例覆盖了哪些子域写下来。这让测试套件的彻底性对读者可见,直接服务于 Easy to understand。
- 直观解释(”它是什么?”):测试类顶部的注释就是这份测试套件的「说明书」,读它的人不必逐个反推用例意图。
- 关键规则与最佳实践:
- 在测试类顶部的注释里写下划分与子域:
public class MaxTest {
/*
* Testing strategy
*
* partition:
* a < b
* a > b
* a = b
*/
}
- 每个测试方法上方写注释,说明它覆盖哪些子域:
// covers a < b。 - 大多数测试套件会有多个划分,大多数测试用例会覆盖多个子域,例如
// covers a = 1, b != 1, a and b have same sign。 - 策略注释写在测试类里,规格说明(
@return ...)写在被测类里,两者不要混。
黑盒测试与白盒测试(Black Box and Glass Box Testing)
- 定义与目的:黑盒测试只依据规格说明选择用例,不看实现;白盒测试(glass box testing) 借助对实现的了解来选择用例。前者保证测试正确性(不依赖实现细节),后者提高彻底性(覆盖实现特有的分支)。
- 直观解释(”它是什么?”):黑盒测试是「只看说明书办事」;白盒测试是「知道内部构造后专门去戳容易坏的地方」。
- 关键规则与最佳实践:
- 黑盒测试是测试优先编程的默认方式——你还没写实现,自然只能看规格。本讲的
abs、max、BigInteger.multiply例子全是黑盒测试。 - 白盒测试的常见触发点:实现会按输入选择不同算法(例如
sort根据values.size()在radixSort/quickSort/mergeSort之间切换,于是 0、9、10、1e9 这些阈值附近成为新边界);实现有内部缓存(于是要测试重复输入);内部表示有结构(例如BigInteger用十进制数字数组表示,则「一位数相乘」与「多位数相乘」是不同子域)。 - 白盒测试的纪律:绝不能要求规格说明没有承诺的实现行为。若规格说明写「输入格式错误时抛出异常」,测试就不应该具体断言
NullPointerException,因为规格允许任何异常——否则你就限制了实现者的自由,测试也不再「正确」。
- 黑盒测试是测试优先编程的默认方式——你还没写实现,自然只能看规格。本讲的
代码覆盖率(Coverage)
- 定义与目的:覆盖率用来判断测试套件执行程序的充分程度,是白盒测试的量化工具。它服务于 Safe from bugs(找出没测到的代码)与 Easy to understand(让测试缺口可视化)。
- 直观解释(”它是什么?”):覆盖率工具把每一行标成绿色(执行过)、红色(没执行过)、黄色(分支只走了一个方向)。
- 关键规则与最佳实践:
- 语句覆盖(statement coverage):每个语句都被某个用例执行过吗?
- 分支覆盖(branch coverage):每个
if/while的真假两个方向都被走过吗? - 路径覆盖(path coverage):每条可能的路径(所有分支组合)都被走过吗?
- 强度关系:分支覆盖强于语句覆盖,路径覆盖强于分支覆盖。工业界常见目标是 100% 语句覆盖,但由于「不该到这里」这类不可达的防御性代码,实际上很少达到;100% 分支覆盖非常可取;安全关键行业有更严苛的标准(如 MC/DC,修正条件/判定覆盖);100% 路径覆盖不可行,需要指数规模的测试套件。
- 标准做法是:先写黑盒测试,再测量覆盖率,然后针对红色与黄色的代码补测试用例。Java 生态常用工具是 Eclipse 的 EclEmma(Run → Coverage As),TypeScript 生态是 Istanbul/nyc。
- 以
Hailstone为例:初始n = 3时序列为 3, 10, 5, 16, 8, 4, 2, 1,n = n / 2这行会被执行(绿色);初始n = 16时序列是 16, 8, 4, 2, 1,全是偶数,n = 3 * n + 1这行永远不会执行(红色);初始n = 1时循环体一次都不执行,整个循环体都是红色,而while (n != 1)这一行是黄色(条件永远为真、从未取过假方向)。
单元测试与集成测试(Unit and Integration Testing)
- 定义与目的:单元测试隔离地测单个模块;集成测试(integration test) 测多个模块的组合甚至整个程序。二者互补:前者定位快,后者能发现模块之间的连接问题。
- 直观解释(”它是什么?”):单元测试是「单独尝一尝每道工序的成品」;集成测试是「把整道菜端上桌尝一尝」。做披萨时,单独烤一张饼皮看它够不够脆 —— 单元测试;尝一口用现成香料做的酱 —— 单元测试;把酱和配料铺在饼皮上一起烤,看饼皮在潮湿配料下还能不能烤好 —— 集成测试。
- 关键规则与最佳实践:
- 隔离测试让调试容易得多:单元测试失败时,你可以更有信心 bug 就在这个模块里。
- 只有集成测试时,测试一旦失败,bug 可能在任何地方,必须到处排查。
- 典型错误:测试
extract()时先用load()把文件读进来,再把结果传给extract()。这不是extract的单元测试——失败时你无法判断是load还是extract有 bug。 - 正确做法:把文件内容写成字面量字符串直接传给
extract()。使用「贴近真实文件内容」的划分是合理的(因为这才是extract的实际用法),但不要真的调用load。 - 对
index()这类高层模块,隔离很难:调用index就同时测试了它内部调用的所有函数。这就是为什么必须先有load和extract的独立单元测试——它们让你能心安理得地把 bug 定位在index的连接逻辑里。 - 真要隔离高层模块,可以为它调用的模块写桩(stub):例如
load的桩完全不访问文件系统,无论传入什么File都返回固定的模拟内容。类的桩常被称为 mock object。桩是构建大型系统的重要技术,但 6.031 一般不用它。
自动化回归测试(Automated Regression Testing)
- 定义与目的:自动化测试指自动运行测试并检查结果;回归测试指每次改动代码后重跑全部测试。二者结合是现代软件工程的最佳实践。
- 直观解释(”它是什么?”):自动化测试是「一键跑完所有检查」;回归测试是「每次动完代码就按一下那个按钮」,防止「修好一个 bug、引入两个新 bug」。
- 关键规则与最佳实践:
- 测试驱动(test driver / test harness / test runner) 不应是交互式程序(不能提示你输入、打印结果让你肉眼判断),而应当在固定的测试用例上调用模块并自动检查结果,输出「all tests OK」或「these tests failed: …」。JUnit 让你构建这样的驱动器。
- 自动化框架让「运行」变容易,但测试用例仍要你自己想——自动生成测试用例仍是活跃的研究课题。
- 每当你发现并修复一个 bug,就把引发它的输入加入测试套件,这叫作回归测试用例(regression test)。一个好测试的标准就是「它能引出一个 bug」——而每个回归测试都确实曾在某个版本上引出过 bug。
- 回归测试还能防止回退(reversion):这个 bug 既然犯过一次,就很可能再犯。
- 这引出了测试优先调试(test-first debugging):bug 一出现,立刻写一个能引出它的测试用例并加入套件;修好之后,所有测试通过,调试结束,同时你永久得到了一个回归测试。
- 值得重跑全部测试的时机:
git add/commit/push之前、为了提高性能重写函数之后、使用覆盖率工具时、以及你认为自己修好了一个 bug 之后。
迭代式测试优先编程(Iterative Test-First Programming)
- 定义与目的:有效的软件工程不是线性流程。要在每一步都准备好回头修改前面的成果。
- 直观解释(”它是什么?”):规格说明、测试、实现三者互相校验,像三角架一样彼此支撑;任何一条腿发现问题,都要回去调整另外两条。
- 关键规则与最佳实践:
- 三步:写规格 → 写测试(发现问题就回头改规格与测试)→ 写实现(发现问题就回头改规格、测试与实现)。
- 写测试是理解规格的好方法:规格可能不正确、不完整、有歧义、漏掉边界情况;写测试能在你为错误规格浪费时间写实现之前发现这些问题。
- 反过来,写实现也可能让你发现测试缺失或错误,或促使你回过头修改规格。
- 因此不要在某一步追求完美再前进:大规格先写其中一部分,测它、实现它,再扩展;复杂测试套件先选几个重要划分做小套件,实现一个简单版本通过它,再逐步增加划分;棘手实现先写一个简单粗暴的版本(如用线性查找代替二分查找)来验证规格与测试,然后再写难的那个。
- 迭代需要不同于「一次做对」的心态:尽快得到一个粗略可用的解,然后持续精化,以便必要时推翻重做。这在问题困难、解空间未知时最省时间。
代码示例与对比分析
场景 1:交互式手动检查 vs. 自动化 JUnit 测试
❌ 错误代码
import java.util.List;
import java.util.ArrayList;
// 错误:靠打印结果、用肉眼判断的「测试」
public class HailstoneMain {
public static List<Integer> hailstoneSequence(int n) {
List<Integer> list = new ArrayList<>();
while (n != 1) {
list.add(n);
if (n % 2 == 0) {
n = n / 2;
} else {
n = 3 * n + 1;
}
}
list.add(n);
return list;
}
public static void main(String[] args) {
System.out.println(hailstoneSequence(3)); // 人工看一眼:好像是对的
}
}
【错误代码的问题】
- 结果需要人眼判断:没有断言,没有「通过/失败」,无法在 CI 中自动运行。
- 不可重复、不可回归:改代码后必须有人记得再跑一次并再看一眼,而人会忘记。
- 一旦输出变长(如 n=27 的 112 项),肉眼比对事实上不可行,人会「看上去差不多就放过」。
- 无法表达「预期输出行为」:规格说明中「以 n 开始、以 1 结束」这类约束完全没有被检查,只检查了这一组输入恰好产生了这一串数字。
✅ 正确代码
import java.util.List;
import java.util.ArrayList;
import java.util.Arrays;
import org.junit.Test;
import static org.junit.Assert.assertEquals;
public class HailstoneTest {
/*
* Testing strategy
*
* partition on n:
* n = 1
* n = 2
* n is an odd number > 1 (so the 3n+1 rule is used at least once)
* n is an even number > 2
* n is a power of 2 (the sequence decreases monotonically)
*/
@Test
public void testStartsAtOne() {
// covers n = 1
assertEquals(Arrays.asList(1), Hailstone.hailstoneSequence(1));
}
@Test
public void testStartsAtTwo() {
// covers n = 2
assertEquals(Arrays.asList(2, 1), Hailstone.hailstoneSequence(2));
}
@Test
public void testOddStart() {
// covers n is an odd number > 1
assertEquals(Arrays.asList(3, 10, 5, 16, 8, 4, 2, 1),
Hailstone.hailstoneSequence(3));
}
@Test
public void testPowerOfTwo() {
// covers n is a power of 2
assertEquals(Arrays.asList(16, 8, 4, 2, 1),
Hailstone.hailstoneSequence(16));
}
}
【为什么这样更好】 每个测试方法都把自己的预期写成可执行的断言,运行结果是二值的(通过/失败),可以一键重复执行、可以进入持续集成、可以在每次改动后作为回归测试。测试类顶部的策略注释让读者立刻看到划分与覆盖范围,无需反推每个用例的意图。而且一旦实现被改坏,失败信息会精确告诉你哪一条划分出了问题。
【代码对比解说】 main 加 println 的模式并非毫无价值——它适合探索性的临时试验。但它不是测试:它没有预期值,因此无法自动判断对错。这里的关键跃迁是把「我知道正确答案是什么」这一人类知识,固化成机器可检查的断言。注意 assertEquals(expected, actual) 的参数顺序:Arrays.asList(2, 1) 是期望值,Hailstone.hailstoneSequence(2) 是实际值,写反会让失败消息变成误导人的形式。
【设计原则透视】 测试驱动是自动化的具体形态,对应「Nothing makes tests easier to run, and more likely to be run, than complete automation」。从设计上看,这段测试同时展示了「正确」(只断言规格说明承诺的行为:以 n 开始、以 1 结束、中间项符合递推)与「彻底」(覆盖了偶数、奇数、幂次、n = 1 的边界)。至于 assertEquals 对 List 的比较:补充说明,JUnit 的 assertEquals 对 List 依赖 AbstractList.equals 的逐元素比较,因此上面的写法可用;对数组则必须改用 assertArrayEquals,因为数组的 equals 就是引用相等——这正是 Reading 15(相等性)要讲的内容。
场景 2:assertEquals 参数顺序写反
❌ 错误代码
import org.junit.Test;
import static org.junit.Assert.assertEquals;
// 错误:把实际值写在第一个参数,把期望值写在第二个
public class AbsTestBuggy {
@Test
public void testNegative() {
int result = Math.abs(-3);
assertEquals(result, 3); // actual 在前,expected 在后
}
@Test
public void testNegativeIntMin() {
int result = Math.abs(Integer.MIN_VALUE);
assertEquals(result, Integer.MIN_VALUE); // 边界:abs 可以返回负数!
}
}
【错误代码的问题】
- JUnit 的失败信息格式是「expected:
but was: 」,参数写反后 X 与 Y 互换,**失败信息会误导**你去找相反的方向。 - 对于「期望值和实际值恰好类型相同但语义相反」的场景(如
assertEquals(result, 3)中result与3都是int),编译器完全无法提醒,错误只能靠纪律避免。 - 违反「所有比较值的 JUnit 断言都遵循 expected 在前」这一统一约定,让测试代码难以被快速扫读。
- 第二个用例把
Integer.MIN_VALUE当成期望值写在后面,掩盖了它其实是边界测试这一重要意图。
✅ 正确代码
import org.junit.Test;
import static org.junit.Assert.assertEquals;
public class AbsTest {
@Test
public void testPositive() {
// covers 0 < a < Integer.MAX_VALUE
assertEquals(17, Math.abs(17));
}
@Test
public void testNegative() {
// covers Integer.MIN_VALUE < a < 0
assertEquals(3, Math.abs(-3));
}
@Test
public void testZero() {
// covers a = 0
assertEquals(0, Math.abs(0));
}
@Test
public void testIntMinValue() {
// covers a = Integer.MIN_VALUE
// the spec permits a negative result at this boundary
assertEquals(Integer.MIN_VALUE, Math.abs(Integer.MIN_VALUE));
}
@Test
public void testIntMaxValue() {
// covers a = Integer.MAX_VALUE
assertEquals(Integer.MAX_VALUE, Math.abs(Integer.MAX_VALUE));
}
}
【为什么这样更好】 期望值在前、实际值在后,是 JUnit 所有比较断言的统一约定;失败信息因此永远读作「我期待 X,但实际得到 Y」,方向正确、可读性强。此外,这组测试把 Math.abs 的五子域划分完整地写了出来(Integer.MIN_VALUE、负区间、0、正区间、Integer.MAX_VALUE),每个方法上方注明它覆盖哪个子域,彻底性一目了然。
【代码对比解说】 参数顺序问题看似琐碎,实则是「可诊断性」问题:测试失败时你需要的信息不是「程序算错了」,而是「算成了什么、应该是什么」。当测试套件有几百个用例时,一个方向被反转的失败消息可能让你浪费半小时。另外注意 testIntMinValue 这个用例:Math.abs(Integer.MIN_VALUE) 返回一个负数,这是 Java 规格说明明确写出的行为(两补码表示导致 -2³¹ 的相反数超出 int 范围),一个凭直觉写出的「abs 永远非负」的测试会在这里失败——而这正是边界值测试的价值:它把「直觉」和「规格」的差异逼出来。
【设计原则透视】 这组对比与 Reading 01 的分界线呼应:assertEquals 的参数顺序属于「类型系统无法检查的约定」,只能靠纪律与代码审查(Reading 04)守护。同时,五个子域构成的划分展示了「正确」与「彻底」的平衡——它既没有写成穷举(不可能穷举 2³² 个 int),也不是随意挑几个值,而是围绕规格说明中行为发生变化的地方取样,并显式包含了两个极端边界。
(sp22 原版 TypeScript 写法对照:Mocha 的断言顺序正好相反)
// sp22 原版 TypeScript 写法(Mocha)
import assert from 'assert';
describe("Math.abs", function() {
/*
* Testing strategy
* partition: a < 0; a = 0; a > 0
*/
it("covers a < 0", function() {
const result: number = Math.abs(-3);
assert.strictEqual(result, 3); // 注意:第一个参数是 actual,第二个才是 expected
});
it("covers a = 0", function() {
assert.strictEqual(Math.abs(0), 0);
});
});
把这两种框架并排看:JUnit 是 assertEquals(expected, actual),Mocha 是 assert.strictEqual(actual, expected)——参数顺序完全相反。这是本讲跨语言时最容易出错的一个细节。另外注意 TypeScript 中的 number 无法表示 Integer.MIN_VALUE 那种边界(它是 64 位浮点),所以在 sp22 中 Math.abs 的边界讨论以 Number.MAX_SAFE_INTEGER 等常量呈现,而 Java 中则有 Integer.MIN_VALUE 这个「abs 会返回负数」的著名特例。
场景 3:断言信息不足——随机结果如何正确断言
❌ 错误代码
import java.util.Set;
import org.junit.Test;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
// 错误:对「有多个正确答案」的函数写出过强或过弱的断言
public class PickRandomlyTestBuggy {
@Test
public void testDrawFromSet() {
Set<Integer> set = Set.of(293, 384, 10, 5, -3, 99);
int result = pickRandomly(set);
assertEquals(-3, result); // 断言了一个实现未承诺的具体结果
}
@Test
public void testDrawFromSetNoMessage() {
Set<Integer> set = Set.of(293, 384, 10, 5, -3, 99);
int result = pickRandomly(set);
assertTrue(set.contains(result)); // 正确但失败时毫无线索
}
}
【错误代码的问题】
assertEquals(-3, result)断言了规格说明没有承诺的行为:pickRandomly只承诺「返回集合中的某个元素」,任何合法实现都可能在这次调用中返回 293。这让测试不正确——它会拒绝合法实现。- 更糟的是,这种测试有时会「碰巧通过」(当实现恰好返回 -3 时),于是它既不可靠又给人虚假的安全感。
assertTrue(set.contains(result))本身是正确的断言,但失败时只告诉你「断言失败」,不告诉你实际收到了什么值、期望来自哪个集合——调试时毫无线索。- 缺少对「返回值来自给定集合」这一关键性质的清晰表达,读者需要自己去推断测试意图。
✅ 正确代码
import java.util.Set;
import org.junit.Test;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
public class PickRandomlyTest {
/*
* Testing strategy
*
* partition on set:
* the set has exactly one element (only one legal answer)
* the set has more than one element (multiple legal answers)
*/
@Test
public void testSingleElementSet() {
// covers the set has exactly one element
Set<Integer> set = Set.of(42);
assertEquals(42, pickRandomly(set)); // 唯一合法答案,可以精确断言
}
@Test
public void testDrawFromSet() {
// covers the set has more than one element
Set<Integer> set = Set.of(293, 384, 10, 5, -3, 99);
int result = pickRandomly(set);
assertTrue("expected result to be from " + set + " but actually was " + result,
set.contains(result));
}
}
【为什么这样更好】 对「有唯一合法答案」的子域用精确断言,对「有多个合法答案」的子域用性质断言(assertion on a property),并在消息里同时打印期望的来源集合与实际得到的值。这样测试既正确(不接受任何非法实现,也不拒绝任何合法实现),又在失败时可诊断。
【代码对比解说】 这里体现的是一条重要原则:断言的强度必须恰好等于规格说明的承诺强度。断言过强(assertEquals(-3, result))会拒绝合法实现,让测试变成规格的额外约束;断言过弱(什么都不查)则抓不到 bug。带消息的 assertTrue 是恰当的中间地带:它只表达规格真正承诺的性质,同时提供足够的调试信息。补充说明:JUnit 5 提供 assertAll 可以把多条性质断言聚合成一次报告,避免「修好第一条才发现第二条也失败」的往返。
【设计原则透视】 这条原则直接对应「正确(Correct)」的定义——测试套件是规格说明的合法客户,它必须接受所有合法实现。这也解释了为什么测试只依赖规格说明是如此重要:一旦测试依赖实现细节,实现就无法自由变化,「Ready for change」随之丧失。这条线索将在 Reading 06(规格说明)中被形式化为「规格说明同时约束客户与实现者」的契约思想。
场景 4:不是真正的单元测试——测试之间发生耦合
❌ 错误代码
import java.io.File;
import java.util.List;
import org.junit.Test;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
// 错误:测试 extract() 时先调用 load(),两个模块被绑在一起
public class ExtractorTestBuggy {
@Test
public void testExtractWords() {
File file = new File("testdata/document.txt");
String doc = load(file); // 依赖另一个模块!
List<String> words = extract(doc);
assertTrue(words.contains("hello"));
assertEquals(3, words.size());
}
}
【错误代码的问题】
- 失败时无法定位:bug 可能在
load(文件缺失、编码错误、路径拼接错),也可能在extract。 - 测试依赖文件系统状态:换一台机器、换个工作目录、文件被改动,测试就失败——脆弱且不可移植。
- 它不是
extract的单元测试,而是load+extract的集成测试,只是被误当成了单元测试。 - 覆盖率的意义被削弱:
extract的分支覆盖情况被load的行为遮蔽(例如文件读不到时extract根本没被调用)。
✅ 正确代码
import java.io.File;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.Test;
import static org.junit.Assert.assertEquals;
// load 的测试:单独处理文件输入,不牵涉 extract
public class LoadTest {
/*
* Testing strategy
*
* partition on file:
* the file is empty
* the file has exactly one line without a trailing newline
* the file has multiple lines, with a trailing newline
*/
@Test
public void testEmptyFile() throws Exception {
// covers the file is empty
Path p = Files.createTempFile("load-test", ".txt");
assertEquals("", load(p.toFile()));
}
@Test
public void testOneLine() throws Exception {
// covers the file has exactly one line without a trailing newline
Path p = Files.createTempFile("load-test", ".txt");
Files.writeString(p, "hello");
assertEquals("hello", load(p.toFile()));
}
}
import java.util.List;
import java.util.Arrays;
import org.junit.Test;
import static org.junit.Assert.assertEquals;
// extract 的测试:输入是字面量字符串,完全不碰文件系统
public class ExtractTest {
/*
* Testing strategy
*
* partition on s:
* s is empty
* s has no words (only whitespace and punctuation)
* s has one word
* s has several words separated by whitespace and punctuation
* s starts or ends with punctuation
*/
@Test
public void testEmptyString() {
// covers s is empty
assertEquals(List.of(), extract(""));
}
@Test
public void testNoWords() {
// covers s has no words
assertEquals(List.of(), extract(" ,;. "));
}
@Test
public void testSeveralWords() {
// covers s has several words separated by whitespace and punctuation
assertEquals(List.of("hello", "world", "again"),
extract("hello, world! again"));
}
}
【为什么这样更好】 extract 的测试把输入写成字面量字符串,与文件系统彻底解耦:测试可移植、运行快、失败时你几乎可以确定 bug 就在 extract 里。load 有它自己的单元测试,专门覆盖空文件、单行、多行等划分。而对 index,我们再写一组(不可隔离的)测试来验证「连接逻辑」——因为前置的两个模块已经被各自验证过了,index 测试失败时我们可以把注意力集中在它调用各模块的方式上。
【代码对比解说】 关键区别在于测试用例的输入从哪里来。使用「贴近真实文件内容」的划分是完全合理的——因为那才是 extract 的真实用法;但不合理的是真的去调用 load。把自己的模块从依赖中解放出来,是单元测试的核心纪律。补充说明:当确实无法避免依赖(如网络、时钟、数据库)时,可以为它们写桩(stub)或 mock object:一个不访问真实外部资源、总是返回固定内容的替身。桩在大型系统中很重要,但 6.031 一般不用它。
【设计原则透视】 这一组对比把「单元测试 vs. 集成测试」的分工讲清楚了:单元测试负责把 bug 局部化到模块内部,集成测试负责发现模块之间连接处的 bug(例如 index 期待 extract 返回 List<String> 却拿到别的东西)。两者缺一不可,但顺序很重要——先有可靠的单元测试,集成测试失败时你才有信心把 bug 定位在连接逻辑上。这正对应 Reading 06/07 的抽象边界思想:模块之间的契约(规格说明)是连接处正确性的依据。
场景 5:遗漏边界值——gcd 的划分不完整
❌ 错误代码
import org.junit.Test;
import static org.junit.Assert.assertEquals;
// 错误:只测「正常」输入,忽略了边界与负值
public class GcdTestBuggy {
@Test
public void testBothPositive() {
assertEquals(6, gcd(54, 24));
}
@Test
public void testAnotherPair() {
assertEquals(1, gcd(17, 5));
}
}
【错误代码的问题】
- 划分不完整:只覆盖了「两个正整数且互质/不互质」这一小块,完全没有覆盖
x = 0、y = 0、负数、x = y、一个数整除另一个数等子域。 - 规格说明写的是「x 与 y 不同时为 0」,也就是说负数是被允许的,而负数正是实现最容易写错的地方(取模的符号语义)。
- 这个测试套件小但不彻底——它写起来很快,但几乎抓不到任何真实 bug,给人一种虚假的安全感。
- 没有测试策略注释,读者无从判断覆盖面,也无从判断该补哪些用例。
✅ 正确代码
import org.junit.Test;
import static org.junit.Assert.assertEquals;
public class GcdTest {
/*
* Testing strategy
*
* partition on (x, y):
* x and y are both positive
* x and y are both negative
* x and y have opposite signs
* x = 0 or y = 0 (but not both)
* x is divisible by y
* y is divisible by x
* x and y are relatively prime
* x = y
* boundary values: x = 0, y = 0, Integer.MIN_VALUE, Integer.MAX_VALUE
*/
@Test
public void testBothPositive() {
// covers both positive, x divisible by y
assertEquals(6, gcd(54, 24));
}
@Test
public void testRelativelyPrime() {
// covers relatively prime
assertEquals(1, gcd(17, 5));
}
@Test
public void testOneIsZero() {
// covers y = 0 (boundary)
assertEquals(7, gcd(7, 0));
}
@Test
public void testBothNegative() {
// covers both negative
assertEquals(6, gcd(-54, -24));
}
@Test
public void testOppositeSigns() {
// covers opposite signs
assertEquals(6, gcd(54, -24));
}
@Test
public void testEqualValues() {
// covers x = y, and y divisible by x
assertEquals(9, gcd(9, 9));
}
@Test
public void testBothAtIntMinValue() {
// covers the boundary Integer.MIN_VALUE
assertEquals(Integer.MIN_VALUE, gcd(Integer.MIN_VALUE, Integer.MIN_VALUE));
}
}
【为什么这样更好】 划分被完整地写出来并逐条覆盖,其中显式包含了 0、负号组合、相等、整除关系与 Integer.MIN_VALUE 这些最容易出 bug 的地方。测试策略注释让「彻底性」成为可见的事实:把划分与用例一一对照,就能看出有没有遗漏。注意最后一个用例:gcd(Integer.MIN_VALUE, Integer.MIN_VALUE) 的值是 -2³¹,而它的绝对值超出 int 范围——如果实现内部用了 Math.abs,这个用例会立刻暴露 bug。
【代码对比解说】 从两个用例到七个用例,覆盖的是行为差异而不是「更多数字」。第二组的价值不在于「测得更多」,而在于每一个新用例都对应一个实现可能采取不同代码路径的子域:符号处理、零值处理、整除分支。这正是「划分 + 边界」相对「多写几个用例」的本质优势。注意 gcd(7, 0) = 7 来自规格说明中「x 与 y 不同时为 0」的约定,而 gcd(0, 0) 是非法输入(无法用合法的测试用例覆盖),因此不应写成一个测试用例——那会违反「测试套件必须是规格说明的合法客户」这一正确性要求。
【设计原则透视】 这组对比完整演示了「正确、彻底、小」三性质的权衡:把 gcd(0, 0) 加进来会让套件不正确(它不是合法客户);只写两个正数用例会让套件不彻底;而七个用例是在彻底与「小」之间取得的平衡。边界值的存在还体现了一条设计直觉:规格说明中特别提到的行为,一定是最值得测试的行为(Math.abs(Integer.MIN_VALUE)、gcd(x, 0) 都是规格说明专门交代过的特例)。
场景 6:白盒测试越界——断言规格没有承诺的实现细节
❌ 错误代码
import org.junit.Test;
import static org.junit.Assert.fail;
// 错误:断言了一个具体的异常类型,而规格说明只承诺「抛出异常」
public class ParserTestBuggy {
@Test
public void testBadlyFormattedInput() {
try {
parse("(((");
fail("expected an exception");
} catch (NullPointerException e) { // 只接受 NPE
// ok
}
}
}
【错误代码的问题】
- 规格说明若只写「输入格式错误时抛出异常」,那么任何异常都是合法的;这个测试却只接受
NullPointerException,等于给实现者加了一条规格里没有的约束。 - 这让测试不正确:一个完全合法的实现(例如抛
IllegalArgumentException)会被判定为失败。 - 更隐蔽的是:如果实现先把输入解析成内部结构再检查,抛出的异常类型可能随内部重构而变化,测试随之「无故失败」,逼迫开发者去改测试而不是改实现。
- 它把白盒测试的信息(实现细节)固化进测试,直接损害 Ready for change。
✅ 正确代码
import org.junit.Test;
import static org.junit.Assert.fail;
public class ParserTest {
/*
* Testing strategy
*
* partition on input text:
* text is well-formed
* text is empty
* text has unbalanced parentheses
* text has illegal characters
*/
@Test
public void testWellFormed() {
// covers text is well-formed
// ... assert the parsed structure
}
@Test
public void testUnbalancedParentheses() {
// covers text has unbalanced parentheses
try {
parse("(((");
fail("expected an exception for unbalanced parentheses");
} catch (Exception e) {
// The spec permits any exception here, so we accept any.
// (glass box knowledge: the current implementation happens to
// throw IllegalArgumentException, but the spec does not promise it.)
}
}
}
【为什么这样更好】 测试只断言规格说明承诺的性质(「会抛异常」),因此接受任何合法实现,实现者保有选择异常类型的自由。白盒知识仍然有用,但它被写在注释里作为提示,而不是写成断言——这样当实现重构、异常类型改变时,测试依然正确。
【代码对比解说】 这是本讲第一处提醒:做白盒测试时必须小心,测试用例不能要求规格说明没有明确承诺的实现行为。白盒测试的合法用法是「用实现知识去选择用例」(例如知道 sort 在 size < 10 时用 radixSort,于是专门测试 9、10、0 这些阈值),而不是「用实现知识去写断言」。区分这两件事,是白盒测试不越界的关键。
【设计原则透视】 这组对比把「正确」这一性质的深层含义揭示出来:测试套件是规格说明的合法客户,而不是实现者的助手。这条原则与 Reading 06(规格说明)中的「规格说明同时约束客户与实现者」完全同构,也是「Ready for change」的技术基础——只要测试只依赖规格,实现就可以在规格允许的范围内任意演进。
与其他设计原则的关联
- 与 Reading 01(静态检查 / Static Checking):Reading 01 反复强调「静态检查抓不到与具体值相关的错误」,本讲正是填补这个空缺的系统方法——输入空间划分与边界值所覆盖的,恰恰是静态类型无法区分的那些具体值(除零、越界、
Integer.MIN_VALUE、空集合)。 - 与 Reading 02(基本 Java / Basic Java):本讲的测试代码大量使用集合与相等性;
assertEquals对List依赖equals,对数组则必须用assertArrayEquals,Set/Map作为键的元素必须可哈希——这些都指向 Reading 15(相等性)。 - 与 Reading 04(代码审查 / Code Review):本讲的「验证」包含代码审查这一手段;审查时检验「测试策略是否完整、断言强度是否恰当」是标准流程之一。
- 与 Reading 05(版本控制 / Version Control):自动化回归测试是版本控制工作流的一部分——提交前重跑全部测试,是本讲明确列出的最佳实践。
- 与 Reading 06(规格说明 / Specifications) 和 Reading 07(设计规格说明 / Designing Specifications):本讲的「正确测试套件 = 规格说明的合法客户」「白盒测试不得要求规格未承诺的行为」「前置条件与非法输入如何处理」都在那两讲被形式化。测试优先编程的「先写规格」一步也需要那两讲的写作技巧。
- 与 Reading 08(不可变性 / Immutability):本讲场景 3 的
Set.of(...)是不可变集合,能防止测试用例之间通过共享状态互相污染;不可变数据结构也天然让测试更容易推理。 - 与 Reading 09(避免调试 / Avoiding Debugging):测试优先调试(bug 一出现就写一个引出它的测试)是那一讲的核心建议在本讲中的具体形态。
- 与 Reading 10(抽象数据类型 / Abstract Data Types) 与 Reading 11(抽象函数与表示不变量 / Abstraction Functions & Rep Invariants):对 ADT 的测试不能只看单个方法,还要检验操作之间的交互与 RI 是否被维持——那是「更大模块」的测试策略。
- 与 Reading 13(调试 / Debugging):当单元测试失败时,如何系统地缩小范围、形成假设、验证假设,是调试那一讲的主题。
- 与 Reading 29(团队版本控制 / Team Version Control):持续集成(CI)在每次提交时自动运行回归测试,正是本讲「自动化回归测试」的工程化落地。
关键要点
- 先写规格与测试,再写实现:测试优先让你在还没「爱上」自己的代码时就戴上残酷的测试帽子,也让规格说明中的歧义与遗漏更早暴露。
- 用划分与边界代替穷举与随机:把输入空间切成不相交且完整的子域,从每个子域取一个代表,并把边界值写成单元素子域;多参数时优先用多个独立划分,再补一个捕捉交互的划分。
- 测试套件要满足正确、彻底、小:正确的核心是「只依赖规格说明、接受所有合法实现」;彻底意味着覆盖程序员可能犯的错;小意味着写得快、改得快、跑得快。
- 断言强度必须匹配规格承诺强度:有唯一答案时精确断言,有多个合法答案时断言性质并附上可诊断的消息;
assertEquals在 JUnit 中永远是「expected 在前,actual 在后」。 - 把测试策略写下来,让覆盖率指路,并让测试自动化地反复运行:划分注释让彻底性可见;覆盖率(语句/分支/路径)用来发现漏测,路径覆盖不可行而语句与分支覆盖是实用目标;单元测试隔离地测模块、集成测试测连接,每修好一个 bug 就把引发它的输入加入回归测试。
常见陷阱与注意事项
- 断言了规格没有承诺的实现行为(如只接受
NullPointerException、断言随机函数返回某个具体值)。→ 后果:测试拒绝合法实现,实现者被迫为了通过测试而写「迎合测试」的代码,Ready for change 彻底丧失。 - 把集成测试当单元测试用(测试
extract时先调用load)。→ 后果:测试失败时无法定位 bug,且测试依赖文件系统等外部状态,脆弱且不可移植。 - 边界值漏测或划分不完整:只测「正常」输入,漏掉 0、空集合、
Integer.MIN_VALUE/MAX_VALUE、字符串首尾元素;或写出Math.max只有a < b; a > b而丢掉a = b、以及要求非负参数却划出x < 0子域这类不合格划分。→ 后果:off-by-one、符号处理、溢出这些最常见的 bug 全部漏网(Math.abs(Integer.MIN_VALUE)就是典型),或者写出无法用合法输入覆盖的用例。 assertEquals参数顺序写反。→ 后果:失败信息中的 expected/actual 互换,调试方向被误导,且编译期毫无提示。- 在迭代中修改集合来「构造」测试输入,或让多个测试用例共享同一个可变集合。→ 后果:测试之间互相污染,出现「单独跑通过、一起跑失败」的诡异现象(对应 Reading 02 的迭代陷阱,建议使用
Set.of/List.of等不可变字面量)。 - 把「100% 语句覆盖率」当成质量保证。→ 后果:覆盖率高但断言贫弱(「执行了但没检查」)的测试套件会给出虚假的安全感;覆盖率是发现遗漏的工具,不是正确性的证明。
思考题(带答案)
问题 1:下面这个函数的规格说明是「返回数组所有元素的布尔与」,实现如下。请指出它的 bug,说明为什么「随意挑选的测试用例」很可能发现不了它,并给出一个能发现它的、基于划分的测试套件。
/**
* @param bits an array of 32 true/false values
* @return the Boolean AND of all values in the array
*/
boolean andAll(boolean[] bits) {
boolean result = bits[0];
for (int i = 1; i < 31; i++) {
result = result && bits[i];
}
return result;
}
答案:bug 在循环条件 i < 31——它漏掉了下标 31 的元素(off-by-one),正确应为 i < bits.length(或 i < 32)。这个 bug 属于「只在特定输入上出现」的类型:如果数组的第 32 个元素恰好与前面所有元素的与相同(例如全是 true,或最后一个是 true),结果就是正确的;只有在第 32 个元素为 false 而前面全为 true 时才会出错。所以「随意挑几个用例」(如全 true、交替真假)都可能碰巧给出正确结果。穷举测试需要 2³² 个用例,随机测试发现的概率约为 1/2³²(只有「前 31 个全真且第 32 个为假」这一个输入会失败),几乎为零。基于划分的测试套件应包含:bits 全为 true(覆盖「与为真」的子域)、bits 全为 false、前 31 个为 true 而第 32 个为 false(关键边界:决定结果的是最后一个元素)、第 1 个为 false 其余为 true(覆盖 bits[0] 就决定结果的路径)、以及交替真假。其中「最后一个元素为 false、其余全 true」这一条来自 边界值 分析(序列的第一个与最后一个元素是经典边界),也是唯一能揪出该 bug 的用例。这个例子同时说明:测试的价值来自划分与边界,而不来自用例数量。
问题 2:某同学为 sort(List<Integer> values) 写了测试策略注释:「partition on values: 长度为 0、长度为 1、长度小于 10、长度在 10 到 10⁹ 之间、长度大于 10⁹」。请评估这个划分,并说明它属于黑盒测试还是白盒测试。如果他是先写的测试(测试优先编程),这个划分可能吗?
答案:这个划分不相交且完整(任何列表长度恰好落入其中一个子域),但它显然来自白盒测试——因为 10 与 10⁹ 这两个阈值只在实现里才存在:实现根据 values.size() 在 radixSort(< 10)、quickSort(< 10⁹)、mergeSort(否则)之间切换。单纯看规格说明(「把列表按非递减顺序原地排序」),没有任何理由认为 9 和 10、10⁹-1 和 10⁹ 是行为分界点。因此后两个子域在长度 > 10⁹ 时无法用合法测试用例覆盖(现实中造不出这么大的列表),按「划分必须正确」的标准,它们是不合格的子域——至少在实践中不合格。如果是先写测试(测试优先编程),这个划分不可能产生:那时还没有实现,无从知道算法切换点,只能依据规格写出「空列表 / 单元素 / 多个元素 / 已排序 / 逆序 / 含重复元素」这类黑盒子域。这正说明两种测试的恰当分工:测试优先阶段做黑盒测试(只依赖规格、保证正确性),实现完成后再用白盒知识补充用例(借助覆盖率找出实现特有的分支),但补充时必须确保这些用例仍然只用合法输入,且不断言规格未承诺的行为。
问题 3:某项目中所有测试都是集成测试(整个程序端到端跑一遍),而且都通过了。为什么这门课仍然坚持要为每个模块写单元测试?请结合「代码覆盖率」与「回归测试」各给出一条理由。
答案:第一,覆盖率与彻底性:端到端测试往往只走到每段代码的一条主要路径,load 的文件缺失分支、extract 的空字符串分支、index 的空文件集合分支几乎必然处于红色状态;而单元测试可以对每个模块独立地做划分与边界覆盖,把覆盖率补起来——模块内部的 bug 只有在直接以各种输入照射它时才会暴露。第二,定位与回归:集成测试失败时,bug 可能在程序任何地方,排查成本随模块数线性增长;而单元测试失败时你可以确信 bug 在该模块内,定位几乎不需要搜索。这两点叠加起来对「回归测试」尤其重要:本讲要求「每修好一个 bug,就把引发它的输入加入测试套件」,如果只有集成测试,那么这些回归测试运行慢、失败信息模糊,很难在每次提交前跑完,最终就没人跑了——自动化回归测试的前提是测试又小又快,而这正是单元测试的优势。当然,集成测试也不可或缺:模块之间的连接处(如 index 期待 extract 返回的格式与实际不符)只有集成测试能发现。二者互补:单元测试把 bug 局部化,集成测试守住边界。
