fedorthinks

速成课 · No. 21

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

19 个概念 · 每个都有图 · 结尾有速查表

§ 01

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

地图:七章里的 19 个概念

一张地铁图:先一眼看清所有线路,再一站一站地坐过去。

课程里的每个概念都属于七章之一。先讲测试为什么存在。然后是测试的种类,以及如何平衡它们。接着是隔离一段代码所需要的替身。再往后:写好一个 test 的手艺、如何信任整套测试、以及让它真正派上用场。

1 · Why we testAutomated testRegression2 · Unit testsUnit testArrange–Act–Assert3 · PyramidIntegration testE2E testTest pyramid4 · FakesTest doubleStubMockOver-mocking5 · CraftTDDTest behaviourLiving docs6 · TrustCode coverageGoodhart's lawFlaky test7 · PracticeRisk-based testingCI gateCheat sheetall terms, at the end
七章,19 个概念。结尾有一张包含所有概念的速查表。

怎么读这门课

一本护照:每个人都是同样的栏目,所以很容易比较。

每个概念都按同样的方式讲:它的英文名称(记住它——文档和面试里遇到的就是这个术语)、一幅生活中的画面、一张图,以及一张带有这些栏目的卡片:问题、思路、例子、代价、何时使用,以及关联概念。有一个想法贯穿整门课:测试是对未来改动的保险,而不是代码曾经能用过一次的证书。

Automated Test — 检查代码的代码

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

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

Inputadd(2, 3)Code under testadd()Assertionexpect 5PASSgot 5FAILgot 6automatic · repeatable · thousands of checks per run
test 给代码一个已知的输入,并把结果与预期比较:一致——通过,不一致——失败。
ConceptAutomated Test· test,自动化测试
问题
手动检查又慢又无聊——所以每次改完之后没人会把所有东西重新检查一遍。
思路
把检查写成代码:已知输入 → 调用 → 与预期比较。结果:通过或失败。
例子
「add(2, 3) 必须返回 5」——一行代码,几毫秒就跑完。
代价
测试也是代码:要写、要读、要维护。
何时使用
用于任何你打算持续修改的代码。
关联

抓出 Regression;分为 Unit、Integration 和 E2E;在 CI Gate 中运行。

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

攀岩者身上的安全带:它不替你攀岩,但让你敢于尝试大胆的动作,因为失手也不会致命。你爬得更猛,因为摔下来有人接住。

误解就在这里。测试主要不是为了证明今天的代码是正确的——而是为了明天。当你修改代码、重构它、或添加功能时,一套好的测试套件(test suite)会立刻告诉你,你是否破坏了以前能用的东西。这张安全网让你能够自信地修改庞大的代码库,而不是害怕碰任何东西。测试让你能继续飞快前进,而身后的东西不会悄悄坏掉。

Changerefactor · featureTest suiteruns in secondsgreen ✓old behaviour okred ✗broke old codeShip itwith confidenceFix it nowbefore users see
每次改动都要过一遍测试套件:绿色——放心发布;红色——现在就修,别等用户来抱怨。

Regression — 改动破坏了原本能用的东西

一排穿过整座房子的绊线:只要你根本没在看的某个房间有了动静,警报就响了——你不必亲自巡逻每个房间。

regression 指的是一次改动破坏了以前能用的东西——往往发生在离你改动处很远的地方。没有测试,regression 会一直藏着,直到用户发现它。有了测试,你的改动一旦破坏了旧行为,就会有一个 test 变红并指出它,就在当下。这就是日常的回报:你做一次改动,跑一遍测试,立刻知道自己是否也破坏了别处的东西。你在一个房间里干活,测试套件守着整座房子。

CodebaseCartyou changed thisPricingCheckoutInvoicesbroke silentlyinvoice test ✗a tripwire in a room you weren't looking at
你改了购物车,结果发票坏了——隔着三个模块。发票的 test 立刻变红。
TermRegression· regression bug,回归缺陷
它是什么
一次改动破坏了以前能用的行为。
陷阱
它常常坏在离你编辑处很远的地方,在系统的另一个角落。
例子
你改了购物车的总价——结果发票开始显示错误的金额。
如何抓住它
针对旧行为的一套测试,每次改动都运行。
提示
发现了 bug?为它加一个 test,让它永远不会再回来。
关联
由 Automated Test 抓住;由 CI Gate 自动把关。

test 自动检查代码。它真正的价值不是一次性的证明——而是让你明天能修改,而不悄悄破坏今天还能用的东西。

§ 02

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

Unit Test — 孤立地测试一小块

在用乐高积木搭建之前,先检查单块积木的形状和大小对不对——而不是把整座城堡搭完才发现有一块是错的。

unit test 检查一个小的单元——通常是一个函数——把它与系统其余部分隔离开。给它一个输入,检查它的输出,不牵扯别的东西。因为这一块又小又独立,test 失败时你确切知道什么坏了、坏在哪里。unit test 是测试领域的显微镜:窄、准,一次只对准一样东西。

isolationUnitone functioninputexpected?not touched:DatabaseNetworkDisk
unit test:输入 → 一个函数 → 检查输出。数据库、网络和磁盘都不参与。
ConceptUnit Test· 模块测试
问题
一个大检查失败时,你说不清是一百个部分里的哪一个出了错。
思路
一次只检查一个函数,与数据库、网络和其他一切隔开。
例子
calculateTax(100) 返回 20——没有数据库,没有 HTTP,几毫秒完成。
代价
它看不到各部分如何协同工作——那由其他测试负责。
何时使用
用于你的大部分测试:业务逻辑、计算、规则、边界情况。
关联

Test Pyramid 的底座;由 Test Doubles 实现隔离;呈 Arrange–Act–Assert 的结构。

又快又多 — 这就是全部的思路

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

因为每个 unit test 都很小,也不碰任何外部东西,所以它只需几毫秒——于是你可以拥有成千上万个,并在每次改动时都全部运行,不间断。这种速度就是关键:几秒钟能跑完的测试套件会被一直跑,在损坏发生的那一刻就抓住它。慢的套件会被跳过,被跳过的套件什么也保护不了。又快又多,正是 unit test 成为主力的原因。

time per testunitmillisecondsintegrationslowere2eslowestfast suite → run on every change → breakage caught instantly
层级越低,test 越快。快的套件每次编辑都会运行——所以它才抓得住损坏。

Arrange–Act–Assert — 准备、执行、检查

一个简单的实验:布置好条件,做那唯一的动作,然后检查结果。准备清楚,一步动作,一次验证。

一个好的 unit test 有三个朴素的步骤——**Arrange–Act–Assert(AAA)**模式:准备好输入,对被测对象只调用一次,然后断言(assert)结果符合预期。assertion 就是检查本身——「答案必须等于 5。」让 test 保持这种清爽的结构,它就易读,失败也一目了然。一个难读的 test,没人会信任,也没人愿意维护。

1 · Arrangecart = new Cart()cart.add(item, 2)2 · Acttotal = cart.total()3 · Assertexpect(total) .toBe(20)set up · do one thing · check the resultassertion = the check itself
test 的三个步骤:Arrange 搭好购物车,Act 计算总价,Assert 与预期比较。
PatternArrange–Act–Assert· AAA
问题
纠缠不清的 test:准备、调用和检查混在一起——看不出它到底在验证什么。
思路
三个块依次排列:准备 → 一次动作 → 检查(assertion)。
例子

Arrange:一个装有两件商品的购物车。Act:cart.total()。Assert:总价等于 20。

代价
几乎没有——只需要「每个 test 只做一个动作」的纪律。
何时使用
永远,在每个 test 里——unit、integration 还是 end-to-end 都一样。
关联
让 test 易读——从而成为 Living Documentation;Unit Test 的骨架。

unit test 孤立地检查一小块,只需几毫秒——所以你能时时运行成千上万个。准备输入,执行一次,断言结果。

§ 03

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

Integration Test — 各部分协同工作

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

integration test 检查多个部分一起是否正确工作——你的代码与数据库对话,两个服务交换数据。每个单元单独看可能都完美,却仍然连不上:格式不匹配,对另一端的假设有误。integration test 抓的正是这些接缝(seam)。它们比 unit test 慢,因为涉及更多真实运转的部件,但它们验证的是 unit test 按设计会略过的那些连接处。

seamYour codeDatabasequeryrowsService AService BJSONreplybugs hide in the joins: format mismatch, wrong assumptions
integration test 检查接缝:发给数据库的查询及其回复,服务之间的 JSON。bug 就藏在连接处。
ConceptIntegration Test· 集成检查
问题
每个部分的 unit test 都通过了,可放在一起却不能工作。
思路
把几个真实的部分一起跑起来,检查它们之间的连接。
例子
一个 repository 把用户写入真实的测试数据库,再读回来。
代价
比 unit test 慢;需要搭好环境(数据库、服务)。
何时使用
用于接缝:代码 ↔ 数据库、服务 ↔ 服务、你的代码 ↔ 第三方 API。
关联
Test Pyramid 的中间层;抓住 Over-mocking 所掩盖的问题。

End-to-End Test — 像用户一样走完整个系统

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

end-to-end test(E2E)像真实用户那样驱动整个系统——点按钮、填表单,检查整条技术栈上是否发生了正确的事。它是最贴近现实的 test,也是确认产品真正可用的最有价值的证明。但它同时也是最慢、最脆弱的:很多部分必须同时运行,界面上一个小改动就可能把它弄坏。你把 e2e test 用在少数关键流程上,而不是用在所有东西上。

the whole stack, really runningUserclicks, typesUIbrowserAPIserverDatabaseOrder placed?assertmost realistic · slowest · most fragileuse it for the few critical journeys
E2E test 从用户出发,经过界面、API 和数据库——并检查结果:订单下成功了吗?
ConceptEnd-to-End Test· E2E
问题
每个部分都测过了,但没人知道产品作为一个整体能不能用。
思路
沿着用户的路径走完整个真实技术栈,并检查结果。
例子
一个浏览器机器人:登录、把商品加入购物车、付款,看到「订单已提交」。
代价
最慢也最脆弱;界面上的小改动就会让它出错。
何时使用
用于少数关键流程:登录、支付、主流程。
关联
Test Pyramid 的塔尖;太多的话就成了冰淇淋筒。

Test Pyramid — 恰当的平衡

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

test pyramid 是关于平衡的经验法则:底部是很多快速的 unit test,中间是较少的 integration test,顶部是少数几个慢速的 end-to-end test。这种形状让你以低成本获得广泛而快速的覆盖,顶部再配上刚好够用的真实检查。反模式——大量慢速的 e2e test、很少的 unit test——叫冰淇淋筒(ice-cream cone),它又慢、又不稳、又痛苦。目标是金字塔,而不是冰淇淋筒。

E2EIntegrationsomeUnitmany, fastTest pyramid ✓E2Emany, slowIntegrationunit (few)Ice-cream cone ✗
左边是金字塔:很多 unit test,较少 integration,几个 e2e。右边是冰淇淋筒——同一座金字塔倒了过来。
PatternTest Pyramid· 测试金字塔
问题
测试很多,但套件又慢又不稳定——于是大家不再运行它。
思路
底部是很多便宜快速的 test,顶部是少数昂贵缓慢的 test。
例子
成千上万个 unit test,几百个 integration test,针对主流程的十来个 e2e。
代价
你得有意识地决定每样东西由哪一层来检查。
反模式
Ice-cream cone——倒过来的金字塔:很多 e2e,很少 unit。
关联

各层是 Unit、Integration、E2E;多出来的 e2e 会滋生 Flaky Tests。

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

§ 04

要孤立地测试一小块,你常常得替它所依赖的真实东西找个替身。这就是 mock 和 stub——它们既好用,也同样容易被滥用。

Test Double — 用替身代替真实依赖

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

要孤立地测试一小块,你会把它真实的依赖——数据库、支付服务、发邮件的组件——换成顶替它们的替身。任何这类替身的通称是 test double,就像电影里的替身演员。这样你就能在没有那个又慢、又贵、又不可预测的真东西的情况下测试你的代码。你不会希望 unit test 真的去刷一张信用卡或发一封邮件;你换上一个假装在做的替身,让 test 保持快速、可靠、自成一体。

test doublereal thingCode under testFake DBFake paymentsFake emailPostgresslowStripecosts moneySMTPsends for real
被测代码对接的是替身,而不是真实的 Postgres、Stripe 和邮件服务器——那些要么慢,要么花钱,要么真的会把东西发出去。
ConceptTest Double· 替身,fake
问题
真实的依赖又慢、又花钱,或行为不可预测——用到它们的 test 很脆弱。
思路
换上一个行为像依赖、但只是假装的对象。
例子
用一个永远回答「支付成功」的替身来代替 Stripe。
代价
替身可能说谎:真东西的行为也许不一样。
何时使用
用于外部的、慢的、随机的东西:网络、支付、邮件、时间。
关联

两大类——Stub 和 Mock;让 Unit Test 得以隔离;用过头就是 Over-mocking。

Stub vs Mock — 罐头回答 vs 被检查的调用

stub 是一块纸板人像,到点说一句台词;mock 则是一位演员,还会向你汇报你对他说了什么、怎么说的。

两种替身常被混淆。stub 只是返回一个固定的、现成的回答——「假装数据库返回这个用户」——这样你就能测试代码如何处理这个输入。mock 还会验证交互——「检查我的代码是否恰好用这个地址调用了一次 sendEmail。」stub 给你的代码喂数据;mock 则盯着你的代码对它做了什么。需要提供数据时用 stub,需要断言某个副作用发生了时用 mock。

Stubsupplies dataStubgetUser()Your codereturns { name: 'Ann' }test checks your code's outputMockverifies the callYour codeMocksendEmail()expect(sendEmail) .calledOnceWith(addr)test checks the call happened
stub 提供数据,test 检查你代码的输出。mock 记录调用,test 检查调用本身。
TermStub· 罐头回答,canned answer
它是什么
一个返回预先准备好的回答的替身。
例子
getUser() 永远返回 { name: 'Ann' }。
检查什么
在这份数据下你代码的输出。
何时使用
当你的代码需要从依赖那里_拿到_某样东西时。
TermMock· 调用记录器
它是什么
一个会记录调用并让你验证它们的替身。
例子
检查 sendEmail 是否恰好被调用了一次,且地址正确。
检查什么
调用本身:有没有发生、发生几次、带什么参数。
何时使用
当副作用很重要时:邮件发出去了,款项付出去了。

Over-mocking — 说谎的测试

排练一出戏时,其他所有演员都用纸板人像代替——你可以把自己的台词说得滴水不漏,却依然不知道真正的演员阵容能不能配合起来。

替身很强大,但过量就危险。如果你把所有东西都 mock 了,你的 test 通过的只是你对其他部分行为的假设——而不是它们实际的行为。test 是绿的,真实的集成却是坏的。这就是 over-mocking 反模式。所以,要 mock 那些慢的、外部的或不可预测的依赖,但别把你正想验证的东西本身也假掉。一个完全建立在 mock 之上的 test 可以通过,而真实系统却失败——这是最危险的那种绿。

in the test: everything mockedCodeMock DBMock APIPASS ✓in production: the real partsCodeReal DBReal APIFAIL ✗green test, broken system — the most dangerous green
在 test 里,一切都是 mock——于是是绿的。到了生产环境,换成真实的数据库和 API——全都失败。
Anti-patternOver-mocking· 过度 mock
症状
test 是绿的,可同一个函数在生产环境里却失败。
原因
它周围的一切都是假的——test 只是在检查你对其他部分的假设。
例子
数据库的 mock 永远说「ok」,而真实的数据库因为字段写错而拒绝了这条查询。
对策
只 mock 外部的、慢的东西;用 Integration Test 检查接缝。
关联
对 Test Doubles 的滥用;像 Flaky Test 一样侵蚀信任。

mock 和 stub 顶替真实的依赖,让你能孤立地测试。stub 提供数据;mock 验证调用。过度 mock,test 通过了,现实却崩了。

§ 05

除了 test 的种类之外,还有写好 test 的手艺——以及一项颠倒惯常顺序、改变你设计代码方式的纪律:测试驱动开发。

TDD — 先写 test

先画靶子再射箭,而不是之后——这样你瞄准的是一个明确的目标,而不是在箭落下的地方周围画个靶心。

测试驱动开发(TDD,test-driven development)颠倒了惯常的顺序:你在写代码之前先写 test。循环是红、绿、重构——先为你想要的东西写一个会失败的 test(红),再写刚好能让它通过的代码(绿),然后在 test 的守护下整理代码(重构)。先写 test 迫使你在动手前就明确什么叫成功,并保证代码是可测试的,因为它就是从 test 里诞生的。

Redwrite a failing testGreenjust enough codeRefactorclean up, stay greenrepeat
TDD 循环:红(test 失败)→ 绿(刚好能通过的最少代码)→ 重构(整理,test 保持绿色)→ 再来一轮。
PracticeTest-Driven Development· TDD,red-green-refactor
问题
test 总是「以后再写」——结果代码写出来很难测。
思路
先写一个失败的 test,再写最少的代码,然后整理。如此循环往复。
例子

test「空购物车 → 总价为 0」失败;你写出 total();它变绿;你再整理一下。

代价
一开始不习惯、也更慢;任务本身还不清晰时尤其困难。
何时使用
用于规则明确的逻辑:计算、校验、修 bug。
关联

产出可测试的代码和 Living Documentation;重构依赖于 Test Behaviour。

Test Behaviour, Not Implementation — 检查做什么,而不是怎么做

评判厨师要看菜好不好吃,而不是看他用哪只手握刀——你在乎的是结果,而不是他怎么做出来的那些私人细节。

写出经久耐用的 test,最重要的习惯是:检查代码做了什么,而不是怎么做的——检查它的行为,而不是它的实现。测试 getDiscount 返回了正确的价格,而不是测试它是否按顺序调用了三个具体的内部辅助函数。绑定在结果上的 test 能挺过重构——你可以重写内部,它们照样通过。绑定在实现上的 test,每次你整理代码都会坏,这会让人不再信任它们、最终把它们删掉。测试行为,test 才一直有用。

getDiscount()helperhelperhelperinternals (free to change)inputcart, couponprice90✓ check the outcome✗ test coupled to internals — breaks on every refactor
好的 test 看进入 getDiscount 的东西和出来的东西。坏的 test 去够内部的辅助函数——每次重构都会坏。
PrincipleTest Behaviour, Not Implementation· 黑盒测试,black-box testing
问题
行为明明没变,test 却在每次重构时都坏掉。
思路
只检查可观察的结果,并通过公开接口来检查。
例子
检查 getDiscount 返回了 90——而不是它内部调用了哪些辅助函数。
代价
有时只靠输入和输出,某个边界情况更难触达。
何时使用
永远,作为默认做法。
关联
让 TDD 里的重构成为可能;多余的 Mocks 常常把 test 绑死在实现上。

Living Documentation — 测试即文档

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

一套清晰的测试套件同时也是活文档(living documentation)。每个 test 都陈述一个承诺——「给定空购物车,总价为零」——而且因为 test 会被运行,这份文档永远不会像注释那样过时;行为一变,test 就会坏。所以写 test 要让人读得懂:名字清楚,每个只讲一个行为,arrange-act-assert 一目了然。新人应该能通过读 test 来了解代码做了什么。这种可读性和检查本身一样有价值。

ConceptLiving Documentation· 可执行的规格说明
问题
注释和 wiki 会说谎:代码变了,文字还停在旧的。
思路
test 是写成代码的承诺。违背承诺——test 就变红。
例子
一个名为「空购物车——总价为零」的 test。读起来就像一份规格说明。
代价
花时间起清楚的名字,并让每个 test 只讲一个行为。
何时使用
永远——尤其是新人会读的代码。
关联
建立在 Arrange–Act–Assert 和 Test Behaviour 之上。

先写 test(red-green-refactor),测试行为而不是实现,并让它易读——这样 test 能挺过重构,还能充当不会过时的文档。

§ 06

一个数字和一个麻烦,决定了一套测试是否真的值得信任。误读 coverage,或容忍不稳定,绿色的套件就不再意味着任何东西。

Code Coverage — 测了什么,而不是测得多好

一张显示巡逻队开过哪些街道的地图——有助于发现从未去过的街区,但开车路过一栋房子,并不意味着你检查过门锁。

code coverage 是测试期间被运行到的代码所占的百分比。它是个有用的向导,用来找出完全没被测试的部分——那些从没人巡逻过的街道。但高 coverage 并不意味着好 test:代码可能被一个没断言任何有意义内容的 test 执行过,拿到 100%,却几乎什么都没验证。用 coverage 去发现盲区,而不要把它当作质量的证明。一行代码被碰到,并不等于这一行被检查过。

12345678executed + assertedexecuted, nothing assertednever executedcoverage: 6 of 8 lines (75%)really checked: 3 of 8a touched line is not a checked line
75% 的 coverage:8 行里跑到了 6 行。但真正被 assertion 检查的只有 3 行。
MetricCode Coverage· 测试覆盖率,test coverage
它是什么
测试期间被运行到的行(或分支)所占的比例。
有什么用
显示盲区——从来没有任何 test 运行过的代码。
陷阱
它不衡量质量:一行代码可以被运行,却没有任何 assertion 去检查它。
例子
80% 的 coverage,但所有折扣逻辑都在没覆盖的那 20% 里——风险就在那儿。
何时使用
把它当作照出缺口的手电筒,而不是目标。
关联
通过 Goodhart's Law 变成陷阱;不能替代 Risk-based Testing。

Goodhart's Law — 别让 coverage 变成目标

按刷出的公路英里数付钱给工人,结果看着他们在空地上刷出又长又没用的线来凑数——指标上去了,路并没有更安全。

当一个数字变成目标,人们就会去优化这个数字,而不是它原本衡量的东西——这就是 Goodhart 定律(Goodhart's law)。要求「100% coverage」,你得到的是为了碰到代码行而写的 test,而不是为了抓 bug:什么都不断言的空壳 test,在玩弄这个指标。coverage 是用来找缺口的手电筒,不是用来最大化的奖杯。追求真正的信心——这些 test 真的能抓住损坏吗?——让 coverage 只是众多提示之一,永远不是目标本身。

Coveragea measurebecomes“100% or no merge”a targetHollow testsassert nothingthe number went up — the bugs stayedcoverage is a flashlight, not a trophy
一个衡量指标变成了目标——空壳 test 就诞生了:数字上去了,bug 还在。
LawGoodhart's Law· 指标 vs 目标
要点
当一个衡量指标变成目标,它就不再是个好指标。
在测试中

一条「coverage 不到 100% 就不许合并」的规则,会催生不带 assertion 的 test——只为凑数字。

例子
一个 test 调用了函数却什么也没检查——coverage 上升,保护为零。
对策
问「这个 test 能抓住损坏吗?」,并把 coverage 当作提示。
关联
Code Coverage 的主要陷阱。

Flaky Test — 比没有 test 还糟

一个毫无理由地随机乱响的火警:一周之内,所有人都彻底无视它,所以真起火的时候,没人动弹。

flaky test 是指在代码没变的情况下随机时过时不过的 test——通常源于时序、顺序或某个隐藏的依赖。flaky test 是毒药:当一个红色结果可能只是噪音时,人们就不再信任任何红色,真正的失败也会被当成「大概只是 flaky」而放过。test 必须是确定性的(deterministic)——同样的代码,每次同样的结果。要么修好 flaky test,要么删掉它;一套你不信任的套件什么也保护不了。

same code, nothing changed — six runsPASSrun 1FAILrun 2PASSrun 3PASSrun 4FAILrun 5PASSrun 6red might be noise → people stop trusting any reddeterministic: same code → same result, every time
同样的代码,跑六次——两次随机失败。从此,每一次红色都被当作噪音忽略。
TermFlaky Test· 不稳定的测试
它是什么
一个时过时不过的 test,而代码并没有改动。
成因
时序(sleep、竞态)、test 的执行顺序、隐藏的共享依赖。
例子
一个 test 等一个服务 100 毫秒;在繁忙的 CI 机器上,它有时放弃得太早。
危害
它教会团队无视红色——于是真正的失败溜了过去。
对策
让 test 变得确定性——或者删掉它。永远别和 flaky test 共存。
关联
在 E2E 里最常见;摧毁对 CI Gate 的信任。

coverage 显示测了什么,而不是测得多好——别让它成为目标。而 flaky test 比没有还糟:一旦红色可能只是噪音,每个红色都会被无视。

§ 07

测试靠的是判断力,不是仪式。技巧在于:测那些要紧的东西,比例恰当,并把它接入你发布的流程——让套件赢得信任,而不是沦为形式主义的杂活。

Risk-based Testing — 测有风险的部分,而不是所有东西

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

不是所有代码都值得同样程度的测试。risk-based testing 的意思是,把精力集中在 bug 代价高昂或很可能出现的地方:核心业务逻辑、金钱、安全、棘手的边界情况、经常变动的部分。琐碎、低风险的代码可以少测一些。给所有东西都追着写 test,会浪费精力,还会造出一个又大又慢、没人愿意运行的套件。目标是那些能抓住你真正会后悔的故障的 test,而不是追一个数字——覆盖要紧的东西,胜过覆盖所有东西。

Test wellrare but costlyTest hardestmoney · security · core logicTest lightlytrivial, low-risk codeTest someedge cases · frequent changeshigh costlow costunlikely to breaklikely to breakeffort goes where failure actually hurts
故障代价越高、出错越可能,test 就越多。金钱、安全和核心逻辑位于右上角。
PracticeRisk-based Testing· 按风险测试
问题
测试的时间很稀缺,想什么都覆盖只会把它摊得太薄。
思路
失败代价高或可能性大的地方多测;琐碎的地方少测。
例子
支付的计算和访问权限——测得密;getter 和简单的排版——测得极少。
代价
你得诚实地判断风险,而不是只测容易测的东西。
何时使用
每当你决定把测试时间花在哪里的时候。
关联

取代对 Code Coverage 的追逐;在逻辑上以 Unit Tests 的形式最密集。

CI Gate — 每次改动都自动运行测试

一道闸机,车票无效就不开——检查就内置在闸门里,而不是靠某个人记得去看一眼。

测试只有真正运行时才能保护你。把它们接入你的流水线(pipeline),让它们在每次改动时自动执行,失败就阻止合并——这就是部署课里讲过的 CI gate。正是这一点,把 test 从「也许会跑的东西」变成了「不会有坏代码被合并」的保证。「没有 test 就不算发布」是值得坚持的标准:把一个没经过测试的改动送上生产环境,是一场你本不必下的赌注。

Pusha changeCI pipelineruns every testGateall green?Merge ✓into mainBlocked ✗fix firsttests or it didn't ship
每次改动都要过 CI:所有 test 全绿——合并;哪怕只有一个红——合并被阻止。
PracticeCI Gate· 合并检查
问题
test 是有的,但大家忘了运行——于是坏代码落进了 main。
思路
CI 在每次改动时运行每一个 test,在它们变绿之前拒绝合并。
例子
一个 pull request:CI 跑了 test,有一个失败——合并按钮被禁用。
代价
要等 CI;flaky test 在这里伤害最大。
何时使用
用于任何一周改动超过一次的项目。
关联

运行整座 Test Pyramid;容不下 Flaky Tests;在用户之前抓住 Regressions。

它们如何连成一体

一支管弦乐队:每件乐器都重要,但音乐只有合在一起才会出现。

这门课里的概念是作为一个系统运作的。pyramid 决定套件的形状,TDD 把它写出来,test double 给 unit test 提供隔离,CI gate 在每次改动时把这一切跑一遍——由此得到主要的回报:无所畏惧地修改代码。与之作对的是 flaky test 和 over-mocking,它们削弱对绿色的信任。而 coverage 只是暗示缺口在哪里。

Test pyramidshapes the suiteTDDwrites tests firstTest doublesisolate unitsTest suiteunit · integration · e2eOver-mockingfalse greenChange without fearregressions caughtFlaky testsdestroy trustCoveragehints at gapsCI gateruns on every change
pyramid、TDD 和 test double 构建套件;CI gate 运行它,让你无所畏惧地修改。flaky test 和多余的 mock 打击信任;coverage 只是提示。
信任一套测试之前
  • 它能让你自信地修改吗——明天它能抓住 regression 吗?
  • 它是金字塔的形状吗——很多 unit、一些 integration、少数 e2e?
  • test 检查的是行为吗,而不是一重构就坏的内部实现?
  • mock 用在真正的外部依赖上了吗,而不是把被测对象本身给假掉了?
  • 它又快又确定吗——没有 flaky test 在侵蚀信任?
  • 它在每次改动时都在 CI 里运行吗,并阻止有问题的合并?
你测得好的迹象
  • 你自信地修改代码,相信套件会抓住你弄坏的东西。
  • 你的 test 构成一座金字塔——底部又快又多,顶部是少数贴近现实的。
  • test 检查的是行为,能挺过重构,而不是每次整理代码都坏。
  • 套件又快又确定,并且你会修好或删掉 flaky test。
  • test 在每次改动时都在 CI 里运行,并且你把 coverage 当作提示,而不是目标。

速查表:全部 19 个概念

我们为什么测试

Automated Testtest

检查代码的代码:输入 → 调用 → assertion → 通过或失败。

Regressionregression bug

一次改动破坏了以前能用的东西,往往离编辑处很远。

Unit tests

Unit Test模块测试

孤立地测一个函数,几毫秒。你有成千上万个。

Arrange–Act–AssertAAA

准备,做一件事,检查结果。

Integration、e2e 与 pyramid

Integration Test集成检查

几个真实的部分一起跑——检查接缝。

End-to-End TestE2E

用户眼中的整个系统。贴近现实,但又慢又脆弱。

Test Pyramid测试金字塔

很多 unit,较少 integration,几个 e2e。别搞成冰淇淋筒。

替身

Test Double替身,fake

对又慢、又贵或随机的依赖的替代品。

Stub罐头回答,canned answer

返回预先准备的回答——给你的代码喂数据。

Mock调用记录器

记录调用——验证某个副作用发生了。

Over-mocking过度 mock

全部都 mock——test 是绿的,而真实系统在失败。

TDD 与好的 test

TDDred-green-refactor

先写一个失败的 test,再写最少的代码,然后整理。

Test Behaviour, Not Implementation黑盒测试,black-box testing

通过公开接口检查结果——test 能挺过重构。

Living Documentation可执行的规格说明

test 是写成代码的承诺,永远不会过时。

信任这套测试

Code Coverage测试覆盖率,test coverage

测试中运行到了多少代码。照出缺口的手电筒,而不是质量的衡量标准。

Goodhart's Law指标 vs 目标

变成目标的指标就不再衡量了。100% coverage → 空壳 test。

Flaky Test不稳定的测试

随机失败。对策:让它确定,或者删掉它。

用好它

Risk-based Testing按风险测试

失败代价高或可能性大的地方多测。

CI Gate合并检查

每次改动都跑测试;红——合并被阻止。

好的测试靠判断力:测有风险的部分,守住金字塔,检查行为而不是实现,并让这一切自动运行——这样绿色的套件才真正意味着可以放心修改。

速成课结束 · 7 章 · 19 个概念

接下来是练习:挑一个重要的函数,为它写几个 unit test——arrange、act、assert——然后故意把这个函数弄坏,看着一个 test 变红,并准确告诉你出了什么问题。再为你发现的下一个 bug 加一个 test,让套件沿着真正发生过的故障成长。但请把一个想法放在所有想法之上:测试不是为了证明今天的代码能用。它们是让你明天能无所畏惧地修改的安全带——而这种自信修改的自由,正是让好软件持续成长、而不是慢慢僵化的原因。