第11章 测试驱动开发
第11章 测试驱动开发
学习目标
- 能说出 TDD 三阶段每一步要交付什么,并把验收测试、单元测试与 fake、mock 替身放到正确的测试层级。
- 能为一个新函数先写失败测试、再写最小实现、最后重构,让测试全程保持绿色。
- 能回答:为什么把复杂搭建代码留在 doctest 里,反而会让文档失效?
为什么先写测试再写代码
想象一条流水线:如果等整批产品做完才检查,发现问题时已经造了一仓库次品,只能整批报废。把检查前移到每一道工序之后,次品当场被发现、当场修掉,仓库里就不会堆积返工。
写代码也是同一个道理。先把“什么算成功”写成一段能自动判定的检查,再动手写代码,就等于给每一行代码配上当场把关。检查先失败、再被代码满足,你能确定每一行代码都是为了通过某条检查而存在,没有一行是“也许有用”的猜测。
没有这层当场把关,代码“在我电脑上能跑”只能证明它没在你电脑上崩溃过,不能证明它对别人、对没料到的输入、对将来还会对。本章把这些“当场把关”组织成可重复的流程。
先做预测
先预测:一段代码“在作者机器上跑通了”,能不能就算它可维护?不能。要可维护,还得钉死解释器和依赖版本、写清楚喂进去的输入和边界、留下失败时该报什么错,并给出能在干净机器上重放结果的命令。本章把这份契约放进每个测试循环里反复兑现。
原书骨架与现代迁移
官方章节围绕 TDD 原则、验收测试与单元测试、标准测试工具、Fake 与 Mock、文档驱动开发五条主线展开。Tarek Ziade 写作时 Python 还停在 2.5、敏捷工具生态也还在早期,所以本章只继承它解释“为什么这样测”的骨架;具体的命令与安全默认则改按当下的 Python、标准库和 PyPA 文档来迁移——不会把 EasyInstall、distutils 安装命令或老 CI 平台直接当成新项目的默认。
读的时候沿一条线追:输入是什么、约定了什么协议、状态怎么变、输出是什么、留下了什么证据。这样才不会只背 API 名字,却说不清一个测试到底证明了什么。
把“先失败再实现”的循环用代码钉下来,下面左右两栏看同一段测试逻辑的两种视角:
import unittest
class ParserTests(unittest.TestCase):
def test_rejects_empty_payload(self):
with self.assertRaises(ParseError):
parse(b"")RED 先写测试 —— 断言尚未实现的接口行为,运行必须失败
GREEN 最小实现 —— 写刚好让测试通过的代码,不多做
REFACTOR 重构 —— 消除重复与坏味道,测试保持绿色机制一:责任与数据流
先写会失败的行为示例,再写最小实现并重构——这种↡先写注定失败的测试,再写刚好让它通过的最小实现,最后在不破坏测试的前提下重构,循环推进循环驱动的是接口与反馈,不追求每行实现都先配一个脆弱断言。其中↡从用户能看到的入口出发,证明整条能力满足需求的测试从用户边界证明能力。与之配套的↡只盯一小段代码、快速验证局部规则的测试则快速验证局部规则。两者之间还需要集成测试覆盖数据库、文件与网络适配。先写出公共行为和失败类型,再选语法或工具,这样即使实现从原书工具迁到当前生态,调用者仍能依据同一份契约判断结果。
抽象不会消除成本,只会把它挪到别处:包装、生成器、构建系统、CI 或缓存都得讲清耗了什么资源、按什么顺序、异常怎么传播。
替身怎么选,先看↡提供简化但能正常工作的替身实现,记录内部状态后断言可见结果这种简化但能跑通的实现,下面左右两栏看同一段替身逻辑的两种视角:
class FakeStore:
def __init__(self): self.saved = []
def save(self, entries):
self.saved.extend(entries)
return len(entries)Fake 可工作替身 —— 记录状态,断言可见结果(saved 列表)
Mock 交互替身 —— 验证调用次数/参数/顺序,仅协议重要时用
优先级 先断言结果,再断言交互 —— 避免脆性测试机制二:失败与边界
unittest、doctest 和测试发现提供标准反馈环;原书的 nose 与早期 py.test 展示替代入口,现代迁移时要统一 fixture、参数化和失败报告。替身这边,fake 给出简化但能工作的实现,mock 验证特定交互;优先断言可见结果,只有协议本身重要时才锁定调用次数和顺序。边界实验至少包含正常、空输入、上限附近、依赖失败和重复执行;涉及网络、并发或外部制品时,再加超时、取消、部分完成与摘要校验。
老工具不是没有价值。正确的迁移是先抽出声明式配置、隔离、持续反馈和可回滚发布这些不变量,再用还在维护的接口重写;只机械替换命令、却留下隐式环境和不可追踪的副作用,是错误做法。
用↡把写在文档或函数说明里的示例真的跑一遍,输出对不上就报错,文档和测试是同一份把文档示例变成可执行证据,下面左右两栏看同一段文档驱动逻辑的两种视角:
def add(a, b):
"""Return a sum.
>>> add(2, 3)
5
"""
return a + b>>> add(2,3) 可执行示例 —— doctest 直接运行,输出不符即失败
docstring 同一来源 —— 文档与测试写在一起,不重复维护
适用场景 稳定小接口 —— 复杂搭建代码应移入测试模块机制三:证据闭环
这一节负责把验收收束起来。故事和 doctest 把可读示例变成可执行证据,适合稳定的小接口;复杂环境则应移到测试模块,免得文档被一堆搭建代码淹没。要让另一台干净机器重放出同一结论,就得保存解释器与依赖锁定、输入、命令、退出状态、关键输出和制品摘要。
先预测:把上面的循环在一个可点开的面板里走一遍,红灯、绿灯、重构三步分别会停在哪个证据上?
先写会失败的行为示例,再写最小实现并重构。RED→GREEN→REFACTOR三阶段循环。
实战验收清单
- 在隔离环境里把本章三段示例跑一遍,记下解释器实现、版本和依赖来源。
- 给核心行为补上正常、空输入、失败和重复执行四类测试,先看到红灯再改实现。
- 清掉缓存和临时文件后重跑,证明结果不靠工作区里的残留。
- 为历史命令写出现在该走的替代路径,并说清保留了哪些架构不变量、放弃了哪些旧的安全默认。
迁移决策题
假设团队在维护一个跑了多年的 Python 服务,它仍靠本章对应的老工具,但业务不能停机。别急着直接重写。第一步列出 TDD 原则承担的真实输入和输出,再用验收测试与单元测试识别构建或运行时依赖;第二步把标准测试工具放进隔离实验,证明当前行为与失败类型;第三步用 Fake 与 Mock 设计兼容层,让旧入口和新入口在同一组契约测试下跑;最后用文档驱动开发保存制品摘要、性能或行为差异与回滚条件。只有新路径在正常、边界和故障输入上都达到既定条件,才逐步切流量或调用者。这样迁的是可验证契约,而不是把一个旧命令盲目换成一个新命令。
常见误区
误区 1
现象 → 测试在开发机上通过,CI 上失败 原因 → 测试依赖开发机缓存或隐式环境状态 修法 → 每次测试在干净环境运行,用 fixture 隔离文件、网络与临时目录
误区 2
现象 → 改一处实现,大量测试同时失败 原因 → 测试过度 mock 调用细节,实现重构即脆性断裂 修法 → 优先断言可见结果,只有协议契约本身重要时才锁定交互
误区 3
现象 → 覆盖率 100% 仍出生产 bug 原因 → 只测了快乐路径,未覆盖边界、失败与重复执行 修法 → 边界实验至少含正常、空输入、上限、依赖失败和重复执行
误区 4
现象 → doctest 失败但没人理
原因 → doctest 未纳入 CI 流水线,文档示例漂移成噪音
修法 → python -m doctest 加入构建步骤,失败即阻断合并
本章回顾
本章逐项覆盖 TDD 原则、验收测试与单元测试、标准测试工具、Fake 与 Mock、文档驱动开发。做法是先用可读接口把责任说清楚,再用边界测试把失败语义证死,最后靠可重建的环境和制品证据把结论闭环。
小结
- TDD 三阶段:先写失败测试,最小实现,再重构
- 验收测用户边界,单元测局部规则,集成测适配层
- fake 记录状态断言结果,mock 验证交互,优先断言结果
- doctest 把文档示例变执行证据,适合稳定小接口
- 复杂搭建代码移入测试模块,避免文档被淹没
练习与验收
练习
问题 1: TDD 的 GREEN 阶段为什么只写“刚好让测试通过”的代码?
问题 2: 什么情况下用 mock 而非 fake?
问题 3: 为一个“分页查询”函数编写 TDD 测试集:先写失败测试再写实现,要求覆盖空结果、单页满、跨页边界三种情况,并用 fake 存储替代真实数据库。(独立实现)
名词解释
名词解释
本章出现的专业名词,用大白话再讲一遍。
- TDD 三阶段
写代码的三步固定动作:先写一个注定失败的测试(红灯),再写刚好让它通过的最小代码(绿灯),最后在不破坏测试的前提下整理代码(重构)。反复走这三步就叫测试驱动开发。
- 验收测试
站在用户那边,从最外面的入口把整条路走通,证明“用户要的事真做成了”。它跑得慢,但回答的是“能不能用”。
- 单元测试
只盯一小段代码,喂几个输入看输出对不对,跑得飞快,专门抓局部规则的小错。
- fake
一个“能干活但偷工减料”的替身——比如不真连数据库,而是把数据塞进一个列表里。测试时用它,能断言最后存了什么,又不用真依赖外部。
- doctest
把写在文档或函数说明里的示例代码真跑一遍,输出和文档写的一致就过,不一致就报错。文档和测试是同一份,不会各写各的。