读书导读 · MIT

《通过测试学习 Go》全书摘要与逐章导读

MIT

引文按 MIT 许可使用。免费阅读原书。

《通过测试学习 Go》讲了什么AI 辅助

测试的痛苦诊断的是硬连线的依赖,而不是 TDD 循环的缺陷

如何以测试先行的方式培育出值得信赖的 Go 软件?这一个问题驱动着整本书。它从刻意琐碎的练习开篇——Hello、Add、Repeat、Sum、Area、Wallet、Dictionary——每一个教一个语言特性,同时反复操练同一个循环:写一个失败的测试,读编译器的报错,看它因正确的原因失败,只写刚好够用的代码,重构。然后它攀升到 goroutine、通道、select、context、反射、泛型和基于性质的测试;一章一章地构建一个完整的 HTTP 和命令行扑克应用;并以验收测试、规格说明与驱动的分离、扩展 TDD 以及一份反模式名录收尾。贯穿全书的主线是:测试是一种设计工具,而不是验证的杂务:测试中的痛苦诊断出硬连线的依赖,所以协作者要在小型标准库接口后面被注入,失败要作为可检查的值而不是字符串被返回,而抽象只在具体的被测行为揭示它们之后才被提取。每一课都跑在小型 Go 程序上,所以关于大型系统的说法是被论证的,而不是被测量的。

一则讲透全书的寓言AI 辅助

用一个故事装下全书最核心的想法,并老实交代故事在哪里失真。

标准水桶使秘密小门和特殊通道变得不再需要

从前有一个村子,井设在磨坊主的屋子里,砌在他的壁炉墙里。只有磨坊主能打水,而且只能借着火光。村民们在烘焙日之前想尝一口水,磨坊主说:先烤,后尝。于是他们烤了,面包出炉是苦的,磨坊主发誓水是甜的,而不拆一堵墙,谁也无法证明不是。一个春天,一位名叫伊莎的年轻箍桶匠说:在我烤任何东西之前,我要一杯水。她想去取,却发现取不到——不是因为水不好,而是因为井被建在了没有水桶够得着的地方。邻居们说这是她的杯子的失败。伊莎说这是墙的失败。她没有为尝水者专开一个秘密小门;她说,井应当在任何带着水桶的人都能站到的地方,磨坊主也应当像其他人一样被递给他的水。于是墙拆了,井搬到了广场,打水的人如今需要的只是一副标准尺寸的绳桶——箍桶匠的桶,面包师的桶,孩子的锡杯,都合同一个口沿。伊莎打了一杯,尝了尝,发现是咸苦的,村子在点燃烤炉之前就知道了。后来来了约伦,他讨厌走去广场。他在厨房里放着一只涂了漆的桶,每天早晨凭着对井水从前味道的记忆把它灌满,每天晚上宣布水是甜的。他的面包也是苦的,但现在他的品尝却说相反的话——比磨坊主更糟的状态,后者至少承认没人能分辨。村子定了一条规矩:厨房里的桶,只有在它是真桶、装着真水,并且对它和对广场上的井运行同样的品尝仪式、二者必须一致时,才被允许。而每年一次,全村把一个面包从头到尾烤出来并吃掉,因为再诚实的一杯水,也证明不了面包。

故事里的每样东西代表什么

故事里书里
伊莎要求在烤之前、而不是之后得到一杯水先写测试,并看它因正确的原因失败,因为事后写的测试会变成常绿测试,或断言无关的细节
她够不着水,以及她把它读作墙的过错而不是自己杯子的过错测试中的痛苦是关于硬连线的依赖、全局状态和过多协作者的设计反馈——修复属于生产设计
拒绝为尝水者开秘密小门,而是把井搬到广场拒绝仅供测试的模式和为测试而发明的层,转而改变真实的结构
箍桶匠、面包师和孩子的容器都合得上的标准口沿作为接缝的小型标准库接口——io.Reader、io.Writer、一个存储——使同一套逻辑能服务于命令行、HTTP 服务器和浏览器
像对待其他人一样,从外面把水递给磨坊主依赖注入:协作者从外面传入,而不是在内部构造或作为全局变量去取
约伦凭记忆灌满的涂漆桶,宣布水是甜的,而面包依旧是苦的模拟对象(mock)、桩(stub)和间谍对象(spy)作为分散的、未经验证的假设,使测试在软件已坏时仍然通过
厨房的桶必须装真水、并通过与井相同的仪式的规矩一个有状态的假实现(fake),因为同一套可复用的契约测试同时对它和真实依赖运行而被信任
每年把整个面包烤出来吃掉从外部驱动系统的验收测试——单元测试给不了的信心,昂贵到只能补充而不能取代它们

寓言在哪里失真

这则寓言的扭曲在于,把重新设计说成一次不可逆的砌墙行动,并配了个明显的恶棍。书里的版本是迭代的、廉价的:墙一块砖一块砖地拆,用薄薄的端到端切片,尽快脱离红色——必要时硬编码一个返回值——只有在具体的被测行为显出模式之后才提取标准口沿,绝不预先雕刻。它也无法容纳那些活在类型和工具里、而不在道德几何里的部分:作为可检查的哨兵值或带类型的值、用 errors.Is/As 检查的错误,在编译期使坏桶无法被表示的泛型与受约束类型,把速度说法变成测量的基准测试,以及整个并发的体系,其中互斥锁、通道和 context 承担串行化,而 sleep 从来不是同步。它也无法承载本书自己未解决的张力——扑克各章用寓言里谴责约伦的那种桩和间谍搭建了 HTTP 服务器,没有规则说硬编码的烂泥什么时候必须去掉,而把规格说明与驱动拆开恰恰加进了重构建议所警告的那种间接层。最后,这个故事背后的每一课都是在刻意琐碎的程序上演示的,所以村子对一个口沿适合所有容器的信心,是被论证的,而不是在规模上被测量的。

《通过测试学习 Go》的核心论点

全书捍卫的 10 个论点,按论证需要的顺序排列。

  1. 因为真实使用中的软件必须不断变化并累积复杂度,单元测试是持续的、保持行为不变的重构的前提条件——所以没有时间重构,是它早该进行的信号,而不是跳过它的理由。

    “选择不写测试的人,通常只能依赖手工测试。对任何不止是小项目的东西,这都会是巨大的时间黑洞,而且从长远看无法扩展。”

    “Those who choose not to write tests will typically be reliant on manual testing. For anything other than a small project this will be a tremendous time-sink and does not scale in the long run.”

    Why unit tests and how to make them work for you › When refactoring code you must not be changing behaviour查看原文 →
  2. 测试必须通过一个单元导出的 API 来断言外部可见的行为;把它们耦合到实现细节或原始输出字符串,会产生误导性的失败、脆弱的测试套件和受阻的重新设计。
  3. 测试必须先写,并看它因正确的原因失败,因为事后写的测试倾向于常绿,或断言无关的细节,而基于示例的用例系统性地漏掉那些由陈述出来的性质所暴露的领域真相。
  4. 测试中的痛苦是关于硬连线的依赖、全局状态或过多协作者的设计反馈——TDD 在概念上很简单,人们归咎于它的,是良好设计的难度,而循环只是把它暴露了出来。
  5. 依赖应当被注入到代码本就会接触的小接口后面——io.Reader、io.Writer、一个服务或存储——而不是硬连线,或包上为测试而发明的层,因为让代码可测试的同一条接缝,也让同一套领域逻辑能支撑命令行、HTTP 服务器和浏览器。
  6. 代码应当在薄的端到端切片中生长,尽快脱离红色——必要时硬编码——抽象只在具体的被测行为揭示模式之后才提取,绝不预先发明。
  7. mock、stub 和 spy 是一种负担,因为它们散布未经验证的假设,使测试在软件已坏时仍然通过,而有状态的假实现,借由对真实依赖运行的契约来检查,则以单元测试的速度给出集成层面的信心。
  8. 从外部驱动整个系统的验收测试给出单元测试给不了的信心,但成本高到必须补充而不是取代它们——而且只有当规格说明与驱动分离时,它们才保持可复用,所以行为变更从规格说明开始,并顺着编译器错误向下。
  9. 正确性属于类型和返回值,而不是字符串和运行时的运气:错误应当是用 errors.Is/As 检查的哨兵值或带类型的值,无效输入应由受约束或泛型的类型排除,而编译器应被用作把失败移到编译期的协作者。
  10. 并发和优化必须由证据而不是希望来驱动:共享的可变状态需要显式的串行化,而 sleep 从来不是同步;速度的说法,只有在代码能工作、已重构、并且基准测试测出收益之后,才是正当的。

逐章导读

每一章都在本页:论证怎样展开、关键论点、常见误读和核心概念。从目录里选一章。

关于本书:为什么要写单元测试,以及怎样让它们为你所用

你如何区分一个单元真正的边界与偶然的边界?

关于本书是怎样展开的AI 辅助

本章以围绕“软件”一词本身的一个谜题开篇:如果代码本应是柔软可塑的,为什么这么多项目最后都塌成必须重写的遗留系统?作者把若干有帮助的特性归功于 Go——一个很小的关键字集合,用于测试和基准测试的强大工具,丰富的标准库,快速编译,以及向后兼容的承诺(与一次痛苦的 Scala 升级形成对照)——但坚持认为没有哪种语言能阻止糟糕的系统。这引出了历史的锚点:曼尼·莱曼1974年的软件演化定律。其中两条被特别点出。持续变化定律说,软件必须变化,否则就越来越无用,作者借此批评那些被激励为按日期交付然后移交的团队,并论证即使优秀的工程师也无法预见未来的需求。复杂度递增定律说,除非做工作去降低,复杂度会增长,警告不要把团队当作“盲目的功能工厂”。

从这里,本章收窄到它所选的补救。在许多让软件保持柔软的实践之中——良好的关注点分离、架构、可观测性、可部署性、测试、反馈循环——作者选了重构,指出这个词被广泛误用,并引用马丁·福勒的话大意是:一个坏了好几天的系统并不是在被重构。含义靠一个课堂类比被找回:把 1/2 和 1/4 相加,办法是把 1/2 重新表示成 2/4,这改变了形式而不改变值。重构同样是重塑代码,使之适合你对领域当前的理解,而不改变行为。一个 Go 的例子使之具体:一个堆着按语言分支的 if 的 Hello 函数,变成一个查找映射加一个带默认值的小小 greeting 辅助函数。作者强调具体怎么重写并不重要——唯一的规则是行为不被触动,因为在重构时改变行为,就意味着一次做两件难事,这正是重构努力失败的地方。既然在一个不简单的系统上,你无法在脑子里验证等价,而手工测试无法扩展,单元测试就提供了缺失的检验:信心、对预期行为的文档,以及快速的反馈。一个简短的 TestHello 展示了节奏:小重构,运行测试,重复——亚秒级的反馈,作为对抗莱曼复杂度的安全网。

转折出现在作者直面相反的体验时:许多人发现单元测试阻碍重构,这与承诺正相反。诊断性的问题是:重构期间测试必须改变得多频繁。图示是一个由两个三角形拼成的正方形,测试断言的是三角形的性质;当后来的一位开发者用长方形重建这个正方形时,这些测试以误导的方式失败了,尽管内部形状从来不是重点。教训是,这类测试错误地抬高了实现细节。药方是测试行为而不是实现——抱怨通常能追溯到处于错误抽象层级的测试、对协作者的过度侦察、过度模拟,以及把追逐测试覆盖率当作虚荣指标。纯粹的黑盒测试也不是答案:对关键旅程有价值,但昂贵、缓慢,又不善于精确定位原因,所以对重构帮助不大。

解决办法把测试重新框定为设计问题。瞄准自成一体、解耦、围绕领域概念构建的单元——乐高积木,带着连贯的 API,如银行系统里的“账户”包,把让它们工作的任何一堆类型和函数藏起来。测试这些公共 API,你就必然是在测试有用的行为,让内部可以自由变化。是的,作者肯定,这些算作单元测试;“单元”从来不是指单个函数或类。

收尾部分把三条线编织在一起:重构提示你的测试是否处在正确的层级(手工检查意味着测试太少;错误地失败的测试意味着错误的抽象或没有价值)并管理复杂度;单元测试提供安全网和文档;设计良好的单元使测试和重构都变得容易。这引向对 TDD 的论证。与其是“糟糕的旧日子”——数月的需求与设计文档先于失败,作者指出,这种事仍在发生——敏捷和 TDD 强制迭代式的工作和快速反馈:为一个小行为写一个小测试,看它清楚地失败,写最少的代码让它通过,重构,重复。经过练习,这个循环会觉得自然,而待在“绿”之外就开始像一个警告信号。总结重申命题:变化不可避免却不可预测,所以别过度设计;靠持续的重构,由良好的测试套件和有意义、可组合的单元支撑,让软件保持柔软,而 TDD 是驱动这三者的纪律。

关键论点,以及支撑它们的原文

论点 1

一门现代语言加上良好的工具,本身并不能保护系统不退化成遗留系统。

作者列出了让 Go 有前景的东西——很小的关键字集合,强大的标准库,快速编译,一等公民的测试与 lint 工具,以及他拿来与自己升级一个 Scala 项目耗掉的数周相对照的向后兼容承诺——然后承认,即使拥有这一切,仍然可以造出糟糕透顶的系统,这就是他转向莱曼1974年定律的原因:真实使用中的软件必须变化,否则就失去价值,而除非投入精力去降低,它的复杂度会不断上升。

“选择不写测试的人,通常只能依赖手工测试。对任何不止是小项目的东西,这都会是巨大的时间黑洞,而且从长远看无法扩展。”

“Those who choose not to write tests will typically be reliant on manual testing. For anything other than a small project this will be a tremendous time-sink and does not scale in the long run.”

Why unit tests and how to make them work for you › When refactoring code you must not be changing behaviour查看原文 →
论点 2

重构意味着改进代码的形状,同时让它的行为完全保持原样;同时改变行为是另一桩更冒险的活。

他借用学校的算术:把 1/2 + 1/4 改写成 2/4 + 1/4,得到同样的 3/4,却是更便于处理的形式。他的 Go 例子对代码做同样的事——一堆按语言分支的 if 语句变成一个问候语映射加一个查找辅助函数——要点不在技巧,而在输出不变。他也引用福勒的检验:如果一个系统坏了好几天,那项工作就不是重构。同时做两件事,是各团队贪多嚼不烂的地方。

“选择不写测试的人,通常只能依赖手工测试。对任何不止是小项目的东西,这都会是巨大的时间黑洞,而且从长远看无法扩展。”

“Those who choose not to write tests will typically be reliant on manual testing. For anything other than a small project this will be a tremendous time-sink and does not scale in the long run.”

Why unit tests and how to make them work for you › When refactoring code you must not be changing behaviour查看原文 →
论点 3

因为手工验证无法扩展,单元测试才是使在不简单的系统上重构变得安全的东西。

没有测试的团队退回到手工检查,这是一个时间黑洞,一旦超出小项目就行不通。测试则提供三样东西:重塑代码的信心,对预期行为给人看的文档,以及比手工检查快得多、可靠得多的反馈。具体而言,针对问候函数的一个测试加上 go test,片刻之间就告诉他一次改动是否改变了行为,这就维持着“小重构/跑测试/重复”的循环,把复杂度压住。

“选择不写测试的人,通常只能依赖手工测试。对任何不止是小项目的东西,这都会是巨大的时间黑洞,而且从长远看无法扩展。”

“Those who choose not to write tests will typically be reliant on manual testing. For anything other than a small project this will be a tremendous time-sink and does not scale in the long run.”

Why unit tests and how to make them work for you › When refactoring code you must not be changing behaviour查看原文 →
另外 3 个论点
论点 4

测试应当瞄准通过组件公共 API 可见的行为,而不是它内部如何实现。

设想一个由两个三角形拼成的正方形,测试断言三角形的内角和为180度。数周后,一位开发者决定用两个长方形更好,却迎面撞上失败的测试,它们对唯一要紧的问题——真正的行为是否坏了——毫无回答。三角形的构造从来不重要,测试却把它提升到了需求的地位。作者把这追溯到对什么是单元的误解,以及追逐覆盖率这一虚荣指标。

“宁可测试行为,而不是实现细节”

“Favour testing behaviour rather than implementation detail”

Why unit tests and how to make them work for you › Favour testing behaviour rather than implementation detail查看原文 →
论点 5

写出有用的单元测试,实际上是一个关于系统如何划分的设计问题。

他请读者暂时忘掉测试:你本来就想要围绕领域概念的、解耦的组件,他把它们想象成带着连贯 API 的乐高积木,能组合成更大的系统,而几十个类型和函数在看不见的地方协作。一家银行的“账户”包是他的例子:一个不泄漏内部、易于集成的 API。把这些边界划对,行为层面的测试几乎就是白送的——这就是他对正方形和三角形的故事所暴露的问题的回答。

“宁可测试行为,而不是实现细节”

“Favour testing behaviour rather than implementation detail”

Why unit tests and how to make them work for you › Favour testing behaviour rather than implementation detail查看原文 →
论点 6

TDD 是让这个重构循环持续运转的工作过程,而不是试图预先设计出完美可扩展的系统。

接受了莱曼的观点——软件必须变化——有些人矫枉过正,陷入精心的预先设计:他描述的那个年代,六个月的需求文档,再六个月的架构,几年后项目失败,他指出这种事仍在发生。既然未来无法预测,他追随敏捷,迭代地工作,而 TDD 强制执行它:为一个小行为写一个小测试,一个清楚的红色失败,用最少的代码变绿,然后重构,重复。实践者逐渐期待那个循环是快的,而每当系统不是绿色时就感到不安,因为那预示着兔子洞。

“选择不写测试的人,通常只能依赖手工测试。对任何不止是小项目的东西,这都会是巨大的时间黑洞,而且从长远看无法扩展。”

“Those who choose not to write tests will typically be reliant on manual testing. For anything other than a small project this will be a tremendous time-sink and does not scale in the long run.”

Why unit tests and how to make them work for you › When refactoring code you must not be changing behaviour查看原文 →

它是怎样运作的

本章论证所依靠的 3 个机制。

机制 1

测试作为重构的安全网

只有行为保持完全一致,重塑代码才算重构,但在任何规模可观的系统上,一个人没法在脑子里装下所有行为。快速的单元测试自动检查这一不变式,所以作者可以走一小步,一秒内重跑它们,并在行为变化发生的当下就抓住,而不是几小时之后。

机制 2

为什么绑定细节的测试阻碍变化

当测试断言的是某物如何被构造,而不是它做什么,构造的选择就被冻住了。后来的开发者选了另一种内部构造,看到一堵失败之墙,它们并没有说明真实的行为是否坏了,所以他们得先发掘旧的测试——最终干脆不再尝试这个改动。

机制 3

单元边界决定测试的耐久性

如果系统被划分成围绕领域概念构建的自成一体的组件,每个组件暴露一个把内部隐藏起来的连贯公共 API,那么针对该 API 写的测试只可能是在断言值得保留的行为。API 之下的一切都保持可以自由重新排布,所以重构很少触动测试。

常见误读

你可能以为

如果目标是测试行为而不是内部,诚实的答案就是跳过单元测试,依靠端到端或黑盒测试。

其实

系统级测试对验证关键的用户旅程确实有价值,但作者把它们排除在重构安全网之外:它们写起来昂贵,跑起来缓慢,又不善于指出根因,所以它们的反馈循环太慢,支撑不了小步前进。恰当高度的行为测试,意味着通过单元的公共 API 测试,这既聚焦行为,又快。

“宁可测试行为,而不是实现细节”

“Favour testing behaviour rather than implementation detail”

Why unit tests and how to make them work for you › Favour testing behaviour rather than implementation detail查看原文 →
你可能以为

按定义,单元测试是对一个函数、类或方法的测试,所以通过 API 测试整个包一定是别的东西——集成测试。

其实

作者直接回答了这一点:这些就是单元测试,因为“单元”是自成一体、以领域为中心、带连贯公共 API 的组件,而不是单个函数或类。许多类型和函数可以在这个 API 之下协作;把最小的代码元素当作单元,恰恰把测试推进了实现细节,使它们阻碍重构。

“宁可测试行为,而不是实现细节”

“Favour testing behaviour rather than implementation detail”

Why unit tests and how to make them work for you › Favour testing behaviour rather than implementation detail查看原文 →

关于本书的核心概念

重构
重塑代码,使之更易理解而不触动行为,就像分解一个分数。
适用处: 如果系统坏了好几天,或行为发生了变化,那就不是重构。
单元
一个围绕关键领域概念构建、自成一体、解耦的系统部分,暴露一个连贯的公共 API。
适用处: 单元不是单个类或函数;许多类型可以在其 API 之下协作。
莱曼软件演化定律
真实使用中的软件必须变化,否则就失去价值,而除非花工夫降低,它的复杂度会增长。
适用处: 它们证明迭代式变化的正当,而不是数月的预先过度设计。
另外 0 个概念

关于本书划线与批注AI 陪读

Why unit tests and how to make them work for you › Software

“Go 只有25个关键字”

“Go has only 25 keywords”

查看原文 →

隐含的主张——关键字越少,代码越耐久——从未被论证。大量无法维护的 C 代码存在,而 C 也很小。约束有帮助,但遗留系统来自累积的决定,而不是词汇量的大小。

Why unit tests and how to make them work for you › The Law of Increasing Complexity

“除非做工作去降低,否则它的复杂度会增加”

“its complexity increases unless work is done to reduce it”

查看原文 →

悄悄的那个词是“工作”。降低复杂度是没人会开工单的劳动,这恰恰是它输给功能的原因。这条定律与其说关乎软件,不如说关乎激励。

Why unit tests and how to make them work for you › Refactoring

“你几乎可以确定他们不是在重构”

“you can be pretty sure they are not refactoring”

查看原文 →

福勒的这句话之所以管用,是因为它可证伪:如果构建红了两天,这个名字就叫错了。很少见到一个定义自带一项检验。

另外 8 条批注
Why unit tests and how to make them work for you › Factorisation

“把 1/2 改成 2/4,它就更容易适合我们的“领域”

“by changing 1/2 to 2/4 it fits into our "domain" easier”

查看原文 →

这个类比很迷人,但它奉承了重构。算术有判定等价的程序;代码没有。测试是对行为的抽样,而不是对它的证明——这个比喻恰恰隐藏了风险所在。

Why unit tests and how to make them work for you › An example in Go

“这次重构的性质其实并不重要”

“The nature of this refactor isn't actually important”

查看原文 →

值得注意的是,这个例子在精神上确实稍微改变了行为:映射把语言变成数据,这邀请了 if 链从不允许的运行时配置。结构是关于未来的一个主张。

Why unit tests and how to make them work for you › An example in Go

“got := Hello(“Chris”, es)”

查看原文 →

花体引号和一个光秃秃的 es——这段代码无法编译。在一本整个论证都建立在紧凑、值得信赖的反馈循环之上的书里,一个未经测试的代码示例,是一桩小而好笑的控诉。

Why unit tests and how to make them work for you › Why is this happening?

“我们的测试错误地抬高了我们实现细节的重要性”

“our tests have falsely elevated the importance of our implementation details”

查看原文 →

这是本章真正的命题,而三角形到长方形的寓言配得上它。每一个失败的测试都是在主张某件事要紧;写足够多草率的主张,你的测试套件就成了你已不再持有的决定的纪念碑。

Why unit tests and how to make them work for you › Favour testing behaviour rather than implementation detail

“追逐虚荣指标(测试覆盖率)”

“chasing vanity metrics (test coverage)”

查看原文 →

说得公道,但覆盖率之所以成了虚荣指标,是因为“测试行为,而不是实现”无法测量,而覆盖率可以。这个批评需要一个替代信号,而本章没有提供。

Why unit tests and how to make them work for you › Are these unit tests?

“它们从来都不只是针对单个类/函数/随便什么”

“They were never about only being against a single class/function/whatever”

查看原文 →

一个悄悄激进的举动:把“单元”重新定义为包级 API,凭一纸宣告消解了“什么都 mock”的学派。有说服力,但这意味着两个工程师可以都说“我们写单元测试”,而指的是互不相容的东西。

Why unit tests and how to make them work for you › Refactoring

“如果测试错误地失败,那么我们的测试处在错误的抽象层级”

“If tests are wrongly failing then our tests are at the wrong abstraction level”

查看原文 →

就其陈述而言,这是无法证伪的——任何痛苦的测试都可以归咎于抽象层级,而不是重构确实有风险。一条有用的启发,披着裁决的外衣。

Why unit tests and how to make them work for you › Wrapping up

“别过度设计,因为未来太难预测”

“don't try and over-engineer because it's too hard to predict the future”

查看原文 →

整章诚实的张力:设计好的“单元”本身就是对哪些概念将保持稳定的一次预测。TDD 并不消除这个赌注,只是使输掉它更便宜。

读后感AI 陪读

把单元重新定义为包级 API 是激进的,而不仅仅是澄清

本章的架构比它任何单个论证都更强:莱曼定律确立变化是强制的,重构被点名为回应,测试被引入作为不对自己撒谎地重构的唯一办法,而单元设计被揭示为决定测试是帮忙还是碍事的东西。这条链——尤其是最后一步,即写出好的单元测试是一个设计问题而不是一个测试问题——是这里最有价值的想法,而三角形/长方形的寓言,以你真能带进代码评审的形式把它交了出来。它最弱的地方,也是它最自信的地方。因式分解的类比悄悄借用了数学对等价的确定性,而测试给不了这一点。认为重构期间测试套件失败就证明测试处在错误的抽象层级,这个说法循环到足以为任何重构开脱。对覆盖率的攻击是对的,却让读者没有可替代的信号,而这恰恰是覆盖率起初得胜的原因。而把“单元”重新定义为包级 API,是一项真正的贡献,本章却把它呈现为单纯的澄清,而它实际上是个有争议的立场。那个偶然的喜剧——在一章主张值得信赖的反馈循环的文字里,有一段无法编译的测试片段——是个体面的提醒:纪律比说教更难。把它当作一个有说服力的框架而不是证明来读,它确实有用。

在 FeedLitra 里读关于本书,每段旁边都有这些卡片。

开始读书

论证在哪里吃力

不是反驳,而是细心的读者会想追问的地方。

  1. 桩和间谍被谴责为未经验证的假设的载体,而应用各章却恰恰用一个桩存储和一个间谍来搭建 HTTP 服务器,以打破 GET/POST 的死锁。

    “如果替身与真实依赖的行为之间有差异,或者随时间产生了差异(比如真实依赖变了,这是可以预料的),你可能有通过的测试,却有失败的软件。”

    “If there is a discrepancy between the behaviour of the double and the real dependency, or if one happens over time (e.g. the real dependency changes, which has to be expected), you may have passing tests but failing software.”

    Working without mocks, stubs and spies › The problem with stubs and mocks查看原文 →
  2. 重构被定义为从不触及测试的变更,而本书所教的、由发现驱动的 TDD,却一再重塑设计的接缝,因而也重塑它的测试。

    “如果你在“重构”某些代码时不得不同时修改测试,那你做的是别的事情。”

    “You're doing something else if you are "refactoring" some code and having to change tests at the same time.”

    Refactoring step, starting checklist › Refactoring vs other activities查看原文 →
  3. 为了逃离红色而硬编码或用烂泥糊弄被认可,同时又警告粗糙的实现会变得不可维护,却没有给出烂泥何时必须去掉的规则。

    “我知道只是硬编码结果感觉怪怪的,但用 TDD,我们想尽可能长时间地待在“红”之外。”

    “I know it feels weird just to hard-code the result but with TDD we want to stay out of "red" for as long as possible.”

    Roman Numerals › Refactor查看原文 →
  4. 测试既是安全变更的使能者,又是要最小化的成本,验收测试既是北极星,又昂贵且与设计耦合,所以各自合适的数量没有解决。

    “出于这个原因,只依赖验收测试是愚蠢的。它们不具备单元测试的许多品质,而拥有大量验收测试的系统,往往会在维护成本和糟糕的交付周期方面受苦。”

    “For this reason, it is foolish to only rely on acceptance tests. They do not have many of the qualities unit tests have, and a system with a large number of acceptance tests will tend to suffer in terms of maintenance costs and poor lead time.”

    Introduction to acceptance testing › Potential drawbacks vs unit tests查看原文 →
另外 4 处张力,以及 8 条证据上的局限
  1. 力主只测试公共 API,然而编译器错误和实现层面的选择——如指针接收者或 strings.Builder——一再驱动下一个测试,模糊了行为边界在哪里。

    “宁可测试行为,而不是实现细节”

    “Favour testing behaviour rather than implementation detail”

    Why unit tests and how to make them work for you › Favour testing behaviour rather than implementation detail查看原文 →
  2. 反射和 interface{} 被详细讲授,随后又被建议不要用,而通道、互斥锁和原子操作各自被提供为共享状态的解法,除了单个值对多字段之外,没有任何决策规则。

    “作为一个接受 interface{} 的函数的使用者,你失去了类型安全。”

    “As a user of a function that takes interface{} you lose type safety.”

    Reflection › So why not use interface{} for everything and have really flexible functions?查看原文 →
  3. 有纪律的最小化 TDD,让一个看似合理却错误的取消实现一路通过,直到增加了一条额外的断言,表明这个循环本身并不保证有一套完整的测试。

    “靠着自律,它凸显了我们测试中的一个缺陷,这是件好事!”

    “By being disciplined it highlighted a flaw in our tests and this is a good thing!”

    Context › Write enough code to make it pass查看原文 →
  4. 把规格说明与驱动分开、提取共享抽象会增加间接层,这与重构建议里“清晰来自去掉结构而不是添加结构”的说法相处得不安。

    “把规格说明如何被执行解耦,使我们能在不同场景中复用它。”

    “Decoupling how the specification is executed allows us to reuse it in different scenarios.”

    Learn Go with Tests - Scaling Acceptance Tests (and light intro to gRPC) › Testing on steroids查看原文 →

证据上的局限

  1. 每一课都是在刻意琐碎的练习上演示的——Hello、Sum、Wallet、Dictionary、一个钟面、一个小小的扑克应用——所以关于大型系统或团队约束的说法,依靠论证而不是演示。

    “选择不写测试的人,通常只能依赖手工测试。对任何不止是小项目的东西,这都会是巨大的时间黑洞,而且从长远看无法扩展。”

    “Those who choose not to write tests will typically be reliant on manual testing. For anything other than a small project this will be a tremendous time-sink and does not scale in the long run.”

    Why unit tests and how to make them work for you › When refactoring code you must not be changing behaviour查看原文 →
  2. 性能数字来自作者的一台机器、一个20毫秒的睡眠桩而不是真实的 HTTP 调用,以及作者自己也承认通常可以忽略的一个模板基准测试。

    “Go 中的字符串是不可变的,这意味着每次拼接,比如在我们的 Repeat 函数里,都涉及复制内存以容纳新字符串。”

    “Strings in Go are immutable, meaning every concatenation, such as in our Repeat function, involves copying memory to accommodate the new string.”

    Iteration › Benchmarking查看原文 →
  3. 关于测试替身和糟糕设计导致糟糕测试的论证,依靠的是轶事——一个六个 API 的故事,一个数据库连接池的例证,读者的片段明确说明不是调查。

    “如果替身与真实依赖的行为之间有差异,或者随时间产生了差异(比如真实依赖变了,这是可以预料的),你可能有通过的测试,却有失败的软件。”

    “If there is a discrepancy between the behaviour of the double and the real dependency, or if one happens over time (e.g. the real dependency changes, which has to be expected), you may have passing tests but failing software.”

    Working without mocks, stubs and spies › The problem with stubs and mocks查看原文 →
  4. 关键的并发证据是非确定性的:并发映射写入的失败是间歇性的,而 synctest 的发现依赖于特定的运行时调度,可能无法推广。

    “Go 的映射对并发写入不安全,所以运行时会抛出致命错误,以防内存损坏。”

    “Go maps are not safe for concurrent writes, so the runtime throws a fatal error to prevent memory corruption.”

    Concurrency › ... and we're back.查看原文 →
  5. 数个所教的解法被承认只是粗糙的:被承认脆弱的削前缀式路由,WebSocket 测试里的一个计时 sleep,以及在不同目录运行二进制文件会悄悄创建两个数据库文件。

    “这个测试迫使我们真正去看请求的 URL 并作出决定。”

    “This test has forced us to actually look at the request's URL and make a decision.”

    HTTP Server › Write enough code to make it pass查看原文 →
  6. 保证是局部或碰巧的:可测试示例只有带 Output 注释时才会执行,精确的浮点比较只有因为所选的值是整数才安全,而未检查的错误只能靠外部的 errcheck 检查器发现。

    “代码库之外的代码示例,比如 readme 文件里的,往往会随着时间过时、与实际代码不符,因为它们没有被检查。”

    “Often code examples that can be found outside the codebase, such as a readme file, become out of date and incorrect compared to the actual code because they don't get checked.”

    Integers › Testable Examples查看原文 →
  7. 所有指导都绑定在 Go 特定的表面和工具链版本上——modules、io.Reader、context、errors.As、httptest、编辑器快捷键清单——留下向其他语言迁移未经检验。

    “使用模块相当直接。选择 GOPATH 之外的任何目录作为项目根,用 go mod init 命令创建一个新模块。”

    “Using Modules is pretty straightforward. Select any directory outside GOPATH as the root of your project, and create a new module with the go mod init command.”

    Install Go, set up environment for productivity › Go Modules查看原文 →
  8. 值得注意的结论是判断而非已演示的结果:网站不需要 SPA,HTML 不值得测试,测试文件夹是错误的取舍,以及 context 放第一位是因为 Google 这么做。

    “许多网站并不需要是单页应用(SPA)。HTML 和 CSS 是交付内容的绝佳方式,而你可以用 Go 做一个交付 HTML 的网站。”

    “Many websites do not need to be an SPA. HTML and CSS are fantastic ways of delivering content and you can use Go to make a website to deliver HTML.”

    HTML Templates查看原文 →

全书概念导图

5 组概念:通俗的叫法、一句话解释,以及原书自己的用语。

1 · 先失败,廉价通过,再收拾整齐
写一个失败的测试,粗糙地让它通过,再改进它
写一个失败的测试,做最少的工作让它通过——甚至硬编码或用“烂泥”(slime)糊弄一个返回值——然后重构;从钢线或验收测试由外向内生长,让红色状态保持尽可能短。原书用语: TDD cycle
在测试持续通过的同时,重塑代码的内部
为清晰而重塑代码,行为不变,所以测试理想情况下不需要编辑;更大的、部分带主观性的结构变更是“大设计”,其本身必须以小而有测试支撑的步骤来推动。原书用语: Refactoring
一个测试遍历一份具名用例的列表
一个测试体,用子测试遍历一份具名的输入/期望用例切片,所以新场景几乎不花样板代码——但当不相关的用例被硬塞进来作为额外标志时就会变差。原书用语: Table driven tests
检验一条规则对许多输入都成立,而不是几个挑出来的
陈述一条领域真理——罗马数字的减数规则,单位圆——的测试,并让一个库向它扔大量生成的输入,由你自己的生成器把这些输入保持在领域之内。原书用语: Property-based tests
2 · 从外部测试,把依赖传进来
如果一个测试写起来很痛苦,设计就有问题
写测试的困难被读作关注点分离不佳或对调用者不顺手的证据,所以修复应属于生产设计,而不是仅供测试的模式。原书用语: Testability as a design signal
只命名调用者需要的方法,使各部分可以替换
一个具名的、只含调用者需要的方法的集合,被隐式满足,所以依赖可以被替换;保持它小,只有当真有别的东西要传进来时才发明一个。原书用语: Interface as a seam
把协作者从外面递进来,而不是自己构造它们
把协作者从外面传入,而不是在内部构造或去取全局变量,所以测试提供假实现,而生产提供真实的东西。原书用语: Dependency injection
接受标准的流类型,使任何来源都能接上
标准的单方法 IO 抽象;因为文件、os.Stdout、http.ResponseWriter、WebSocket 连接和 strings.NewReader 都满足它们,一套代码就能服务测试和每一种前端。原书用语: io.Writer and io.Reader
3 · 假实现与整个系统的测试
照剧本行事的替身,可以对真实的东西撒谎
对真实依赖的任何可控替身,从填满参数位置的哑对象,到记录调用、时长和次序的间谍,使原本不可见的行为得以被断言。原书用语: Test double (mock, stub, spy)
一个简单可用的替身,靠一个共享的测试保持诚实
依赖的一个真实的、有状态的、通常在内存里的实现,之所以被信任,是因为针对接口写的一套可复用测试,同时对它和真实的东西运行。原书用语: Fake verified by a contract
把整个东西启动起来,像真实用户那样驱动它
一种黑盒测试,像用户或业务那样从外部启动并驱动整个系统,只有当用户可见的功能端到端地工作时才通过。原书用语: Acceptance test
把规则与你戳系统的方式分开
把本质的领域复杂度拆成一份不含实现的规格说明,与偶然复杂度——网络、传输方式、API——分开,由一个把它适配到特定系统的驱动去处理。原书用语: Specification and driver
4 · 把正确性放进类型里
把失败作为可以检查和匹配的值返回
通过返回值来发出失败信号——用 errors.Is 比较的哨兵错误,用 errors.As 检查的携带数据的结构体,用 %w 包装以添加上下文而不丢掉原因。原书用语: Error as an inspectable value
塑造类型,使坏值根本造不出来
结构体,建立在现有类型之上、带着自己方法的具名类型,以及泛型类型参数,使领域之外的值无法被表达——与 interface{} 只在运行时检查正相反。原书用语: Types that exclude invalid values
依靠 Go 自带的包,而不是你自己的
所提供的各个包——ServeMux、httptest、JSON、带 go:embed 的 html/template、time、os/exec——被呈现为对应用及其测试都足够,无需框架。原书用语: Go standard library
5 · 并发和速度需要证据
在改动前后给代码计时,以证明它变快了
一个函数,其循环由框架计时,用来测量一次改动前后,使性能说法成为测量,而不是断言。原书用语: Benchmark
把工作分开运行,并通过通道传递结果
用 go 启动的独立过程,通常是匿名函数,通过发送和接收值来通信;select 等待多个通道,并运行最先就绪的那个。原书用语: Goroutines and channels
两个线程碰同一个值,就需要一把锁
对共享内存的并发冲突访问,用 -race 检测器发现,并通过串行化修复:多步不变式用互斥锁,单个值用原子操作,等待完成用 WaitGroup。原书用语: Data race and synchronisation
把取消信号向下传递,使工作可以被停止
一个沿调用链向下穿的值,向从它派生的一切发出取消信号并携带原因,time.After 则给出可测试的超时。原书用语: Context

值得停下来的段落AI 陪读

几处值得停下来的原文,每处附一句它为什么对全书要紧。

Go 基础 · Hello, World › How to test

“把你的“领域”代码和外部世界(副作用)分开是好事”

“It is good to separate your "domain" code from the outside world \(side-effects”

查看原文 →

放到全书的尺度来读,这句旁白就是此后每一章重新推导的架构命题:领域与副作用之间的接缝之所以存在,是因为可测试性,而不是因为优雅。

问答 · Revisiting HTTP Handlers

“如果你的测试给你带来痛苦,就倾听这个信号,并思考你代码的设计。”

“If your tests are causing you pain, listen to that signal and think about the design of your code.”

查看原文 →

整本书为之论证的那一句话——测试的痛苦是设计信号——它回溯地把 DI、模拟、context 和 HTTP 各章解释成同一个想法。

测试基础 · Refactoring step, starting checklist › Refactoring vs other activities

“如果你在“重构”某些代码时不得不同时修改测试,那你做的是别的事情。”

“You're doing something else if you are "refactoring" some code and having to change tests at the same time.”

查看原文 →

给书里用得最多的词一个可证伪的定义,把“重构”从一种心情变成测试套件能裁决的主张。

另外 9 条批注
Go 基础 · Mocking › Still some problems

“干脆把它弄坏!(不过先确保你已把改动提交到源码管理)”

“just break it! (make sure you have committed your changes to source control first though)”

查看原文 →

整个方法的认知底线:在你亲眼看到测试因正确的原因失败之前,绿色毫无意义——这一步把 TDD 与事后补测试的作秀区分开来。

Go 基础 · Context › Write enough code to make it pass

“靠着自律,它凸显了我们测试中的一个缺陷,这是件好事!”

“By being disciplined it highlighted a flaw in our tests and this is a good thing!”

查看原文 →

点出了本书悄悄的逆转:测试是规格说明,而不是验证,这就是为什么这么多章节发现的是缺失的约束,而不是缺陷。

Go 基础 · Revisiting arrays and slices with generics › A generic refactor

“人们对闪亮的新语言特性常犯的一个错误,是在没有具体用例时就开始使用它们。”

“A mistake people often make with shiny new language features is they start by using them without having a concrete use-case.”

查看原文 →

全书最强的纪律主张——抽象只有从具体案例中提取才安全,这同样控诉了过早的泛型、过早的接口和过早的包。

构建一个应用 · HTTP Server › Red, green, refactor

“尽快让测试通过,过程中犯什么罪都行。”

“Make the test work quickly, committing whatever sins necessary in process.”

查看原文 →

许可了应用部分所依靠的 TDD 的“犯罪”那一半,而这恰是各团队引用、却跳过不可商量的重构的那一半。

Go 基础 · Dependency Injection

“我们需要做的是能够注入(这只是传入的一个花哨词)打印这个依赖。”

“What we need to do is to be able to inject \(which is just a fancy word for pass in\) the dependency of printing.”

查看原文 →

把本书最令人畏惧的企业概念压成函数参数,而这种压平,正是让后面每一个可测试的设计在事后看来都显而易见的原因。

构建一个应用 · WebSockets › How do we test we return the correct markup?

“所以在《Learn Go with Tests》这本书里,我们第一次不打算写测试。”

“So in the book "Learn Go with Tests" for the first time, we're not going to write a test.”

查看原文 →

一本 TDD 的书标出 TDD 在哪里不再划算,是为其余四十章换来可信度的代价——这个方法被呈现为判断,而不是教条。

Go 基础 · Reading files › Iterative, test-driven development

“乐观是编程的职业病。反馈是治疗。”

“Optimism is an occupational hazard of programming. Feedback is the treatment.”

查看原文 →

对本书的循环究竟为何存在的最干净陈述:TDD 是对程序员性格缺陷的治疗,而不是栓在代码上的质量流程。

测试基础 · Working without mocks, stubs and spies › tl;dr

“这些假设通常除了手工检查外没有被验证,所以它们威胁你测试套件的用处”

“These assumptions are usually not validated beyond manual checking, so they threaten your test suite's usefulness”

查看原文 →

事后重新框定了那几章模拟——危害从来不是测试替身,而是跨整套测试重复的未经验证的假设。

关于本书 · TDD Anti-patterns

“别误以为是 TDD 难,难的是设计!”

“Don't mistake this for TDD being hard, it's design that's hard!”

查看原文 →

值得留作本书自我封闭的一招:把每一次 TDD 的失败都重新归于设计技能,使这个方法即使在大体正确的地方也难以证伪。

读后感AI 陪读

假装在教 Go,实则对单一设计主张做压力测试

通读全书,这并不真是一本 Go 教程,到一半时几乎已不再假装是了。它真正的主题是一个主张,在“为什么要写单元测试,以及怎样让它们为你所用”一章提出,随后经受四十章的压力测试:软件必须变化,变化需要重构,重构需要测试,因此你测试的质量就是你设计的质量。其余一切——语法、goroutine、模板、泛型、WebSocket——都是借口,用来给这一主张施压,看它是否成立。主线分三个乐章。第一,入门各章(5–24),其中的琐碎问题是刻意选的,好让循环本身成为课程:Hello World 的存在,是为了让你观看失败,甚至破坏 t.Helper() 以看行号如何变差;整数让 return 4 作为正当的反对意见存在,而不是挥手打发;迭代把基准测试夹带进来,仿佛测量是寻常之事;指针和映射递给你的是一个矛盾(两个地址,一次对 nil 写入的 panic),而不是一条要背的规则。第二,设计各章(12–18、25–28、41),其中本书不再教 Go,而开始论证:依赖注入和模拟把测试的痛苦重新框定为设计反馈;Context 上演了一场两幕的背叛,一个通过测试、忠实遵循 TDD 的服务器,因为不合惯用法而被扔掉;“不用 mock、stub 和 spy 工作”一章控诉整个桩与间谍的传统散布未经验证的假设,并以有状态的假实现回应,其纪律来自对假实现和真实依赖都运行的契约;“扩展验收测试(以及 gRPC 简介)”一章把规格说明与传输方式分开得如此令人信服,以至于一个全新的协议落地而不触动一个测试。

第三,应用各章(29–40),其中方法被迫在一个真实的代码库里存活——一个被发现而不是被规划出来的记分牌服务器,作为一连串让步而不是设计而到来的持久化,一个真正主题是包边界的命令行,以及一个扑克游戏,其依赖膨胀被读作一个 Game 领域类型想要存在的信号。最尖锐的判断,是那些违背读者直觉的。不可测试的代码不是需要一个测试标志的代码;它是设计糟糕的代码(37、40)。一套通过的测试不是良好设计的证据(18、35,其中两次习惯性的 -race 运行,推翻了看似已完成的测试)。抽象属于重构步骤,从你已经写好的代码中发现,而不是预先设计(21、24)——并且当它挣不回自己的开销时,应毫不羞愧地撤销(16)。反射被透彻地讲授,然后作为警告被撤回,作者承认实现仍会在指针环上崩溃。基于性质的测试被表明能浮出示例遗漏的真理,随即又被纠正:在断言里加守卫会掩盖不匹配,你必须收窄生成(19)。模板应保持无逻辑,因为其中的逻辑只能靠比较渲染出的 HTML 来测试(22)。重构由一项机械的检验来定义——你的测试是否必须改变?——随后本书遵循自己的规则,先重构生产代码,再重构测试,绝不两者同时(28、34)。诚实就是教学法:验收测试得到一份坦率的缺点清单(25),作者承认他并不相信自己那个通过的库,“数学在今天依然有用!”的推销从未真正兑现,而“构建一个应用”一章虚构的产品负责人被承认只是一种方便。我想对读者说的是:不要略读那些小章节,也不要为了 Go 来读这本书。

继续阅读

玩具问题是循环变成肌肉记忆的地方,而后面的各章若只当教程来读就读不通——“不用 mock、stub 和 spy 工作”一章只有在你感受过模拟那一章的不安之后才会落地,“扩展验收测试(以及 gRPC 简介)”一章只有在你看过测试与实现耦合并腐烂之后才会落地。敲下代码,让编译器说话,让步子比体面所允许的更小,把每一个别扭的测试都当作关于你设计的信息,而不是要完成的杂务。本书最后的礼物,在“TDD 反模式”一章送出,是允许你不再麻痹这种痛苦:测试不是覆盖率的义务,它们是你所能得到的、关于你真正在构建的东西的最廉价的诚实反馈。

关于作者:克里斯·詹姆斯(Chris James)

Go TDD 作者,常驻伦敦的工程经理

  • 工程经理,据其 DEV Community 个人资料,常驻伦敦2
  • 六期 Go Time 播客节目的嘉宾4
  • 《Learn Go with Tests》(本书)1
  1. 书评:Chris James 的《Learn Go with Tests》 - DEV Community
  2. Chris James — DEV Community 个人资料
  3. 有 Chris James 出场的播客节目

网络书评怎么说

摘录中的评论者称赞这本免费开源的书,通过测试交互式地教授 Go:快速的反馈,运行的示例而不是抽象的话语,覆盖从类型和指针到 context 和并发,外加 Web 和命令行应用。几位评论者把它框定为一本用 Go 写的 TDD 与软件设计书,推荐给新手。有一条抱怨:后面几章的代码片段摆放位置不清楚。

综合已发表书评的看法;不展示评分,完整书评请点来源链接。

  1. Learn Go with Tests - 书评 - DEV Community
  2. [书评] Chris James 的《Learn Go with Tests》 - DEV Community
  3. learn go with tests - Simon Willison
  4. 学习 Go - DEV Community

来源与授权

MIT原书

  • 《Learn Go with Tests》(通过测试学习 Go),作者 Chris James(quii)。Copyright (c) 2025 Christopher James。以 MIT 许可证发布(https://github.com/quii/learn-go-with-tests/blob/main/LICENSE.md)。免费阅读原书 · 许可证
  • 摘要、论点、批注和读后感由 FeedLitra 根据全书原文 AI 辅助生成,引文已对照原文核对。最近更新:2026 年 10 月 22 日。

在 FeedLitra 里一边读《通过测试学习 Go》,一边看这些卡片。

开始读书