编程语言研究书库

单元测试 — Python 的编译器、TDD 与依赖注入

单元测试 — Python 的编译器、TDD 与依赖注入

这是全书立场最重的一章:在 Python 里,单元测试不是「加分项」,是编译器的替代品。 原书用一整章论证这条链:没有编译器 → 必须有测试 → 测试必须快且确定 → 要快要确定就不能碰文件和网络 → 碰了怎么办?依赖注入。 读完你会拿到一套完整的、可 tomorrow 就用的测试方法论。

1. 这一章讲什么

作者先立论,而且从最可能的反驳开始:「为什么要在乎写测试?你以前没有它们也过来了。」 他的回答:「没错。但过去并非一切都更好——恰恰相反,软件工程在过去几十年里进步巨大, 写测试就是这些进步的原因之一。」1

这句回应的分量在于:它把测试从「可选的良好实践」重新定位成行业学习曲线的一部分。 「以前没有也过来了」是真的,但「以前」的代码发布节奏、系统规模和返工成本都和今天不同; 测试不是品味,是这套新规模下的基础设施。书里把这个定位说得平淡,但它撑起了全章。

本章的推理链(每一节是上一节的必然结果):

  ① 为什么测   —— Python 没有编译器,测试是唯一的安全网
  ② 怎么测     —— print 换成 assert,pytest 自动收集
  ③ 什么算数   —— 三条铁律:快、确定、只有通过与失败
  ④ 测什么     —— 角落情况;测试的极限:彻底测试不可能 → 代码必须简单
  ⑤ 怎么写好   —— TDD:先写测试,强迫你先设计接口
  ⑥ 副作用怎么办 —— 依赖注入 + mock:把「读文件」变成可替换的参数
  图说:①到⑤是「为什么/是什么」,⑥是「最难的那道题的解法」,占全章近一半篇幅。

2. 核心原理一:为什么测——print 调试法的死刑

解决什么问题: 新手验证代码的方式:print(square(1)) 然后**用眼睛和手(也许还有计算器)**核对输出。2

怎么做: 作者列了 print 法的两条罪状:

  1. 极易出错——手动核对本身会看错;
  2. 用完即弃——print 语句测完就删,日后没人知道这段代码本该做什么。

第二条是要害:代码一生会被多次修改(重构,第 02 章),没有测试,你永远无法确定改动有没有悄悄改变功能。 唯一的「无测试替代方案」是不重构——而代码会像一年没打扫的公寓一样烂下去。2

单元测试(unit test)就是自动化后的 print 法。 转换只需两步:print 换成 assert(断言: 条件不成立就立刻报错),放进 test_ 开头的函数和文件:

# square.py —— 被测代码
def square(x):
    return x**2

# test_square.py —— 测试代码
from square import square
def test_square():
    assert square(1) == 1
    assert square(2) == 4
    assert square(5) == 25

对照老写法,语义一一对应:print(square(1)) 期待输出 1 → assert square(1) == 1; print(square(5)) 期待 25 → assert square(5) == 25。 变的只是「谁来核对」:从人眼换成机器。3

pytest 是测试运行器:它自动收集所有 test_ 开头的函数并执行,产出 1 passed 的报告。 运行:pytest test_square.py(或报错时用 python -m pytest test_square.py)。3

为什么有效: assert 把「人眼核对」变成「机器核对」——而机器核对可以被重复执行: 每次改动后跑一遍,改动有没有破坏旧行为,几秒钟见分晓。这就是测试作为「编译器替代品」的完整含义: 编译型语言在每次编译时免费给你的检查,Python 要用测试买来。

再对账一次 print 法的那条死穴:「print 语句测完就删」——assert 写进 test_ 函数,从来不删, 而且随着代码成长,它们只增不减;十年后新来的人跑一遍 pytest,就能知道这份代码「本该」怎样表现。 print 法丢掉的知识,测试法永久存档。

为什么有效、边界在哪: 这套转换的边界在输出无法精确断言的地方:GUI 界面、复杂数值(浮点容差, 第 03 章)、随机输出——这些场景下 assert 什么、怎么 assert,本身就是一道设计题(第 6.4 节的判断块)。 但绝大多数业务代码的输出是可断言的,所以「print → assert」这条升级路径对绝大多数代码成立。

书里的定调框:「单元测试是花得值的时间。当然永远没有 100% 保证,但覆盖到位的测试让你非常接近—— 单元测试大概是确保代码按预期工作的最有效方式。」4

「永远没有 100% 保证」这句坦白容易被跳过,其实它很重要:测试不是证明,是抽样—— 第 4 节的组合爆炸在测试侧同样成立,只是样本从「全部输入」换成「挑过的角落」。 所以成熟的测试观是两句话并持:「覆盖到位非常接近」+「接近 ≠ 到达」。 前者给你重构的勇气,后者给你上线前的敬畏;缺了任何一句,要么不敢动代码,要么敢得不该敢。

3. 核心原理二:单元测试的三条铁律

作者说这是「 utmost importance(头等重要)」:不遵守,测试就没用。5

性质要求违反的代价
快单个测试几毫秒;全套几百上千个测试几秒内跑完没人愿意等,测试就不跑了——安全网消失
确定(deterministic)结果只依赖被测代码;不碰文件系统、不碰网络偶发失败(flaky)摧毁对整个测试体系的信任
两值性结果只有通过/失败;必须真的写 assert没有 assert 的测试毫无用处,甚至更糟——给你虚假的安全感

三条各有一句值得记住的注脚:

  • 快: Python 本身慢,全套测试可能更慢——「这是我不推荐大项目用 Python 的又一个原因」; 单个测试的毫秒级预算,逼着你把重的依赖(数据库、网络)都 mock 掉,这个预算反过来塑造了测试的形状。5
  • 确定: 依赖外部因素的东西要「mock 掉」,手法见第 7 节;偶发失败(flaky)一旦出现, 「你对整个测试体系的信任就全没了」——因为红色不再意味着「代码坏了」,而可能只是「网络抖了」。 信任是测试体系的货币,flaky 是通胀。5
  • 两值性: 结果不能「差不多对」——「要么对,要么错」,浮点比较都要带容差(第 03 章); 没有 assert 的测试「毫无用处,甚至更糟——给你虚假的安全感」。绿色进度条是借来的,assert 才是抵押品。5

三条铁律的排序也有讲究:快和确定是前提,两值性是内容。前两条保证测试「随时可跑、跑了可信」, 第三条保证它「真的在检查东西」。违反任何一条的测试也许仍有价值,「但不再配叫单元测试」—— 有快的、稳的测试可写,为什么要写慢的、飘的?单元测试是测试里的金标准。5

配套纪律(加框):「要检查测试真的会失败。」 最糟的情况莫过于测试该失败却不失败——bug 就这样混进系统。 最稳的保障就是接下来要讲的 TDD:测试先写,它必然先失败。6

「该失败却不失败」是怎么发生的?最常见的三种:(我们的归纳,书只给了警告没给病理) 一是断言写错对象(assert result 恒真——result 是个非空对象);二是测的是 mock 而不是真代码 (mock 自己断言自己,永远绿);三是断言被 try/except 吞掉。三种的共同点: 测试在检查「测试本身」,不是「被测代码」。 第 6 节每个测试先红一遍,就是把这条岔路焊死。

三条铁律在现实里会互相拉扯,书里没有展开,补一句:最典型的是「快」对「确定」—— 「读一次配置文件再测」很确定但慢,「缓存配置」很快但引入了缓存是否失效的问题。 拉扯时的裁决书里其实给了:确定性优先。flaky 毁掉的是整个体系的信用, 而信用一旦破产,多快的测试都没人信。5

配套的自动化观(加框:DRY 原则)。 书里在测试运行命令旁插了一个原则框: DRY(Do Not Repeat Yourself,别重复自己)——「永远不要复制粘贴代码,因为重构时你得改多处,容易漏」; 但 DRY 不止管代码,也管流程:「凡是你要做超过一次的事,都应该自动化——包括构建、部署、测试。 测试只写一次,然后自动反复运行。」7 这个框把测试放进了更大的图景:手工跑一次测试 = 重复劳动;把它交给 pytest 一键全跑, 重复的部分由机器承担——这正是「测试为什么必须是代码」的另一半理由。

4. 核心原理三:测什么——以及「彻底测试不可能」

解决什么问题: 输入无穷多,测哪些?

怎么做: 「尽量测全角落情况(corner cases),再测几个随机值。」 角落情况是那些「很容易 special」的输入——书里点了两个名字:空列表、传 0。 为什么这两个?因为它们在业务里「很少发生」,在代码里却「经常是另一个分支」: 循环对空列表直接跳过、除法遇到 0 抛异常、索引遇到空列表越界—— 每一条都是平时跑不出来、一跑就崩的路径。8

所以原作者必须自己写测试——只有他知道哪里藏着角落。接手的人不知道这段代码 「去年有个坑就是负数输入踩的」;测试是他留下的地图。8

然后书里给了一个冷峻的推演:参数一多,组合爆炸(第 01 章见过这个词): 单参数还好;多参数函数的测试是噩梦——「这是函数该少带参数的原因之一」; 更阴的是,哪怕只有一个参数,它也可能是个装了很多字段的嵌套对象——表面上一个输入, 实际上一打输入。参数个数是签名上的数字,实际输入维度才是测试要付的账。 结论:「在测试充分的系统里,测试代码至少和被测代码一样长;高度监管行业能到好几倍。还是可能漏掉角落。」8

由此推出全书最硬的一条工程结论:

保持代码的低复杂度,是把 bug 数压到最低的唯一办法。 比如(第 04 章的回声):一视同仁地对待列表所有元素——那样只需测一个元素的代表行为, 其余大概率没问题。特殊分支每个都是测试负担的乘法。

这条结论值得停两秒,因为它是可推导的而不是「经验之谈」: 测试量 ≈ 分支数 × 每分支的角落情况数;分支数 ≈ 复杂度。 所以「测试写不完」不是测试技术的失败,是复杂度的账单到期——付不起账的唯一办法是少花钱, 也就是第 08 章「消灭 if」、第 12 章「小类」、本章「函数参数 ≤3」的共同理由。 三条看似无关的规矩,在这一节汇成一条:它们都是减少要测试的表面的手段。

5. 核心原理四:单元测试与好代码——接口先于实现

原书在这里给了本章最反直觉的论断(加框):

「单元测试帮你找 bug——但找 bug 是单元测试最不重要的功能。 更重要的:单元测试帮你写好代码。一般人先写实现再想接口,这很糟——这是诺基亚做手机的方式: 先造手机,再想办法让它好看,直到苹果反转了流程、先用户体验。 你该学苹果:先设计接口,再照着实现。而测试强迫你这么做。 好代码是可测试的;不可测的代码就是坏代码。」9

诺基亚/苹果的对比值得看全:诺基亚「先造手机,再想办法让它好看」——功能先行,体验补救; 苹果「反转了开发流程,先用户体验」。这个对比的锋利之处在于:先写测试 = 先体验—— 测试代码就是使用者体验的第一手记录:签名难记、参数难凑、返回值难用,写测试时全部暴露。 「测试强迫你以用户的视角工作,按接口来设计——这强迫你写出好代码。」9

逻辑链:要写测试,就得从使用者的角度调用代码 → 难受的接口会在写测试时暴露 → 你被迫先修接口。测试是先于实现的「第一个用户」。这也解释了为什么作者把这一章放在 类与模块之后:接口是类和模块的接缝,测试是第一个检验接缝的人。

一个现实的提醒,书里写在这一节开头:「我也不期望你立刻开始 TDD——写测试本身要练, 不用急。但心里要有这件事:尤其当你已经想清楚要实现什么时,先写测试是个好主意。」10 这条建议的形态(不催、但记账)和全书的其他建议一致:规矩先立,节奏自定。

TDD(测试驱动开发) 把这一步推到极限:先写测试,再写实现。 三个步骤循环往复:10

  1. 写一个会失败的测试(测试没失败?查为什么——可能是测错了);
  2. 写刚好让它通过的代码(只写必要的,不多一行);
  3. 重构——清理干净、保持可读。

书里说 TDD 的「great thing」是这样:「普通单元测试下,写出测试糟糕的代码仍属可能(只是不太容易); 用 TDD,这几乎不可能——你被强迫站在使用者的视角设计接口。」10 机制很清楚:测试先于实现存在,接口先在测试里定型;实现只是让既定接口活过来。 「写出没人调用的漂亮方法」在 TDD 里没有发生的机会——每个方法都因某个红过的测试而生。

6. 主线例子一:FizzBuzz 走一遍 TDD 三步循环

FizzBuzz:从 1 数起,3 的倍数喊 Fizz,5 的倍数喊 Buzz,同时是 3 和 5 的倍数喊 FizzBuzz。11 原书把这个老题走成 TDD 教学,值得跟着走一遍。先交代规则:3 的倍数 → "Fizz",5 的倍数 → "Buzz", 15 的倍数 → "FizzBuzz",其余 → 数字本身。

6.1 前两步:让 1 和 2 通过

# ① 第一个测试:1 → 1。失败(函数不存在)→ 实现:return 1。绿。
assert fizzbuzz(1) == 1
def fizzbuzz(x): return 1

# ② 加测:2 → 2。失败 → 实现改成 return x。绿。
assert fizzbuzz(2) == 2
def fizzbuzz(x): return x

注意第 ② 步的实现不是「聪明」的,是刚好够用的——TDD 的纪律就是「只写让测试通过的那几行」。 看起来笨拙?这正是它防过度设计的方式:没有测试要求的代码,一行都不写。10

6.2 第三步:第一个分支

# ③ 3 → "Fizz"。失败 → 加 if x == 3。绿。
assert fizzbuzz(3) == "Fizz"
def fizzbuzz(x):
    if x == 3: return "Fizz"
    return x

第 ③ 步开始,实现里第一次出现了 if——测试逼出来的分支,不是提前设计的。 到这里,三步循环的「红→绿」各演示了一遍,第 ④ 步(5 → "Buzz")结构相同,原书一笔带过。11

6.3 第六步:重构时刻

走到 6 时,测试逼出一个重构时刻:代码里 x == 3、x == 6 的特判开始重复—— 测试全绿时的重构正是第 ③ 步。于是:

def fizzbuzz(x):
    if x % 3 == 0: return "Fizz"
    if x == 5: return "Buzz"
    return x

x % 3 == 0 用取余(第 03 章):3、6、9 全部一网打尽,两个特判塌缩成一个通用条件。 原书在这里收住例子(说不想拿 TDD 填满全书),但三步骤已经各演了一遍: 红(失败)→ 绿(刚好过)→ 重构。11

6.4 这个例子教的三件事

  1. 每个测试生来都红过——「测试该失败时不失败」的最坏情况(第 3 节末的加框)被天然排除;
  2. 实现是被测试一步步逼出来的——没有凭空设计的分支;这也是「接口先于实现」的微观形态;
  3. 重构有固定的入场时机——全绿才动结构,红了先修功能;两道工序永远不混。

判断(我们的,不是书里的): FizzBuzz 教学有一个隐蔽的简化:真实业务里, 「x 是 3 的倍数」这条规则往往写不出这么干净的断言(输出是一封邮件、一张报表), 那时「红」的那一步会难得多——TDD 的瓶颈从来不是写实现,是写出能红的测试。 如果错,会错在: 对计算密集型、输出可精确断言的代码(数值库、编译器、本章所有例子), 「红很容易」是常态,TDD 的账是赚的;这套经验不迁移到输出主观的领域。

7. 主线例子二:依赖注入——从「不可测」到「可测」的四级重构

这是本章的后半重心,也是全书最难的几页。作者预警:「接下来的几页不容易, 但如果你想写认真的软件,它们极其重要。我希望你已全神贯注,因为下面几页不容易,却极其重要。」12

7.0 两个前置概念:多态与函数对象

DI 站在两个概念的肩膀上,书里用两个加框各给了最小演示:13

多态(polymorphism,希腊语「多种形态」): 两个类实现同一个方法,就能在使用处互换——

class A:
    def some_function(self): return 1

class B:
    def some_function(self): return 2

objects = [A(), B()]
for obj in objects:
    print(obj.some_function())      # 1 2 —— 调用处不知道也不关心是谁

「在 Python 里这看起来平平无奇;在 C++ 里做到同样的事要费劲得多。」 书里补了它的战术价值:「多态能替你省下大量 if-else,也是依赖注入的基础。」

函数对象(function object): 函数本身可以当值传来传去——

def function_one(): return 1
def function_two(): return 2

def print_function(func):
    print(func())                   # 调用传进来的函数

print_function(function_one)        # 1
print_function(function_two)        # 2

func 没有括号——传的是函数本身,不是调用结果。DI 的 data_provider=get_content 用的正是这个动作:把函数当参数递过去,被调方在需要的时候替你调。13

7.1 靶子:新手都会写的代码

文件 numbers.txt 里存着一行 "1 2 3 4 5",任务是求和。走一遍:

def get_sum_of_file(file_name):
    with open(file_name) as f:
        content = f.read()        # ① 读整个文件 → "1 2 3 4 5"
    numbers = content.split()     # ② 按空白拆开 → ["1","2","3","4","5"]
    return sum([int(number) for number in numbers])  # ③ 逐个转 int 再求和 → 15

print(get_sum_of_file("numbers.txt"))    # 15

三步各司其职:读、拆、算。单看每一步都没问题——问题出在组合上。 「这是每个新手都会写的代码,我当年也写。直到我用资深程序员的眼睛再看它,才发现缺陷: 它没法写单元测试——它依赖文件的存在,而单元测试不许碰文件系统。」12

「这是每个新手都会写的代码,我当年也写。直到我用资深程序员的眼睛再看它,才发现缺陷: 它没法写单元测试——它依赖文件的存在,而单元测试不许碰文件系统。」12

7.2 第一刀:拆成两个函数

读文件和算和分开,至少「算和」能测了:

def get_content(file_name):
    with open(file_name) as f:
        return f.read()

def get_sum_of_content(content):
    numbers = content.split()
    return sum([int(number) for number in numbers])

进步真实但有限:调用 get_sum_of_content 的上层函数仍然不可测(我们没法控制它读的是文件还是假数据)。14

7.3 第二刀(新手进阶版):布尔标志位——能跑,但丑

def get_sum_of_file(file_name, use_real_data=True):
    if use_real_data:
        content = get_content(file_name)      # 真数据
    else:
        content = "1 2 3 4 5"                 # 假数据
    return get_sum_of_content(content)

# 测试里:get_sum_of_file("", False) == 15

测试能写了,但代价是一个 if——第 08 章说过,「能不用 if 就不用」; 而且真/假两条路挤在一个函数里,假数据还藏在实现深处。15

7.2b 第一刀的隐性代价

第一刀(拆函数)之后,书里立刻写了它的局限,这句转折容易被略过:

「现在我们可以在不依赖文件系统的情况下测试 get_sum_of_content 了——这确实是进步。 然而,调用 get_sum_of_content 的上层函数仍然不可测: 我们没法控制它读文件还是吃假数据。」14

「控制行为」这个词是 DI 的真正目标:测试需要的不是「某个函数能测」, 而是「整条调用链的每一步都能被替换成假数据」。 拆分只把链断成两截,测试从断口处能看到下游;DI 则在断口处装了一个可换的插头,上下游全部受控。

7.3b 第二刀为什么不够好:控制权的错位

布尔标志位版能跑,问题在哪?书里点了两处:15

第一处,假数据藏在实现深处:content = "1 2 3 4 5" 这行硬编码在函数体里, 读代码的人要一直钻到最底层才知道「哦,测试时走这条路」; 而 DI 版里,真假数据源是调用处一眼可见的参数——控制权从实现手里交还给了调用方。

第二处,加第三种数据源要改实现:再来一个「从网络读」的数据源,布尔版要再加一个分支; DI 版只要再写一个 get_network_content 函数传进去,原函数一行不动。 这就是「代码更模块化」的具体含义:扩展 = 新增,而不是修改。

7.4 第三刀(正解):依赖注入——把行为当参数传

依赖注入(DI,dependency injection)的思想一句话:函数需要的「依赖」不从内部造,而从参数拿进来。 这里是两个前置概念的正装登场:

  • 多态(第 12 章):两个对象/函数支持同一个调用方式,就能互换;
  • 函数对象:函数可以像值一样传来传去。

把「读文件」和「读假数据」写成两个同签名函数,把选哪个的权力交给调用方:

def get_content(file_name):          # 生产版:真读文件
    with open(file_name) as f:
        return f.read()

def get_mock_content(file_name):     # 测试版:返回固定假数据
    return "1 2 3 4 5"

def get_sum_of_file(file_name, data_provider=get_content):   # 默认真读;测试时换假的
    content = data_provider(file_name)
    return get_sum_of_content(content)

# 生产代码:get_sum_of_file("numbers.txt")            → 用默认的 get_content → 15
# 测试代码:get_sum_of_file("", get_mock_content) == 15 → 传假数据源,不碰文件

走一遍测试那次调用:data_provider = get_mock_content → content = get_mock_content("") → 返回 "1 2 3 4 5" → 求和 15。 测试全程没有文件系统。16

再对账生产那次调用:参数走默认值 get_content → content = get_content("numbers.txt") → 真读文件得到 "1 2 3 4 5" → 求和 15。同一行 content = data_provider(file_name), 生产里读盘,测试里吃假数据——这行代码本身没有任何「我处在什么环境」的意识, 环境差异 100% 由参数承载。这就是 DI 名字里「注入」的含义:依赖不是函数自己找的,是外面注进来的。

DI 的好处清单(书里的原话整理):

  • 没有 if——永远的好事;
  • 注入什么行为随你——「不用在底层实现里翻来翻去、摆弄低层代码的实现细节; 直接换掉那个读文件的函数,就收工了」;
  • 代码更模块化——「被注入的代码可以轻易替换」,生产上换数据源、测试上换假数据,用的是同一个口。

唯一的缺点:「复杂度高一点;学 DI 要时间。而且别过度使用——它让东西可测, 但不是治所有代码的万灵药。」17

第三条「模块化」值得再落一层:依赖从参数进,意味着函数不知道也不需要知道外面的世界 (不知道有文件、有网络、有数据库)——它只认「给我一个 data_provider 形状的东西」。 不知道外部世界的函数,天然可以在任何环境里跑:测试环境、笔记本、服务器。 这正是第 12 章「依赖显式化」在函数尺度的样子。

顺带回看第 7.3 节那个布尔标志位:它留下的教训可以推广成一条设计嗅觉—— 「函数体里出现『我现在处于什么环境』的判断,就是 DI 没做到位的味道。」 环境差异应该在调用处(参数)解决,不该在实现里(if)解决。 这条嗅觉在配置项、特性标志、调试模式等场景反复适用。

7.5 第四刀:DI 的类版本

依赖多了,函数参数装不下,就用类装(第 10 章「参数 ≤ 3」的解药):

class RealContentProvider:
    def get_content(self, file_name):
        with open(file_name) as f:
            return f.read()

class MockContentProvider:
    def get_content(self, file_name):
        return "1 2 3 4 5"

def get_sum_of_file(file_name, data_provider=RealContentProvider()):
    content = data_provider.get_content(file_name)
    return get_sum_of_content(content)

# 测试:get_sum_of_file("", MockContentProvider()) == 15

两个类同方法名——多态的类形态;执行时 data_provider.get_content(...) 落到哪个类上,由传进来的对象决定。 类版的优势:「一个类里能定义许多方法」——依赖复杂时,mock 也能 mock 得有模有样; 但「大多数情况下,函数版够用了」。18

类版的走查和函数版同构,只是参数从「函数」换成「对象」:

  生产调用:get_sum_of_file("numbers.txt")
    → data_provider 取默认值 RealContentProvider() 的实例
    → data_provider.get_content("numbers.txt") → 真读文件 → "1 2 3 4 5" → 求和 15

  测试调用:get_sum_of_file("", MockContentProvider())
    → data_provider 是 MockContentProvider() 的实例
    → data_provider.get_content("") → 返回写死的 "1 2 3 4 5" → 求和 15

  图说:函数版传「函数」,类版传「对象」;对象能打包多个方法,其余完全一致。

什么时候值得升级到类版?书里的判据是「依赖的数量」:一个依赖,函数参数就够; 三五个依赖(读配置、读文件、连网络……),打包成一个 Provider 对象传一个参数, 调用签名还是干净的——这又是第 10 章「参数 ≤3」的解法。18

7.6 mock 与 fake:替换的两个量级

  • mock(仿制件):把有副作用的复杂函数换成「返回预定值、没有副作用」的简单替身——DI 之后这很 trivial; 「mocking 配合 DI,是测试有麻烦副作用的函数的最佳组合。」19
  • fake(假货):换一个完整的替代实现——书里的例子是一个手写的小型内存数据库 (支持 insert/get 两个字典操作)。「非常强大,但也非常复杂,只在极少数场合用。」19

7.8 FakeDatabase:fake 的完整代码

把书里那个 fake 数据库展开看——它只有 7 行,但形状就是真数据库的迷你版:19

class FakeDatabase:
    def __init__(self):
        self.data = {}

    def insert(self, key, value):
        self.data[key] = value

    def get(self, key):
        return self.data[key]

用途:被测代码调 insert("alice", 30)、get("alice") 时,行为和一个真数据库一样—— 但它快(内存)、无副作用(不用连真库)、可控(测试前塞什么数据,读出来就是什么)。 代价:你得手写并维护它和真数据库的「行为一致」——真数据库升级了语义,fake 得跟着改, 否则测试全绿、上真库翻车。这就是「fake 只在极少数场合用」的原因: mock 返回固定值,一致性 trivial;fake 模拟行为,一致性是个长期负债。

7.7 回看:这一刀为什么同时消灭了 if

把四级演进连起来看,会发现一件漂亮的事:测试的约束反过来治好了两个设计病。

  • 第一刀(拆函数)治好「函数干两件事」——第 10 章 SRP 的回声;
  • 第三刀(依赖注入)治好「真/假数据标志位」——第 08 章「消灭 if」的第三扇门在此兑现: 书里明说 DI 「最明显的优点就是不需要任何 if 语句」。17

这就是本章论证链的收口:可测试性不是一个额外的质量指标,它是好设计的探针—— 不可测的代码,总会在某个地方同时违反「短函数、少参数、无 if、无副作用」中的至少一条; 让代码可测的过程,就是把它们一一修掉的过程。「好代码是可测试的;不可测的代码就是坏代码」 在这里从口号变成可操作的检查清单。9

这份清单反过来读也成立:当你想说服自己「这段代码不用测」时, 先问自己它违反了清单里的哪一条——答案往往就是它需要被拆的地方。 测试不是写完代码后的义务,是设计进行中的仪表盘;仪表盘存在的意义是让你敢开快车。

7.9 mock 与 DI 的配合,一次完整的测试长什么样

把 DI 和 mock 拼起来,一个「有副作用函数的测试」完整长这样:19

# 生产代码(第 7.4 节的最终版)
def get_sum_of_file(file_name, data_provider=get_content):
    content = data_provider(file_name)
    return get_sum_of_content(content)

# 测试代码(test_get_sum_of_file.py)
from get_sum_of_file import get_sum_of_file, get_mock_content

def test_get_sum_of_file():
    assert get_sum_of_file("", get_mock_content) == 15

(导入行按 pytest 的惯例从被测模块取名字;文件名以 test_ 开头,pytest 才会自动收。)

读法:"" 是假的文件名(反正也不会真去读),get_mock_content 是假的数据源; 被测函数照常执行,只是每一步都踩在铺好的地毯上。**测试不碰文件系统、不碰网络, 同时验证了「拆分正确 + 求和正确」两件事。**这就是「Mocking 配合 DI 是最佳组合」的完整含义。19

8. 作者的判断与证据

书里给了证据的: 全部测试代码可直接运行(pytest 输出示例给出);FizzBuzz 每一步的红绿状态明确; DI 四刀的对比是完整的演进史。31116

书里给了证据、但值得再核一遍的: 那条「测试代码至少和被测代码一样长」的经验量—— 在作者熟悉的受监管行业(汽车、医疗软件)这是常态,但互联网业务代码的常见区间是 0.5–0.8 倍 (书外常识,供对账);作者的方向感(测试不是小数目)完全正确,精确倍数随行业漂移。

作者的判断(本章 = 全书立场的落点):

  • 「单元测试是 Python 的必需品」——由「无编译器」推出,逻辑严密;
  • 「找 bug 是测试最不重要的功能」——强立场,行业多数人会倒过来说,但他的论证(测试=第一个用户)站得住;
  • 「DI 别过度用」——难得的自我设限,可信。

判断(我们的,不是书里的): 本章的「依赖注入」是手工版——真实项目里更常见的是 unittest.mock 的 patch 和 pytest 的 monkeypatch fixture,不用改生产代码也能替换依赖。 作者的函数注入版更干净(生产代码因此更解耦),patch 版更省事(测试独立、生产零改动)—— 两者各管一段:新代码按 DI 写,遗留代码用 patch 测。 如果错,会错在: 重度 DI(尤其类版)在大型 Python 项目里可能滑向 Java 式的「注入器框架」, 那时复杂度会反噬——所以作者的「别过度用」是这条路的刹车片。

另一条我们补充的判断:本书把 DI 讲成「测试的附属技术」,但 DI 的真实价值排序其实是 可替换性 > 可测试性——生产上换数据源、换缓存实现,靠的也是同一个参数口。 测试只是第一个逼你开这个口的人;口开了,受益的不止测试。 如果错,会错在: 小项目里根本没有第二个实现,参数口就成了为不存在的变化付的利息—— 所以还是那句「别过度用」:口的数量,以「真实存在或测试需要」为准。

9. 边界与局限

9.1 原书测试示范的两个简化,读的时候要记得

  1. assert 的报错信息很糙。 assert square(2) == 5 失败时只告诉你「AssertionError」, 不告诉你左边是 4——真实项目用 pytest.approx、或把期望值写进断言消息(assert x == 5, f"got {x}")。 原书的教学断言够用,但调试体验差一截(书外补充)。
  2. 没有讲「测试本身的测试」。 第 3 节那句「检查测试会失败」给了纪律,没给手法; 常见手法是「故意改坏一行实现,看哪个测试红」——变异的实现就是探针(书外补充)。

9.2 没覆盖的清单

  • 没讲 fixture、参数化测试、测试目录组织(pytest 的日常三件套);
  • 没讲 覆盖率(coverage)——「测试够不够」的量化工具缺席;
  • mock 一节很短,unittest.mock.Mock 的用法(自动造替身对象)没出现;
  • 测试数据的构造没讲:复杂输入怎么造(工厂函数、builder),书里全靠字符串字面量,真实项目的输入往往是一棵对象树;
  • TDD 的争议面(什么时候不值得)没讨论——作者立场鲜明,不假装中立。

9.3 时间冲刷后的一句话复核

本章写于 2026 年,有一处值得按今天的实践校准:「单元测试毫秒级」在 Python 里越来越靠 进程外服务(数据库起在容器里)来满足,纯内存派和容器派目前各半(书外观察); 但「确定、两值」两条没有任何流派争议——它们是测试之所以是测试的定义部分,不是风格偏好。换言之,这本书写十年后,工具名全会换,这三条铁律大概还立着——「会过时的是工具,不大会过时的是约束」——这也是把它收进书库的理由之一。

9B. 从书到项目:测试落地的最小顺序

把本章变成行动清单,顺序按「先网后针」:

  第 1 步  给现有代码里最纯的函数补测试(无 IO、无全局状态)
  第 2 步  把 print 调试的习惯换成「顺手写成 assert」——测试从增量开始长
  第 3 步  碰到读文件/网络的函数,做一次第 7 节的拆分(DI)
  第 4 步  新功能试一次 TDD:测试先行,哪怕只坚持三天
  第 5 步  把 pytest 接进保存文件的钩子(每次存盘自动跑受影响测试)
  图说:前三步在收网,后两步在加密;顺序反过来(先上 CI 再补测试)会网破人疲。

(这张顺序表是我们的建议,不在书里;书只给了纪律,没给路线图。)

10. 可带走的

本章两条主线例子,一行写完: ① FizzBuzz:assert fizzbuzz(3) == "Fizz" 先红 → if x == 3 转绿 → 重复出现 → 重构为 x % 3 == 0。11 ② DI:get_sum_of_file("numbers.txt") 用默认的真读函数得 15; get_sum_of_file("", get_mock_content) 传假数据源,测试不碰文件。16

  1. Python 没有编译器,单元测试就是安全网——它比在编译型语言里更要紧。
  2. print 调试有两宗罪:手动核对易错、删了就丢了「本该如此」的记录。
  3. pytest + assert:测试函数叫 test_ 开头,机器替你核对,可重复执行。
  4. 三条铁律:快(毫秒级)、确定(不碰文件和网络)、只有通过与失败(没有 assert 的测试是负资产)。
  5. 要检查测试真的会失败;TDD 天然保证这一点。
  6. 测角落情况;彻底测试不可能 → 唯一出路是低复杂度(一视同仁 = 测试量除法)。
  7. 先接口后实现:测试是第一个用户;「不可测 = 坏代码」。
  8. TDD 三步:红 → 绿(刚好过)→ 重构。
  9. 依赖注入:依赖从参数进,不从内部造;副作用(文件/网络)因此被挡在单元测试门外。
  10. mock 是简单替身, fake 是完整假实现;DI + mock 是测副作用函数的标配组合。
  11. DRY 管流程也管代码:做超过一次的事就自动化——测试写一次,跑无数次(加框的 DRY 框原话精神)。7
  12. 书里的方法偏手工:真实项目再补 pytest fixture、参数化、unittest.mock.patch 三件套(本章边界已注明,属书外补充)。
  13. 先接口后实现不是 TDD 专利:哪怕后补测试,写测试的那一刻你也在替接口做用户体验测试——「不可测 = 坏代码」对非 TDD 流程同样成立。
  14. DI 的收益排序:可替换性 > 可测试性;测试只是第一个逼你开参数口的人(判断块详述)。

10A. 本章的完整论证链,一张图

  没有编译器(01)
    └─ 测试是唯一的安全网(②)
        └─ 网要密:快、确定、两值(③)
            └─ 网密的前提:代码低复杂度(④)
                └─ 低复杂度的形状:先接口后实现(⑤)
                    └─ 实现手法:TDD 红绿重构(⑥)
                        └─ 副作用挡在网外:DI + mock(⑦)
  图说:每一环都被上一环逼出来;⑦ 的 DI 又回头治好「函数太长、参数太多、if 泛滥」
       (10、08 章)——论证链在这里闭环。

闭环的意思是:如果哪天你发现自己在一个函数里同时需要 DI、需要砍参数、需要拆 if, 别惊讶——它们从来就是同一场手术的不同切口。本章给的是切口清单,不是三个独立技巧;而这张清单的尽头,就是那句反复出现的话:好代码是可测试的,不可测的代码就是坏代码——本章从第一节到最后一节,护的都是这一句;它值得放在你的工作台正前方,也值得写进你团队的入职文档第一页——新同事第一天就该知道这条规矩的分量,以及它背后那道没有编译器的深渊——别的语言替你看着脚下,Python 把这份差事交给了你和你的测试——接不接,是你的选择,也是本书全部测试章节的起点与终点——绕一圈回来,答案还是那一句:好代码是可测试的,不可测的就是坏代码——句号,本章到此结束,谢谢读完。

这张链也是全书的缩影:语言机制(前半本)和工程纪律(后半本)不是两本书, 是同一条因果链的两段——纪律存在的理由,都能从语言的缺口推出来。

11. 原文地图

照例三列:主题 | 原书章 | 精确到段的地址;每条都用同一套「全路径锚 + 原文短语」写法,机器可核、人可跳、行号漂移了也能按短语找回——短语是从原文逐字取的,这是它比行号硬的原因:行号是地址的复印件,短语才是地址本身——两者都在,才经得起重新转码。下面按本章讲解顺序逐主题列出,每行可以独立跳回原书核对,不必按顺序读;找一条论断的出处,像查字典而不像读文章。

主题原书章原文位置
为什么写测试(print 法两宗罪)10 Unit Teststext/26-ch10-10-unit-tests.txt:3(搜「checks the actual code」) · text/26-ch10-10-unit-tests.txt:24(搜「very error-prone」)
print → assert 的转换、pytest 运行10 Unit Teststext/26-ch10-10-unit-tests.txt:61(搜「test_square」) · text/26-ch10-10-unit-tests.txt:81(搜「pytest test_square.py」)
测试是时间的好投资10 Unit Teststext/26-ch10-10-unit-tests.txt:37(搜「time well invested」)
三条铁律10 Unit Teststext/26-ch10-10-unit-tests.txt:100(搜「Three Properties」)
flaky 之恶、mock 的预告10 Unit Teststext/26-ch10-10-unit-tests.txt:113(搜「Flakiness」)
没有 assert = 无用10 Unit Teststext/26-ch10-10-unit-tests.txt:121(搜「wrong sense of security」)
单元测试是金标准10 Unit Teststext/26-ch10-10-unit-tests.txt:124(搜「the gold」)
「检查测试真的会失败」10 Unit Teststext/26-ch10-10-unit-tests.txt:128(搜「Check That Tests Fail」)
测什么:角落情况、测试的极限10 Unit Teststext/26-ch10-10-unit-tests.txt:137(搜「corner cases」) · text/26-ch10-10-unit-tests.txt:145(搜「simply impossible」)
「保持低复杂度是唯一出路」10 Unit Teststext/26-ch10-10-unit-tests.txt:147(搜「keep the complexity」)
诺基亚/苹果框:「不可测=坏代码」10 Unit Teststext/26-ch10-10-unit-tests.txt:152(搜「Unit Tests and Good Code」)
TDD 定义与三步10 Unit Teststext/26-ch10-10-unit-tests.txt:163(搜「Test-Driven Development」) · text/26-ch10-10-unit-tests.txt:176(搜「three steps」)
FizzBuzz 全流程10 Unit Teststext/26-ch10-10-unit-tests.txt:184(搜「Fizz Buzz」) · text/26-ch10-10-unit-tests.txt:273(搜「redundant code」)
依赖注入的引入与预警10 Unit Teststext/26-ch10-10-unit-tests.txt:288(搜「Dependency Injection」) · text/26-ch10-10-unit-tests.txt:347(搜「full attention」)
第一刀拆分10 Unit Teststext/26-ch10-10-unit-tests.txt:352(搜「break the code into two」)
布尔标志位版及其局限10 Unit Teststext/26-ch10-10-unit-tests.txt:377(搜「use_real_data」)
DI 函数版与调用走查10 Unit Teststext/26-ch10-10-unit-tests.txt:410(搜「get_mock_content」) · text/26-ch10-10-unit-tests.txt:413(搜「get_sum_of_file」)
DI 好处清单与「别过度用」10 Unit Teststext/26-ch10-10-unit-tests.txt:433(搜「Advantages of Dependency Injection」)
DI 类版10 Unit Teststext/26-ch10-10-unit-tests.txt:454(搜「RealContentProvider」)
mock 与 fake10 Unit Teststext/26-ch10-10-unit-tests.txt:490(搜「Mocking」) · text/26-ch10-10-unit-tests.txt:498(搜「Faking」)

Footnotes

  1. 出处:「10 Unit Tests」第 3–12 段(text/26-ch10-10-unit-tests.txt:3,搜「checks the actual code」;「过去几十年软件工程巨大发展」见第 10 段,搜「developed enormously」)。 ↩

  2. 出处:「10 Unit Tests」第 13–32 段(text/26-ch10-10-unit-tests.txt:24,搜「manually check」;第 25 段,搜「nobody will know exactly」)。 ↩ ↩2

  3. 出处:「10 Unit Tests」第 44–96 段(text/26-ch10-10-unit-tests.txt:69,搜「starts with test_」;运行命令见第 81–87 段,搜「pytest test_square.py」)。 ↩ ↩2 ↩3

  4. 出处:「10 Unit Tests」第 37–41 段(加框,text/26-ch10-10-unit-tests.txt:39,搜「most effective way」)。 ↩

  5. 出处:「10 Unit Tests」第 98–127 段(text/26-ch10-10-unit-tests.txt:100,搜「Three Properties」;第 113 段,搜「Flakiness」;第 119 段,搜「useless」;「金标准」见第 124 段,搜「gold standard」)。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6

  6. 出处:「10 Unit Tests」第 128–132 段(加框「Check That Tests Fail」,text/26-ch10-10-unit-tests.txt:129,搜「supposed to」)。 ↩

  7. 出处:「10 Unit Tests」第 71–79 段(加框「Do Not Repeat Yourself」,text/26-ch10-10-unit-tests.txt:72,搜「never copy and paste」;「构建、部署、测试」见第 76 段,搜「procedural repetitions」)。 ↩ ↩2

  8. 出处:「10 Unit Tests」第 135–150 段(text/26-ch10-10-unit-tests.txt:137,搜「corner cases」;第 145 段,搜「simply impossible」;第 147 段,搜「keep the complexity」)。 ↩ ↩2 ↩3

  9. 出处:「10 Unit Tests」第 152–161 段(加框「Unit Tests and Good Code」,text/26-ch10-10-unit-tests.txt:154,搜「least important part」;「好代码是可测试的」见第 160 段,搜「testable」)。 ↩ ↩2 ↩3

  10. 出处:「10 Unit Tests」第 163–182 段(text/26-ch10-10-unit-tests.txt:165,搜「write your tests first」;三步见第 176–180 段)。 ↩ ↩2 ↩3 ↩4

  11. 出处:「10 Unit Tests」第 184–286 段(text/26-ch10-10-unit-tests.txt:185,搜「Fizz Buzz」;重构时刻见第 273 段,搜「redundant code」)。 ↩ ↩2 ↩3 ↩4 ↩5

  12. 出处:「10 Unit Tests」第 288–348 段(text/26-ch10-10-unit-tests.txt:335,搜「get_sum_of_file」;「每个新手都写」见第 343 段,搜「every novice」;预警见第 347 段,搜「not so easy」)。 ↩ ↩2 ↩3

  13. 出处:「10 Unit Tests」第 296–317 段(「Polymorphism」框,text/26-ch10-10-unit-tests.txt:293,搜「Polymorphism」;「Function Objects」框,text/26-ch10-10-unit-tests.txt:318,搜「function object」)。 ↩ ↩2

  14. 出处:「10 Unit Tests」第 349–370 段(text/26-ch10-10-unit-tests.txt:352,搜「break the code into two」)。 ↩ ↩2

  15. 出处:「10 Unit Tests」第 371–389 段(text/26-ch10-10-unit-tests.txt:377,搜「use_real_data=True」)。 ↩ ↩2

  16. 出处:「10 Unit Tests」第 390–431 段(text/26-ch10-10-unit-tests.txt:410,搜「get_mock_content」;调用走查见第 422–431 段)。 ↩ ↩2 ↩3

  17. 出处:「10 Unit Tests」第 433–449 段(text/26-ch10-10-unit-tests.txt:435,搜「don’t need any if statements」;「别过度用」见第 442 段,搜「no panacea」)。 ↩ ↩2

  18. 出处:「10 Unit Tests」第 445–489 段(text/26-ch10-10-unit-tests.txt:454,搜「RealContentProvider」;「多数情况函数版够用」见第 488 段)。 ↩ ↩2

  19. 出处:「10 Unit Tests」第 490–514 段(text/26-ch10-10-unit-tests.txt:490,搜「Mocking」;「best combination」见第 495 段;Fake 定义见第 498 段,搜「Faking」)。 ↩ ↩2 ↩3 ↩4 ↩5

On this page