速成课 · No. 21

test 不过是自动检查你代码的代码。人们以为它的职责是证明今天的代码能用——但真正的回报在后面:正是 test 让你明天能够修改、重构、添加功能,而不破坏已经正常工作的东西。学会这些种类和术语,测试就不再是苦差事,而变成那个让你跑得飞快的东西。

只讲精髓 · 每个想法一个画面 · 掌握术语

§ 01

几乎所有人都误解了 test 的用途。它们看起来像一种证明代码能用的手段;但它们真正的价值更深刻、也更实用——看清这一点会改变你写它们的方式。

test 是检查代码的代码

另一个人在计算器上把你的算术重做一遍,并标记出任何对不上的——自动、不知疲倦,而且每次都一样。

test 是一小段代码,它用一个已知的输入来运行你的代码,并检查结果是否如你所料。「给定 2 和 3,add 会返回 5 吗?」如果是,test 就 pass;如果不是,它就 fail 并告诉你。这就是全部机制。它的威力在于自动且可重复——你可以在几秒内运行成千上万次这样的检查,每次改动任何东西时都跑一遍,而不需要人工逐一手动复核。

真正的意义是无所畏惧地修改

攀岩者身上的安全保护带:它不替你攀爬,但它让你敢于尝试一个大胆的动作,因为一次失手不会致命。你之所以爬得更猛,是因为即便摔下也有人接住。

误解就在这里。test 主要不是为了证明今天的代码正确——它们关乎的是 明天。当你修改代码、重构它,或添加一个功能时,一套好的 test 套件会立刻告诉你,是否破坏了某个原本能用的东西。正是那张安全网,让你能够自信地修改一个庞大的代码库,而不是害怕去碰任何东西。test 是你保持飞快前进、而不让东西在你背后悄悄崩坏的方式。

它们自动捕捉 regression

一栋房子里布满一排排绊线:某个你根本没在看的房间一被惊动,警报就响起来——你不必亲自巡视每个房间。

regression 是指一个改动破坏了某个原本能用的东西——往往发生在离你所碰之处很远的地方。没有 test,regression 会一直藏着,直到某个用户发现它。有了 test,你的改动一破坏某个旧行为,就有一个 test 变红并当场指出它。这就是日常的回报:你做一个改动,跑一遍 test,立刻就知道自己是否也在别处弄坏了什么。在你于一个房间里工作时,套件替你盯着整栋房子。

test 自动检查代码。它真正的价值不是一次性的证明——而是明天无所畏惧地修改、而不悄悄破坏今天还在工作的东西的自由。

§ 02

测试的基石是 unit test:对一小段代码单独进行的、快速而专注的检查。你大部分的 test 都会是这种,而且理由充分。

在 isolation 中测试一小块

在用一块 Lego 积木搭建之前,先检查它的形状和尺寸是否正确——而不是把整座城堡都拼好,才发现有一块不对。

unit test 检查一个小单元——通常是单个函数——在与系统其余部分 isolation(隔离)的状态下。给它一个输入,检查它的输出,不牵涉其他任何东西。因为这一块又小又独立,所以当 test fail 时,你确切地知道是什么坏了、坏在哪里。unit test 是测试的显微镜:窄、精确,而且一次只对准一样东西。

又快又多才是核心思想

一个拼写检查器,眨眼之间就扫完整篇文档——快到你可以不停地运行它,而不是一个月才做一次的缓慢审阅。

因为每个 unit test 都极小、且不碰任何外部的东西,它运行只需 毫秒——所以你可以拥有成千上万个,并在每次改动时把它们全跑一遍,不停地跑。那种速度正是关键:一套你能在几秒内跑完的 test 套件会被一直跑,从而在破坏发生的那一瞬间就抓住它。一套慢的套件会被跳过,而被跳过的套件什么也保护不了。又快又多,正是 unit test 成为主力的原因。

Arrange、act、assert

一个简单的实验:搭好条件,执行那一个动作,然后检查结果。清晰的搭建、一个动作、一次验证。

一个好的 unit test 有三个朴素的步骤,常被称为 arrange、act、assert:搭好输入,把你要测的东西调用一次,然后 assert 结果是否如你所料。assertion 就是那个检查本身——「答案必须等于 5。」让 test 保持这种干净的形状,会使它易读,也使一次 fail 显而易见。一个难读的 test,没人会信任它,也没人会维护它。

unit test 在 isolation 中检查一小块,只需毫秒——所以你不停地跑成千上万个。arrange 好输入,act 一次,assert 结果。

§ 03

unit test 单独检查各个部件,但软件是部件协同工作。两种更宽的 test 覆盖了这一点——而三者之间正确的平衡有一个值得了解的形状。

integration test 检查部件协同工作

在检查完每一块 Lego 积木之后,你把几块拼在一起,确认它们真的能接上——积木单独看都没问题,但它们合得来吗?

integration test 检查若干部分是否能正确地 协同 工作——你的代码与数据库对话,两个服务交换数据。各个单元单独看可能都完美无缺,却仍然连不上:一个不匹配的格式,一个对另一方的错误假设。integration test 抓的正是这些接缝。它们比 unit test 慢,因为牵涉更多真实的活动部件,但它们验证的是 unit test 按设计会跳过的那些接合处。

end-to-end test 像用户一样使用整个系统

整出戏从头到尾的彩排,在真实的舞台上——不是单独检查台词或道具,而是观众将会看到的整场演出。

end-to-end test(e2e)像真实用户那样驱动整个系统——点击按钮、填写表单、检查整个技术栈上该发生的事是否发生了。它是最贴近现实的 test,也是对产品真的能用这件事最有价值的确认。但它也是最慢、最脆弱的:许多部分必须全部运行起来,而一个小小的 UI 改动就能把它弄坏。你把 e2e test 用在那少数几条关键路径上,而不是用在所有东西上。

test pyramid 把它们平衡起来

金字塔之所以立得住,是因为它底宽顶窄——把它倒过来立在尖上,它就会翻倒。这个形状本身就是稳定性。

test pyramid 是关于平衡的经验法则:底部 大量 快速的 unit test,中间 较少 的 integration test,顶部 寥寥几个 慢速的 end-to-end test。这个形状以低廉的代价给你又广又快的覆盖,再在顶上加恰好够用的贴近现实的检查。反模式——大量慢速的 e2e test 和寥寥几个 unit test——就是那个「冰淇淋甜筒」,它又慢、又 flaky、又痛苦。瞄准金字塔,而不是甜筒。

unit test 单独检查部件,integration test 检查它们协同,e2e test 像用户一样检查整个系统。守住金字塔:快的多,慢的少。

§ 04

为了在 isolation 中测试一块,你常常得替它所依赖的真实东西做替身。这就是 mock 和 stub——它们既有用,也同样容易被误用。

用一个假的替换真实的依赖

用飞行模拟器而非真飞机:假的仪表和景象让你安全地练习那一项技能,而不必承担真正飞行的成本和风险。

为了在 isolation 中测试一块,你把它的真实依赖——一个数据库、一个支付服务、一个邮件发送器——替换成替它们做替身的 fake。这让你能在不动用那个又慢、又贵、或不可预测的真实东西的情况下测试你的代码。你不会想让一个 unit test 真的去刷一张卡或发一封邮件;你换上一个假装去做的 fake,于是 test 保持快速、可靠、自成一体。

stub 给出预设的答案;mock 检查那次调用

stub 是一个纸板剪影,只在提示时说一句台词;mock 则是一个演员,他还会向你回报你到底对他说了什么、怎么说的。

两种 fake 常被混淆。stub 只是返回一个固定的、预设的答案——「假装数据库返回这个用户」——这样你就能测试你的代码如何处理那个输入。mock 还会 验证那次交互——「检查我的代码是否恰好调用了一次 sendEmail,且用的是这个地址。」stub 给你的代码喂料;mock 盯着你的代码对它的行为。要供给数据就拿 stub,要 assert 某个副作用发生过就拿 mock。

过度 mock 会造出说谎的 test

用纸板替身代替所有其他演员来排一整出戏——你可能把自己的台词演得分毫不差,却依然完全不知道真正的演员阵容到底能不能配合起来。

fake 很强大,但过量时很危险。如果你 mock 掉 所有东西,你的 test pass 的只是你对其他部分如何表现的假设——而不是它们实际如何表现。test 变绿了,而真实的 integration 却是坏的。所以去 mock 那些慢的、外部的、或不可预测的依赖,但别把你正要验证的那个东西本身也假掉。一个完全建立在 mock 之上的 test,可能在真实系统失败时依然 pass——这是最危险的那种绿。

mock 和 stub 替真实依赖做替身,好让你在 isolation 中测试。stub 供给数据;mock 验证一次调用。mock 过头,test 就会在现实崩坏时依然 pass。

§ 05

除了 test 的种类,还有写出好 test 的手艺——以及一种纪律,test-driven development,它颠倒了通常的顺序,并改变你设计代码的方式。

TDD:先写 test

开枪前先画好靶子,而不是事后——这样你瞄准的是一个明确的目标,而不是绕着箭碰巧落下的地方再画一个靶心。

test-driven development(TDD)颠倒了通常的顺序:你在写代码 之前 先写 test。它的循环是 red、green、refactor——为你想要的东西写一个会 fail 的 test(red),写恰好够让它 pass 的代码(green),然后在 test 的守护下把代码整理干净(refactor)。先写 test 会逼你在动手搭建之前就确切地定义成功意味着什么,并且保证代码是可测试的,因为它本就是从一个 test 中诞生的。

测试行为,而非实现

评判一位厨师,看的是这道菜味道对不对,而不是他用哪只手握刀——在意结果,而非他如何抵达结果的那些私下细节。

经久耐用的 test 最重要的习惯:检查代码 做了什么,而不是它 怎么做 的。测试 getDiscount 是否返回了正确的价格,而不是它是否按顺序调用了三个特定的内部辅助函数。绑在结果上的 test 能熬过重构——你可以重写内部,它们依然 pass。绑在实现上的 test 则在你每次整理代码时都崩坏,这会训练人们去不信任、并删掉它们。测试行为,test 才会一直有用。

好的 test 就是文档

一套写得好的 test 读起来像一串承诺:「给定这个,它就做那个」——比任何关于代码该如何表现的注释都更清楚。

一套清晰的 test 套件兼具 活文档 的作用。每个 test 陈述一个承诺——「给定一个空购物车,总额为零」——而且因为这些 test 会运行,那份文档永远不会像注释那样过时;如果行为变了,test 就会崩坏。所以,写 test 是为了被读:清晰的命名、每个只测一个行为、显而易见的 arrange-act-assert。一个新人应当能通过读代码的 test 来弄懂代码做什么。那种可读性,和检查本身一样值钱。

先写 test(red-green-refactor),测试行为而非实现,并让它可读——这样 test 能熬过重构,并兼作不会过时的文档。

§ 06

两个数字和一个麻烦,决定了一套 test 套件究竟是否真的可信。误读 coverage,或容忍 flaky,一套绿的套件就不再意味着任何东西。

coverage 衡量测了什么,而非测得多好

一张地图显示巡逻队开过哪些街道——拿来发现从未到过的街区很有用,但开车经过一栋房子,并不意味着你检查过它的门锁。

coverage 是你的代码在 test 运行期间被执行到的百分比。它是一个有用的向导,用来找出那些 完全 没被测到的部分——从未巡逻过的街道。但高 coverage 并不意味着好的 test:代码可以被一个什么有意义的东西都不 assert 的 test 执行到,刷出 100% 却几乎没验证什么。把 coverage 用来发现盲点,而非当作质量的证明。一行被碰到,和一行被检查过,不是一回事。

别让 coverage 变成目标

按刷了多少英里路来付工钱给工人,然后看着他们在空地上刷出又长又没用的线来凑数——指标上去了,路却没变得更安全。

当一个数字变成目标,人们就会优化那个数字,而不是它本来衡量的东西——又是 Goodhart 定律。要求「100% coverage」,你得到的就是为碰到代码行而写、而非为抓 bug 而写的 test:什么都不 assert 的空壳 test,钻指标的空子。coverage 是一支用来找缺口的手电筒,而不是一座用来最大化的奖杯。去追真正的信心——这些 test 真的能抓住破坏吗?——并让 coverage 成为众多线索之一,而绝非目标本身。

一个 flaky test 比没有 test 更糟

一个无缘无故随机响起的火警:不出一周,所有人就彻底无视它,于是真有火灾时,没人动弹。

flaky test 是指代码没变,却时而 pass 时而 fail 的 test——通常源于时序、顺序,或一个隐藏的依赖。flaky test 是毒药:当一个红的结果可能只是噪声时,人们就不再信任任何红,而一个真正的 fail 会被当作「八成只是 flaky」而放行。一个 test 必须是 deterministic(确定性的)——同样的代码,同样的结果,每一次都如此。修好 flaky test,或者删掉它们;一套你不信任的套件什么也保护不了。

coverage 显示测了什么,而非测得多好——别把它当目标。而一个 flaky test 比没有更糟:一旦红可能意味着噪声,每一个红都会被无视。

§ 07

测试是判断,不是仪式。本事在于以正确的比例测试要紧的东西,并把它接进你的发布方式——好让套件赢得信任,而不是沦为无用功。

测试有风险的部分,而非所有东西

你会反复检查降落伞和刹车;你不会去给杯架做压力测试。力气花在失败真正会痛的地方。

并非所有代码都配得上同等的测试。把力气集中在一个 bug 代价高昂或可能性大的地方:核心业务逻辑、金钱、安全、棘手的边界情况、经常变动的部分。琐碎、低风险的代码可以少测一些。追着给所有东西都写 test 是浪费力气,还会造出一套又大又慢、没人去跑的套件。瞄准那些能抓住你真会后悔的失败的 test,而不是瞄准一个数字——覆盖要紧的东西,胜过覆盖所有东西。

在每次改动时自动运行 test

一道闸机,除非你的票有效,否则不会打开——检查被建进了闸门里,而不是听凭某人是否记得去看一眼。

test 只有真的运行起来才保护你。把它们接进你的 pipeline,让它们在每次改动时自动执行,并在它们 fail 时拦住合并——就是发布课里那道 CI gate。这正是把 test 从一个你也许会跑的东西,变成一道「没有坏掉的东西会被合并进来」的保证。「有 test 否则就算没发布」是值得坚守的标准:一个未经测试的改动上到生产,是一场你本不必下的赌。

在你信任一套 test 套件之前
  • 它是否让你能自信地修改——它明天能抓住一个 regression 吗? - 它的形状是否像金字塔——大量 unit、一些 integration、寥寥几个 e2e? - test 是否检查行为,而非会在重构时崩坏的内部实现? - mock 是否用在真正的外部依赖上,而非把被测的东西本身也假掉? - 它是否快速且 deterministic——没有 flaky test 在侵蚀信任? - 它是否在每次改动时跑 CI,拦住坏掉的合并?
你现在掌握的术语
  • test / pass / fail / assertion——对代码的一次检查、它的结果,以及那个检查本身。 - regression——一个改动破坏了某个原本能用的东西。 - unit / integration / end-to-end (e2e)——一块、多块协同、整个系统。 - test pyramid——大量快速的 unit test、较少的 integration、寥寥几个 e2e。 - mock / stub——一个验证调用的 fake、一个供给数据的 fake。 - TDD / red-green-refactor——先写 test,循环往复。 - coverage / flaky / deterministic——测了什么、随机 fail 的陷阱,以及解药。
你测试得好的迹象
  • 自信地修改代码,信任套件会抓住你弄坏的东西。 - 你的 test 构成一个 金字塔——底部快,顶上几个贴近现实的。 - test 检查 行为 并熬过重构,而不是每次整理都崩坏。 - 套件 快速且 deterministic,而你会修好或删掉 flaky test。 - test 在每次改动时 跑 CI,而你把 coverage 当作线索,而非目标。

好的测试是判断:测试有风险的部分,守住金字塔,检查行为而非实现,并把这一切自动跑起来——这样一套绿的套件才真正意味着可以安全地修改。

速成课结束 · 7 章 · 掌握术语

接下来是实践:拿一个重要的函数,为它写几个 unit test——arrange、act、assert——然后故意把这个函数弄坏,看着一个 test 变红,并确切地告诉你坏在哪。然后为你发现的下一个 bug 加一个 test,让套件沿着真正会发生的失败成长起来。但请把一个想法置于其余之上:test 不是为了证明今天的代码能用。它们是那条安全带,让你明天无所畏惧地修改——而那种自信修改的自由,正是让好软件不断成长、而非慢慢僵化的东西。