# 测试是你无所畏惧地修改代码的方式

关于测试的速成课。19 个概念——Unit Test、Integration Test、E2E、Test Pyramid、Stub 与 Mock、TDD、Code Coverage、Flaky Test、CI Gate 等——每个都配有英文名称、一张图、一张卡片和一个例子。核心思想：测试的意义不在于一次性证明正确，而在于让你明天能够修改代码，而不破坏今天还在正常工作的部分。结尾附有所有术语的速查表。

- **Author:** Fedor Chishchin
- **Level:** intro
- **Reading time:** ~35 分钟阅读
- **Published:** 2026-06-13
- **Source:** https://fedorthinks.com/zh/learn/testing-express

---

## 1. 我们为什么测试

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

### 地图：七章里的 19 个概念

> 一张地铁图：先一眼看清所有线路，再一站一站地坐过去。

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

_Diagram: 七章，19 个概念。结尾有一张包含所有概念的速查表。_

### 怎么读这门课

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

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

### Automated Test — 检查代码的代码

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

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

_Diagram: test 给代码一个已知的输入，并把结果与预期比较：一致——通过，不一致——失败。_

#### Automated Test (test，自动化测试)

- **问题:** 手动检查又慢又无聊——所以每次改完之后没人会把所有东西重新检查一遍。
- **思路:** 把检查写成代码：已知输入 → 调用 → 与预期比较。结果：通过或失败。
- **例子:** 「`add(2, 3)` 必须返回 5」——一行代码，几毫秒就跑完。
- **代价:** 测试也是代码：要写、要读、要维护。
- **何时使用:** 用于任何你打算持续修改的代码。
- **关联:** 抓出 **Regression**；分为 **Unit**、**Integration** 和 **E2E**；在 **CI Gate** 中运行。

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

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

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

_Diagram: 每次改动都要过一遍测试套件：绿色——放心发布；红色——现在就修，别等用户来抱怨。_

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

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

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

_Diagram: 你改了购物车，结果发票坏了——隔着三个模块。发票的 test 立刻变红。_

#### Regression (regression bug，回归缺陷)

- **它是什么:** 一次改动破坏了以前能用的行为。
- **陷阱:** 它常常坏在离你编辑处很远的地方，在系统的另一个角落。
- **例子:** 你改了购物车的总价——结果发票开始显示错误的金额。
- **如何抓住它:** 针对旧行为的一套测试，每次改动都运行。
- **提示:** 发现了 bug？为它加一个 test，让它永远不会再回来。
- **关联:** 由 **Automated Test** 抓住；由 **CI Gate** 自动把关。

> **核心问题:** 「我写这个 test，是为了证明代码能用一次——还是为了抓住某天有人改了点什么、不知不觉把这里弄坏的那一刻？」

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

## 2. Unit tests

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

### Unit Test — 孤立地测试一小块

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

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

_Diagram: unit test：输入 → 一个函数 → 检查输出。数据库、网络和磁盘都不参与。_

#### Unit Test (模块测试)

- **问题:** 一个大检查失败时，你说不清是一百个部分里的哪一个出了错。
- **思路:** 一次只检查一个函数，与数据库、网络和其他一切隔开。
- **例子:** `calculateTax(100)` 返回 `20`——没有数据库，没有 HTTP，几毫秒完成。
- **代价:** 它看不到各部分如何协同工作——那由其他测试负责。
- **何时使用:** 用于你的大部分测试：业务逻辑、计算、规则、边界情况。
- **关联:** **Test Pyramid** 的底座；由 **Test Doubles** 实现隔离；呈 **Arrange–Act–Assert** 的结构。

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

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

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

_Diagram: 层级越低，test 越快。快的套件每次编辑都会运行——所以它才抓得住损坏。_

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

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

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

_Diagram: test 的三个步骤：Arrange 搭好购物车，Act 计算总价，Assert 与预期比较。_

#### Arrange–Act–Assert (AAA)

- **问题:** 纠缠不清的 test：准备、调用和检查混在一起——看不出它到底在验证什么。
- **思路:** 三个块依次排列：准备 → 一次动作 → 检查（assertion）。
- **例子:** Arrange：一个装有两件商品的购物车。Act：`cart.total()`。Assert：总价等于 20。
- **代价:** 几乎没有——只需要「每个 test 只做一个动作」的纪律。
- **何时使用:** 永远，在每个 test 里——unit、integration 还是 end-to-end 都一样。
- **关联:** 让 test 易读——从而成为 **Living Documentation**；**Unit Test** 的骨架。

> **Unit 问题:** 「我能不能单独、快速地测试这一小块，并用清晰的 arrange-act-assert——这样失败时我确切知道什么坏了？」

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

## 3. Integration、e2e 与 pyramid

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

### Integration Test — 各部分协同工作

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

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

_Diagram: integration test 检查接缝：发给数据库的查询及其回复，服务之间的 JSON。bug 就藏在连接处。_

#### Integration Test (集成检查)

- **问题:** 每个部分的 unit test 都通过了，可放在一起却不能工作。
- **思路:** 把几个真实的部分一起跑起来，检查它们之间的连接。
- **例子:** 一个 repository 把用户写入真实的测试数据库，再读回来。
- **代价:** 比 unit test 慢；需要搭好环境（数据库、服务）。
- **何时使用:** 用于接缝：代码 ↔ 数据库、服务 ↔ 服务、你的代码 ↔ 第三方 API。
- **关联:** **Test Pyramid** 的中间层；抓住 **Over-mocking** 所掩盖的问题。

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

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

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

_Diagram: E2E test 从用户出发，经过界面、API 和数据库——并检查结果：订单下成功了吗？_

#### End-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），它又慢、又不稳、又痛苦。目标是金字塔，而不是冰淇淋筒。

_Diagram: 左边是金字塔：很多 unit test，较少 integration，几个 e2e。右边是冰淇淋筒——同一座金字塔倒了过来。_

#### Test Pyramid (测试金字塔)

- **问题:** 测试很多，但套件又慢又不稳定——于是大家不再运行它。
- **思路:** 底部是很多便宜快速的 test，顶部是少数昂贵缓慢的 test。
- **例子:** 成千上万个 unit test，几百个 integration test，针对主流程的十来个 e2e。
- **代价:** 你得有意识地决定每样东西由哪一层来检查。
- **反模式:** **Ice-cream cone**——倒过来的金字塔：很多 e2e，很少 unit。
- **关联:** 各层是 **Unit**、**Integration**、**E2E**；多出来的 e2e 会滋生 **Flaky Tests**。

> **金字塔问题:** 「我的底部有很多快速的 unit test，有一些针对接缝的 integration test，只有少数几个针对关键流程的 end-to-end test——还是我的金字塔倒过来了？」

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

## 4. 替身：stubs 和 mocks

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

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

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

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

_Diagram: 被测代码对接的是替身，而不是真实的 Postgres、Stripe 和邮件服务器——那些要么慢，要么花钱，要么真的会把东西发出去。_

#### Test Double (替身，fake)

- **问题:** 真实的依赖又慢、又花钱，或行为不可预测——用到它们的 test 很脆弱。
- **思路:** 换上一个行为像依赖、但只是假装的对象。
- **例子:** 用一个永远回答「支付成功」的替身来代替 Stripe。
- **代价:** 替身可能说谎：真东西的行为也许不一样。
- **何时使用:** 用于外部的、慢的、随机的东西：网络、支付、邮件、时间。
- **关联:** 两大类——**Stub** 和 **Mock**；让 **Unit Test** 得以隔离；用过头就是 **Over-mocking**。

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

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

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

_Diagram: stub 提供数据，test 检查你代码的输出。mock 记录调用，test 检查调用本身。_

#### Stub (罐头回答，canned answer)

- **它是什么:** 一个返回预先准备好的回答的替身。
- **例子:** `getUser()` 永远返回 `{ name: 'Ann' }`。
- **检查什么:** 在这份数据下你代码的输出。
- **何时使用:** 当你的代码需要从依赖那里_拿到_某样东西时。

#### Mock (调用记录器)

- **它是什么:** 一个会记录调用并让你验证它们的替身。
- **例子:** 检查 `sendEmail` 是否恰好被调用了一次，且地址正确。
- **检查什么:** 调用本身：有没有发生、发生几次、带什么参数。
- **何时使用:** 当副作用很重要时：邮件发出去了，款项付出去了。

### Over-mocking — 说谎的测试

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

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

_Diagram: 在 test 里，一切都是 mock——于是是绿的。到了生产环境，换成真实的数据库和 API——全都失败。_

#### Over-mocking (过度 mock)

- **症状:** test 是绿的，可同一个函数在生产环境里却失败。
- **原因:** 它周围的一切都是假的——test 只是在检查你对其他部分的假设。
- **例子:** 数据库的 mock 永远说「ok」，而真实的数据库因为字段写错而拒绝了这条查询。
- **对策:** 只 mock 外部的、慢的东西；用 **Integration Test** 检查接缝。
- **关联:** 对 **Test Doubles** 的滥用；像 **Flaky Test** 一样侵蚀信任。

> **Mock 问题:** 「我是在为真正外部的依赖做替身以保持快速和隔离——还是我 mock 得太多，以至于 test 只是在检查我自己的假设？」

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

## 5. TDD 与好的 test

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

### TDD — 先写 test

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

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

_Diagram: TDD 循环：红（test 失败）→ 绿（刚好能通过的最少代码）→ 重构（整理，test 保持绿色）→ 再来一轮。_

#### Test-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 才一直有用。

_Diagram: 好的 test 看进入 getDiscount 的东西和出来的东西。坏的 test 去够内部的辅助函数——每次重构都会坏。_

#### Test 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 来了解代码做了什么。这种可读性和检查本身一样有价值。

#### Living Documentation (可执行的规格说明)

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

> **TDD 问题:** 「这个 test 是否描述了我所承诺的行为——可读、聚焦于结果——从而能挺过重构，并教会下一个人这段代码做了什么？」

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

## 6. Coverage 与 flaky test

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

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

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

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

_Diagram: 75% 的 coverage：8 行里跑到了 6 行。但真正被 assertion 检查的只有 3 行。_

#### Code 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 只是众多提示之一，永远不是目标本身。

_Diagram: 一个衡量指标变成了目标——空壳 test 就诞生了：数字上去了，bug 还在。_

#### Goodhart'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，要么删掉它；一套你不信任的套件什么也保护不了。

_Diagram: 同样的代码，跑六次——两次随机失败。从此，每一次红色都被当作噪音忽略。_

#### Flaky Test (不稳定的测试)

- **它是什么:** 一个时过时不过的 test，而代码并没有改动。
- **成因:** 时序（sleep、竞态）、test 的执行顺序、隐藏的共享依赖。
- **例子:** 一个 test 等一个服务 100 毫秒；在繁忙的 CI 机器上，它有时放弃得太早。
- **危害:** 它教会团队无视红色——于是真正的失败溜了过去。
- **对策:** 让 test 变得**确定性**——或者删掉它。永远别和 flaky test 共存。
- **关联:** 在 **E2E** 里最常见；摧毁对 **CI Gate** 的信任。

> **信任问题:** 「我的测试套件真的值得信任吗——coverage 只当提示而不是目标，flaky test 为零——这样绿色的一次运行才真正意味着可以放心发布？」

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

## 7. 用好它

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

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

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

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

_Diagram: 故障代价越高、出错越可能，test 就越多。金钱、安全和核心逻辑位于右上角。_

#### Risk-based Testing (按风险测试)

- **问题:** 测试的时间很稀缺，想什么都覆盖只会把它摊得太薄。
- **思路:** 失败代价高或可能性大的地方多测；琐碎的地方少测。
- **例子:** 支付的计算和访问权限——测得密；getter 和简单的排版——测得极少。
- **代价:** 你得诚实地判断风险，而不是只测容易测的东西。
- **何时使用:** 每当你决定把测试时间花在哪里的时候。
- **关联:** 取代对 **Code Coverage** 的追逐；在逻辑上以 **Unit Tests** 的形式最密集。

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

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

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

_Diagram: 每次改动都要过 CI：所有 test 全绿——合并；哪怕只有一个红——合并被阻止。_

#### CI 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** 只是暗示缺口在哪里。

_Diagram: 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 Test** (test): 检查代码的代码：输入 → 调用 → assertion → 通过或失败。
- **Regression** (regression bug): 一次改动破坏了以前能用的东西，往往离编辑处很远。

### Unit tests

- **Unit Test** (模块测试): 孤立地测一个函数，几毫秒。你有成千上万个。
- **Arrange–Act–Assert** (AAA): 准备，做一件事，检查结果。

### Integration、e2e 与 pyramid

- **Integration Test** (集成检查): 几个真实的部分一起跑——检查接缝。
- **End-to-End Test** (E2E): 用户眼中的整个系统。贴近现实，但又慢又脆弱。
- **Test Pyramid** (测试金字塔): 很多 unit，较少 integration，几个 e2e。别搞成冰淇淋筒。

### 替身

- **Test Double** (替身，fake): 对又慢、又贵或随机的依赖的替代品。
- **Stub** (罐头回答，canned answer): 返回预先准备的回答——给你的代码喂数据。
- **Mock** (调用记录器): 记录调用——验证某个副作用发生了。
- **Over-mocking** (过度 mock): 全部都 mock——test 是绿的，而真实系统在失败。

### TDD 与好的 test

- **TDD** (red-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，让套件沿着真正发生过的故障成长。但请把一个想法放在所有想法之上：测试不是为了证明今天的代码能用。它们是让你明天能无所畏惧地修改的安全带——而这种自信修改的自由，正是让好软件持续成长、而不是慢慢僵化的原因。
