Reading 3: 测试(Testing)

目录 · ← l2 · l4 →

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_VALUEInteger.MIN_VALUELong.MAX_VALUE)、集合类型的空值(空字符串、空列表、空集合)、序列的第一个与最后一个元素
    • 为什么边界容易出 bug:程序员常犯 off-by-one(写 <= 而不是 <、计数器初值写 0 而不是 1);某些边界需要被当作特例处理;边界是行为的不连续点。
    • 加入边界后,原来的子域要收缩以排除边界。以 Math.abs 为例,完整划分是:a = Integer.MIN_VALUEInteger.MIN_VALUE < a < 0a = 00 < a < Integer.MAX_VALUEa = Integer.MAX_VALUE,五个子域不相交且完整覆盖。
    • Java 的 Math.absInteger.MIN_VALUE 上有一个反直觉行为:规格说明明确写「若参数等于 Integer.MIN_VALUE,结果是同一个(负)值」。原因是 -2³¹ 的相反数 2³¹ 超出了 int 的范围。这类「规格里写明的怪行为」正是最该测的边界。
    • 对「字符串长度至多 5 且只含 ‘W’/’L’」的 winLossRatio,合适的边界包括 ""(空串)、"WWWWW"(全胜)、"LLLLL"(全负)。

自动化单元测试与 JUnit(Automated Unit Testing)

  • 定义与目的单元测试(unit test) 测试单个模块,尽可能隔离。自动化让测试真正被执行——没人愿意手动跑一百次。
  • 直观解释(”它是什么?”):把测试写成一个可执行的方法,断言(assert)期望结果;运行测试类,得到「全部通过」或「这些用例失败了:……」。
  • 关键规则与最佳实践
    • Java 使用 JUnit:测试方法用 @Test 注解标注,方法体内调用被测量模块,然后用 assertEqualsassertTrueassertFalse 等断言方法检查结果。
    • 参数顺序至关重要assertEquals第一个参数是期望值(expected),通常是个常量;第二个参数是实际值(actual),即代码真正算出来的东西。所有比较值的 JUnit 断言都遵循「expected 在前,actual 在后」。写反了会导致失败信息令人困惑。
    • TypeScript 对照(sp22 原版写法):Mocha 的 assert.strictEqual 顺序正好相反——第一个参数是 actual,第二个是 expected。这是从 TypeScript 切到 Java 时最容易搞错的一点。
    • 断言可以带可选的消息字符串作为最后一个参数,让失败信息更有用;一个测试方法里的断言失败后该方法立即返回,但其他 @Test 方法仍会运行
    • 对返回结构(集合、数组)的比较,需要注意 assertEquals 只对内置类型可靠;补充说明:JUnit 5 的 assertIterableEquals 可以比较可迭代对象的内容,而手写断言应当检查结构的关键性质(如「集合大小为 1 且包含 hello」)而不是逐元素比对。

记录测试策略(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) 借助对实现的了解来选择用例。前者保证测试正确性(不依赖实现细节),后者提高彻底性(覆盖实现特有的分支)。
  • 直观解释(”它是什么?”):黑盒测试是「只看说明书办事」;白盒测试是「知道内部构造后专门去戳容易坏的地方」。
  • 关键规则与最佳实践
    • 黑盒测试是测试优先编程的默认方式——你还没写实现,自然只能看规格。本讲的 absmaxBigInteger.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 就同时测试了它内部调用的所有函数。这就是为什么必须先有 loadextract 的独立单元测试——它们让你能心安理得地把 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));    // 人工看一眼:好像是对的
    }
}

【错误代码的问题】

  1. 结果需要人眼判断:没有断言,没有「通过/失败」,无法在 CI 中自动运行。
  2. 不可重复、不可回归:改代码后必须有人记得再跑一次并再看一眼,而人会忘记。
  3. 一旦输出变长(如 n=27 的 112 项),肉眼比对事实上不可行,人会「看上去差不多就放过」。
  4. 无法表达「预期输出行为」:规格说明中「以 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));
    }
}

【为什么这样更好】 每个测试方法都把自己的预期写成可执行的断言,运行结果是二值的(通过/失败),可以一键重复执行、可以进入持续集成、可以在每次改动后作为回归测试。测试类顶部的策略注释让读者立刻看到划分与覆盖范围,无需反推每个用例的意图。而且一旦实现被改坏,失败信息会精确告诉你哪一条划分出了问题。

【代码对比解说】 mainprintln 的模式并非毫无价值——它适合探索性的临时试验。但它不是测试:它没有预期值,因此无法自动判断对错。这里的关键跃迁是把「我知道正确答案是什么」这一人类知识,固化成机器可检查的断言。注意 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 的边界)。至于 assertEqualsList 的比较:补充说明,JUnit 的 assertEqualsList 依赖 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 可以返回负数!
    }
}

【错误代码的问题】

  1. JUnit 的失败信息格式是「expected: but was: 」,参数写反后 X 与 Y 互换,**失败信息会误导**你去找相反的方向。
  2. 对于「期望值和实际值恰好类型相同但语义相反」的场景(如 assertEquals(result, 3)result3 都是 int),编译器完全无法提醒,错误只能靠纪律避免。
  3. 违反「所有比较值的 JUnit 断言都遵循 expected 在前」这一统一约定,让测试代码难以被快速扫读。
  4. 第二个用例把 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)); // 正确但失败时毫无线索
    }
}

【错误代码的问题】

  1. assertEquals(-3, result) 断言了规格说明没有承诺的行为:pickRandomly 只承诺「返回集合中的某个元素」,任何合法实现都可能在这次调用中返回 293。这让测试不正确——它会拒绝合法实现。
  2. 更糟的是,这种测试有时会「碰巧通过」(当实现恰好返回 -3 时),于是它既不可靠又给人虚假的安全感。
  3. assertTrue(set.contains(result)) 本身是正确的断言,但失败时只告诉你「断言失败」,不告诉你实际收到了什么值、期望来自哪个集合——调试时毫无线索。
  4. 缺少对「返回值来自给定集合」这一关键性质的清晰表达,读者需要自己去推断测试意图。

✅ 正确代码

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());
    }
}

【错误代码的问题】

  1. 失败时无法定位:bug 可能在 load(文件缺失、编码错误、路径拼接错),也可能在 extract
  2. 测试依赖文件系统状态:换一台机器、换个工作目录、文件被改动,测试就失败——脆弱且不可移植。
  3. 它不是 extract 的单元测试,而是 load + extract集成测试,只是被误当成了单元测试。
  4. 覆盖率的意义被削弱: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));
    }
}

【错误代码的问题】

  1. 划分不完整:只覆盖了「两个正整数且互质/不互质」这一小块,完全没有覆盖 x = 0y = 0、负数、x = y、一个数整除另一个数等子域。
  2. 规格说明写的是「x 与 y 不同时为 0」,也就是说负数是被允许的,而负数正是实现最容易写错的地方(取模的符号语义)。
  3. 这个测试套件小但不彻底——它写起来很快,但几乎抓不到任何真实 bug,给人一种虚假的安全感。
  4. 没有测试策略注释,读者无从判断覆盖面,也无从判断该补哪些用例。

✅ 正确代码

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
        }
    }
}

【错误代码的问题】

  1. 规格说明若只写「输入格式错误时抛出异常」,那么任何异常都是合法的;这个测试却只接受 NullPointerException,等于给实现者加了一条规格里没有的约束。
  2. 这让测试不正确:一个完全合法的实现(例如抛 IllegalArgumentException)会被判定为失败。
  3. 更隐蔽的是:如果实现先把输入解析成内部结构再检查,抛出的异常类型可能随内部重构而变化,测试随之「无故失败」,逼迫开发者去改测试而不是改实现。
  4. 它把白盒测试的信息(实现细节)固化进测试,直接损害 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.)
        }
    }
}

【为什么这样更好】 测试只断言规格说明承诺的性质(「会抛异常」),因此接受任何合法实现,实现者保有选择异常类型的自由。白盒知识仍然有用,但它被写在注释里作为提示,而不是写成断言——这样当实现重构、异常类型改变时,测试依然正确。

【代码对比解说】 这是本讲第一处提醒:做白盒测试时必须小心,测试用例不能要求规格说明没有明确承诺的实现行为。白盒测试的合法用法是「用实现知识去选择用例」(例如知道 sortsize < 10 时用 radixSort,于是专门测试 9、10、0 这些阈值),而不是「用实现知识去写断言」。区分这两件事,是白盒测试不越界的关键。

【设计原则透视】 这组对比把「正确」这一性质的深层含义揭示出来:测试套件是规格说明的合法客户,而不是实现者的助手。这条原则与 Reading 06(规格说明)中的「规格说明同时约束客户与实现者」完全同构,也是「Ready for change」的技术基础——只要测试只依赖规格,实现就可以在规格允许的范围内任意演进。

与其他设计原则的关联

  • Reading 01(静态检查 / Static Checking):Reading 01 反复强调「静态检查抓不到与具体值相关的错误」,本讲正是填补这个空缺的系统方法——输入空间划分与边界值所覆盖的,恰恰是静态类型无法区分的那些具体值(除零、越界、Integer.MIN_VALUE、空集合)。
  • Reading 02(基本 Java / Basic Java):本讲的测试代码大量使用集合与相等性;assertEqualsList 依赖 equals,对数组则必须用 assertArrayEqualsSet/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 就把引发它的输入加入回归测试。

常见陷阱与注意事项

  1. 断言了规格没有承诺的实现行为(如只接受 NullPointerException、断言随机函数返回某个具体值)。→ 后果:测试拒绝合法实现,实现者被迫为了通过测试而写「迎合测试」的代码,Ready for change 彻底丧失。
  2. 把集成测试当单元测试用(测试 extract 时先调用 load)。→ 后果:测试失败时无法定位 bug,且测试依赖文件系统等外部状态,脆弱且不可移植。
  3. 边界值漏测或划分不完整:只测「正常」输入,漏掉 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) 就是典型),或者写出无法用合法输入覆盖的用例。
  4. assertEquals 参数顺序写反。→ 后果:失败信息中的 expected/actual 互换,调试方向被误导,且编译期毫无提示。
  5. 在迭代中修改集合来「构造」测试输入,或让多个测试用例共享同一个可变集合。→ 后果:测试之间互相污染,出现「单独跑通过、一起跑失败」的诡异现象(对应 Reading 02 的迭代陷阱,建议使用 Set.of/List.of 等不可变字面量)。
  6. 把「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 局部化,集成测试守住边界。