Chapter 2:Test-Driven Development: A First Example

对齐原书 Chapter 2:以 Soundex 类完成第一条端到端 TDD 主线,从单一失败例、小步实现和夹具出发,逐步加入补零、辅音编码、元音、长度与重复规则,再在全绿状态下提取单一职责。

学习目标

  • 能用 Soundex 类完成连续的红、绿、重构小循环,并说明每一步只新增了哪一个可观察行为
  • 能使用测试夹具和 setup 消除机械重复,同时保持每个测试独立、清晰地表达输入与期望
  • 能区分测试驱动与事后测试,通过长度、元音、重复编码和空输入边界发现设计责任与遗漏测试

机制总览

Chapter 2:Test-Driven Development: A First Example:机制路径

  1. 1

    为什么第一个例子要足够小,却不能只是加法

    2 + 2 == 4 能证明框架工作,却不会产生设计压力。Soundex 把英文姓名转换成首字母加三个数字的音码:规则不多,但包含字符分类、状态、长度边界和输出格式,足以展示测试如何逐步塑造实现。

  2. 2

    第一轮:只保留首字母并补零

    增量开发先写最窄的公开接口: Soundex::encode 接收字符串并返回字符串。例子 A - A000 同时包含两个行为,会不会太大?可以先让空实现返回固定值建立绿灯,再用第二个输入迫使实现读取首字母。关键是每次红灯只对应一个可解释缺口。

  3. 3

    每轮红、绿、重构都要有独立证据

    红灯不是“项目有错误”,而是刚写的测试以预期原因失败。如果测试因拼写错误不能编译,或另一条旧测试先失败,尚未证明新行为缺失。绿灯也不是只跑当前测试;必须确认全部旧行为仍成立。

先按顺序建立机制,再进入实验切换阶段并检查失效证据。

章级决策实验

Chapter 2:Test-Driven Development: A First Example:机制与证据

切换《Chapter 2:Test-Driven Development: A First Example》的三个关键教学阶段,先解释机制,再用运行与失败证据验证结论。

选择推理阶段

当前阶段 · 为什么第一个例子要足够小,却不能只是加法

2 + 2 == 4 能证明框架工作,却不会产生设计压力。Soundex 把英文姓名转换成首字母加三个数字的音码:规则不多,但包含字符分类、状态、长度边界和输出格式,足以展示测试如何逐步塑造实现。

可核验证据

保存红—绿—重构的最小提交与失败消息,用行为断言、替身交互和重复运行核对「为什么第一个例子要足够小,却不能只是加法」是否提供快速反馈。

学完《Chapter 2:Test-Driven Development: A First Example》后,应能从输入和前置条件推导状态变化,并用可重复的构建、运行或边界测试证明结果。

失效—证据矩阵

Chapter 2:Test-Driven Development: A First Example:失效与核验

为什么第一个例子要足够小,却不能只是加法

典型失效

若把「为什么第一个例子要足够小,却不能只是加法」退化成先写实现再补断言,测试会耦合内部步骤,既不能驱动设计,也无法稳定解释失败。

核验证据

保存红—绿—重构的最小提交与失败消息,用行为断言、替身交互和重复运行核对「为什么第一个例子要足够小,却不能只是加法」是否提供快速反馈。

第一轮:只保留首字母并补零

典型失效

若把「第一轮:只保留首字母并补零」退化成先写实现再补断言,测试会耦合内部步骤,既不能驱动设计,也无法稳定解释失败。

核验证据

保存红—绿—重构的最小提交与失败消息,用行为断言、替身交互和重复运行核对「第一轮:只保留首字母并补零」是否提供快速反馈。

每轮红、绿、重构都要有独立证据

典型失效

若把「每轮红、绿、重构都要有独立证据」退化成先写实现再补断言,测试会耦合内部步骤,既不能驱动设计,也无法稳定解释失败。

核验证据

保存红—绿—重构的最小提交与失败消息,用行为断言、替身交互和重复运行核对「每轮红、绿、重构都要有独立证据」是否提供快速反馈。

每个判断都必须能落到观测、测试或产物,不能只凭代码表面推测。

为什么第一个例子要足够小,却不能只是加法

2 + 2 == 4 能证明框架工作,却不会产生设计压力。Soundex 把英文姓名转换成首字母加三个数字的音码:规则不多,但包含字符分类、状态、长度边界和输出格式,足以展示测试如何逐步塑造实现。

本章采用原书示例的教学规则梯子,而不是一次宣称实现某个外部 Soundex 规范的全部变体。每轮只选择一个例子,先预测失败,再写最小代码,最后根据测试暴露的重复提取职责。

第一轮:只保留首字母并补零

增量开发先写最窄的公开接口:Soundex::encode 接收字符串并返回字符串。例子 A -> A000 同时包含两个行为,会不会太大?可以先让空实现返回固定值建立绿灯,再用第二个输入迫使实现读取首字母。关键是每次红灯只对应一个可解释缺口。

#include "Soundex.h"
#include <gtest/gtest.h>
 
TEST(SoundexEncoding, RetainsTheFirstLetter) {
    Soundex soundex;
    EXPECT_EQ("A000", soundex.encode("A"));
}
 
TEST(SoundexEncoding, PadsWithZerosToFourCharacters) {
    Soundex soundex;
    EXPECT_EQ("B000", soundex.encode("B"));
}

第一条测试允许最小实现暂时返回 "A000";第二条变红后,固定值不再成立,才需要读取输入首字母。不要在第一轮提前写完整辅音表,因为当前没有测试能区分正确映射与错映射。

std::string Soundex::encode(const std::string& word) const {
    return zeroPad(word.substr(0, 1));
}
 
std::string Soundex::zeroPad(const std::string& encoding) const {
    return encoding + std::string(4 - encoding.length(), '0');
}

此实现还假设输入非空且长度不超过 4。它不是最终安全实现,但在当前前置条件和测试下足够绿。未定义边界必须记入待办,不能悄悄忘记。

每轮红、绿、重构都要有独立证据

红灯不是“项目有错误”,而是刚写的测试以预期原因失败。如果测试因拼写错误不能编译,或另一条旧测试先失败,尚未证明新行为缺失。绿灯也不是只跑当前测试;必须确认全部旧行为仍成立。

重构阶段不添加新行为。可以提取 zeroPad、改善名字或消除测试重复;若要增加元音规则,先结束当前重构并重新进入红灯。把行为变化夹在重构中,会失去错误来自结构调整还是新规则的判断依据。

用夹具集中共同 setup,而不是隐藏意图

随着测试增加,每个用例都创建 Soundex soundex。GoogleTest 的 fixture 可以集中对象初始化,并在每个测试前创建新的 fixture 实例,避免状态在用例间泄漏。

class SoundexEncoding : public ::testing::Test {
protected:
    Soundex soundex;
};
 
TEST_F(SoundexEncoding, RetainsTheFirstLetter) {
    EXPECT_EQ("A000", soundex.encode("A"));
}
 
TEST_F(SoundexEncoding, EncodesBAsOne) {
    EXPECT_EQ("A100", soundex.encode("Ab"));
}

fixture 适合共享稳定对象,不适合把本测试的姓名和期望值塞进隐式全局状态。读者应在测试体中直接看到 "Ab""A100",否则失败时必须跳转 setup 才能理解行为。

一次只驱动一个编码规则

辅音映射可先从一个组开始:B/F/P/V -> 1。让 Ab -> A100 变红,再实现一个最小字符映射。随后用 Ac -> A200 迫使实现扩展到下一组。若第一条测试就输入包含所有组的长姓名,失败只说明“大结果不对”,无法指出哪条规则缺失。

char Soundex::encodedDigit(char letter) const {
    const std::string one = "BFPV";
    const std::string two = "CGJKQSXZ";
    const std::string three = "DT";
    const std::string four = "L";
    const std::string five = "MN";
    const std::string six = "R";
 
    const std::string groups[] = {one, two, three, four, five, six};
    for (std::size_t i = 0; i < std::size(groups); ++i) {
        if (groups[i].find(letter) != std::string::npos) {
            return static_cast<char>('1' + i);
        }
    }
    return '0';
}

代码先表达行为即可,之后可把映射数据改为静态表或 switch。重构是否值得,不看“更聪明”,而看是否减少重复、提高名字与边界可读性,并且全套测试持续为绿。

元音规则迫使扫描与输出分离

元音不产生编码数字。若实现把 encodedDigit 返回的 '0' 直接追加,Ae -> A000 可能偶然通过,因为最终输出本来就补零;这个例子没有区分“跳过元音”和“编码成零”。应加入元音夹在辅音之间的测试,例如 Aeb -> A100,并观察输出序列。

std::string Soundex::encode(const std::string& word) const {
    std::string encoding(1, word.front());
    for (auto it = std::next(word.begin()); it != word.end(); ++it) {
        const char digit = encodedDigit(*it);
        if (digit != '0') {
            encoding += digit;
        }
    }
    return zeroPad(encoding);
}

这个版本仍会生成超过四位的结果,也不会合并相邻同码。测试驱动不是要求一次写完,而是要求明确知道当前版本有哪些已证明行为和哪些未证明行为。

长度边界应改变控制流

输出最多四位,即首字母加三个数字。先写一个恰好产生三个数字的例子,再写第四个数字应被忽略的例子。第二条测试会迫使循环在编码完成后停止,而不是先生成任意长字符串再盲目补零。

constexpr std::size_t MaxCodeLength = 4;
 
for (auto it = std::next(word.begin());
     it != word.end() && encoding.length() < MaxCodeLength;
     ++it) {
    const char digit = encodedDigit(*it);
    if (digit != '0') {
        encoding += digit;
    }
}

当行为通过后再提取 isComplete(encoding),名字会比裸长度比较更直接。这个提取来自新规则产生的设计压力,而不是预先猜测未来需要多少 helper。

重复编码引入最小状态

相邻字母可能映射到同一数字,例如 B/F/P/V 都是 1。测试 Abfp -> A100 会让当前实现得到 A111。实现需要记住上一个有效编码,并只在新数字不同的时候追加;元音是否中断重复组,应由你采用的规则版本通过独立测试明确,而不是靠循环顺序偶然决定。

char lastDigit = '\0';
for (auto it = std::next(word.begin());
     it != word.end() && !isComplete(encoding);
     ++it) {
    const char digit = encodedDigit(*it);
    if (digit != '0' && digit != lastDigit) {
        encoding += digit;
    }
    if (digit != '0') {
        lastDigit = digit;
    }
}

这里出现了扫描、映射、去重、停止和补零五种责任。测试仍从公开 encode 驱动行为,但实现可以在绿灯下提取窄函数,让每个名字说明一条规则。

测试驱动与事后测试的差别在反馈时机

测试驱动与事后测试都能发现回归,但反馈时机不同:事后测试通常围绕已经存在的函数和分支书写,较难反向改善接口;测试驱动在实现前先问“调用者希望怎样表达行为”,因此 encode(word)、固定长度结果和错误契约会在代码结构冻结前得到反馈。

不要把差别简化成“先写测试就是 TDD”。若先在脑中设计并实现完整算法,只是把代码录入顺序改成测试文件在前,仍没有让每个红灯引导下一步。真正证据是提交或实验记录中能看到连续的小失败、小实现和全绿重构。

从辅助函数中提取单一职责

encode 同时承担五个动作,可以提取:head 保留首字母,encodedDigit 负责映射,isComplete 判断长度,isDuplicate 比较状态,zeroPad 负责输出格式。不是每行都做成函数,而是把不同变化原因命名并隔离。

std::string Soundex::encode(const std::string& word) const {
    std::string encoding = head(word);
    encodeTail(word, encoding);
    return zeroPad(encoding);
}

此时测试仍只依赖公开行为,内部 helper 可以自由重构。若测试直接调用每个 private helper,内部结构会被测试锁死;除非 helper 本身成为独立公共概念,否则优先通过外部结果证明它们协作正确。

清晰测试要说明规则,而不是复述实现

测试名应表达领域结果,例如 IgnoresVowelsStopsAfterThreeEncodedDigitsCombinesAdjacentDuplicateEncodings。名称 CallsEncodedDigitThreeTimes 绑定实现步骤,重构循环后即使行为不变也会失败。

断言失败消息应让人直接看到输入、期望与实际。参数化测试适合在规则稳定后压缩多个辅音映射案例;在发现新规则时,先保留单独测试,让每个例子清楚说明为什么存在。

主动列出尚未证明的测试

完成基本规则后检查:空字符串怎样处理,大小写是否统一,非 ASCII 字符是否允许,首字母后的 H/W/Y 如何影响相邻编码,输入包含标点时怎样处理。这些问题不应由 word.front() 或字符表偶然决定。

本章不需要一次决定所有外部规范,但必须把前置条件写清。例如暂时规定输入至少含一个 ASCII 字母,那么空输入测试应验证明确拒绝;若 API 承诺接受任意字符串,就要定义规范化和错误返回。遗漏清单是下一轮红灯来源,不是“以后再说”的垃圾桶。

分步1 / 3

第一步:沿规则梯子一次推进一个行为

从保留首字母与补零开始,再逐组加入辅音、元音、长度和重复规则;每次选择能区分正确与错误实现的最小例子。

小结

  • Soundex 类足够小,却能用字符映射、状态与长度边界产生真实设计反馈
  • 增量开发要求每个例子只引入一个可观察行为,旧测试持续保护已完成规则
  • 红灯、绿灯和重构各有独立证据;行为变化不能藏在结构重构中
  • 测试夹具集中共同 setup,但关键输入与期望应留在测试体中
  • 测试驱动在实现前影响接口和责任,事后测试主要验证已经形成的结构
  • 长度、元音、重复和空输入边界会暴露单一职责与遗漏测试

练习

  1. 问题 1:设计下一小步。 当前 encode("A") == "A000" 已通过,下一条测试应怎样避免让实现一次跨越太多规则?
  1. 问题 2:识别偶然通过。 为什么 Ae -> A000 不能单独证明元音被忽略?
  1. 问题 3:安排重构。 encode 已同时做扫描、映射、去重、长度判断和补零,何时以及怎样拆分?

名词解释

名词解释

本章出现的专业名词,用大白话再讲一遍。

Soundex 类
把姓名转换为固定长度音码、用来承载本章增量 TDD 规则的教学类。
增量开发
通过连续可验证的小行为扩展系统并保留旧证据的开发方式。
测试驱动
先写正确失败的测试,再写最小实现,让测试持续引导接口和设计。
红绿重构
正确失败、全部通过、在绿灯下改善结构的三阶段循环。
测试夹具
为每个测试提供独立共同对象和生命周期钩子的结构。
setup
每个测试前建立必要初始状态的轻量确定步骤。
一次一件事
每轮只引入一个能被例子区分的新行为。
边界规则
约束空输入、固定长度、跳过字符和重复状态等极端位置的行为。
测试驱动与事后测试
分别让失败测试在实现前引导设计,或在实现后验证既有结构的两种反馈时机。
单一职责原则
让模块或函数只有一个主要变化原因的设计原则。
遗漏测试
实现已有相关路径但测试尚不能区分其正确与错误行为的缺口。

原版目录概念补充核对

以下条目补齐官方目录中容易被示例主线掩盖的概念。它们不重复罗列目录,而是明确每项概念的机制、适用边界和验收证据。

单一职责原则:机制、边界与证据

在《Chapter 2:Test-Driven Development: A First Example》的官方单元 mctdd-02 中,单一职责原则连接本章第 5 组知识约束。学习时要同时说明它接受什么输入、改变什么状态、在何种边界失效;再以本章示例的编译诊断、固定输入输出或失败用例复核结论,不能只记术语名称。

讨论

评论区加载中…