本节摘要:红灯阶段决定整个循环的质量——测试切得太厚,绿灯遥遥无期;切得太碎,测试堆成重复噪音。本节给出红灯阶段的完整操作规程:怎么从口语需求里挑出第一条断言、怎么命名、怎么验证失败原因,以及"一条测试只测一件事"的判据。读完你应能把任意一句需求切成厚度合适的失败测试。
「Red」在 TDD 的行话里不是坏消息,是开工令。听到同事说"这条红了",意思是需求的下个切片已经写成了断言,就等实现去兑现。行话能流传,是因为它把一个微妙的立场包装成了颜色:失败不是要消灭的对象,而是前进的路标。本节讲的就是怎么发出开工令——从一团口语需求里,切出下一条厚度合适、指向明确、真的会失败的测试。
承接第 1 章的密码校验器任务:产品经理的原话是"会员密码得有点强度要求"。这句话至少藏着五个行为:长度下限、必须含数字、必须含大写、必须含特殊字符、强度分级。红灯阶段最容易犯的错是把五个行为塞进一条测试——那样绿灯要一次跨五座山,中途没有任何反馈点。正确切法是挑最薄的第一个切片:哪条行为最小、最能逼出代码骨架,就从它开始。长度检查胜出,因为它是唯一不需要讨论字符集的规则。
# tests/test_password.py from shop.password import rate def test_reject_password_shorter_than_8_chars(): result = rate("aB1x") assert result.valid is False assert "长度不足" in result.reasons
三个细节值得放大。命名写成完整句子:test_reject_password_shorter_than_8_chars 读起来就是需求条目,测试报告因此能当文档看。断言只锁行为不锁结构:我们断言"无效"与"原因里提到长度",不断言内部字段怎么组织——结构是实现的自由。一条测试只含一组相关断言:这里两条断言是同一判断的正反面(判定与理由),不算多事;混进第三条断言别的行为就是越界。
写完测试立刻运行,看到红色不算完——要读失败信息是不是你预期的那种失败。上一节解剖过 pytest 输出的三段结构,这里不重复格式,只强调重演一遍的价值:如果红灯报的是 import 错误,说明骨架还没立,这条红灯验证的是"脚手架缺失"而不是"行为缺失",同样有效,但要心里有数。
> result = rate("aB1x") E ModuleNotFoundError: No module named 'shop.password' tests/test_password.py:2: ModuleNotFoundError
失败原因与预期一致(实现不存在),红灯有效。现在建最空的骨架让失败走到断言层:
# shop/password.py def rate(password: str): ...
再跑一次,红灯从 ModuleNotFoundError 变成 TypeError 或 AssertionError——失败点前进到了行为本身,这时的红才是"该实现而未实现"的红。红灯阶段的两段式失败(先环境后行为)很常见,分辨它们是基本功。

这张图是全册的发动机结构图,值得贴在显示器边上:三个阶段各占循环的三分之一弧长,暗示时间也要大致均分——红灯阶段花掉一小时、绿灯一分钟,就是切片没切好的信号。
test_加会员折扣后小计正确 比 test_discount_1 值钱得多——三个月后测试红了,前者让你秒懂是哪条商业规则被破坏了。⚠️ 反例警告:"测一切"的诱惑在红灯阶段最强。见到有人写出断言返回 JSON 里键顺序的测试,基本可以预言:这测试会在下次无关重构时变红,然后被同事删掉。断言只锁你愿意在未来被通知的事情。
事故一:测试永远绿。断言写反(!= 写成 == 后又改错方向)或测了个空操作。处置:写完测试先故意把断言改错验证它真的会红,再改回来。事故二:红灯吓人。一次断言失败带出十页堆栈,多半是异常没被边界捕获。处置:让被测代码在契约边界抛出明确的异常类型,测试用 pytest.raises 接住。事故三:红灯挂太久。半小时过不去,说明切片超过了一轮循环的容量。处置:把这条测试先注释存档,切个更薄的版本先过,再逐步逼近原目标——原测试就是下一轮的红灯。
收工前用五问快速自检,答不满就回到对应动作:这条测试的名字是不是一句需求?断言锁的是行为还是结构?失败原因与预期一致吗?一条测试里有没有混进第二个行为?它的前提是不是自带自灭?配合一张命名对照表,红灯阶段的产出质量立刻肉眼可见:
| 差命名 | 好命名 | 差别在哪 |
|---|---|---|
| test_rate_1 | test_reject_password_shorter_than_8_chars | 前者是编号,后者是需求句 |
| test_discount | test_member_discount_applies_10_percent_off | 缺少"谁、什么条件、什么结果" |
| test_error | test_negative_amount_raises_value_error | 说清异常类型与触发条件 |
| test_total_ok | test_empty_cart_total_is_zero | "ok"是废话,边界才是信息 |