幕 III · 手艺CH 14进度 14/24

快照与契约:把「和上次一样」变成断言

前面所有测试都要你写出一个具体的期望值。但有些东西,期望值根本没法手写:一整棵 Compose 界面长啥样、一个复杂对象序列化成的那一大坨 JSON、一段渲染出来的 HTML。手写等于抄一遍,又臭又长还容易错。快照测试换个思路:让机器把这次的输出记下来,下次自动比对 —— 变了就报警。它把「和上次长得一样」变成了断言。这一章讲快照怎么用、以及它一个致命的坑(橡皮图章);再讲另一种「关系型」断言 —— 契约测试,专治「前端绿、后端绿,接起来对不上」。

快照测试Compose 截图橡皮图章陷阱契约测试Pact

有些期望值,不值得手写

想给一个「把订单渲染成收据文本」的函数写测试。期望值是这么一坨:

========== 收据 ==========
书 x2          ¥200.00
笔 x1          ¥ 15.00
--------------------------
小计           ¥215.00
优惠(SAVE20)   -¥43.00
合计           ¥172.00
==========================

你当然可以把这一整坨手敲进 assertEquals。但它又长又难维护 —— 格式改一个空格,你就得重敲。这时快照测试更合适。

快照测试:记一次,以后自动比对

◆ 判词 · 快照 = 自动记录 + 自动比对

第一次跑,框架把实际输出存成一个文件(快照)。以后每次跑,它把新输出和存档比对:一样就绿,不一样就把 diff 摆给你看。

你从此不用手写期望值 —— 期望值就是「上次那个」。你只需要在第一次生成时,人眼确认它是对的;之后它替你守着「别悄悄变了」。

文本快照(kotest 的 shouldMatchSnapshot、Jest 的 toMatchSnapshot)适合上面那种字符串输出。而 Android 上最有价值的是截图快照 —— 把一个 Compose 组件渲染成图片存下来,以后像素级比对:

// Paparazzi / Roborazzi:在 JVM 上把 Compose 渲染成 PNG,不用真机
@Test fun `收据卡片_默认样式`() {
    paparazzi.snapshot {
        ReceiptCard(order = sampleOrder)
    }
    // 首次:生成 PNG 存档。之后:新渲染 vs 存档,像素不一致就红
}
◈ 落地 · 截图测试专治「看不见的 UI 回归」

UI 回归最难防:你改了个主题色、动了个 padding,逻辑测试全绿(功能没坏),但某个页面的布局悄悄错位了 —— 没人发现,直到用户截图吐槽。截图快照把「长相」纳入了断言:动了一个像素,CI 就给你一张红绿对比图,让你亲眼看到哪儿变了,再决定是 bug 还是有意为之。

Paparazzi(Cash App)和 Roborazzi 都能在纯 JVM 上渲染 Compose/View,不需要真机,快到可以进 PR 流水线。

快照的致命坑:橡皮图章

快照测试有个和它的便利如影随形的危险,一定要警惕:

⚠ 陷阱 · 「快照挂了?那就更新快照呗」

快照失败时,更新它只需要一个命令(--update-snapshots)。这个便利是把双刃剑:当一个快照红了,人的惰性会让他下意识地重新生成,而不去看 diff 到底改了什么

于是快照测试退化成橡皮图章:每次红了就盲目盖章「通过」,把新的(可能有 bug 的)输出固化成新基准。这样的快照测试什么也没在守 —— 它只是记录了「最新一次碰巧长这样」。

◆ 判词 · 快照的价值,全在「更新前那一眼」

快照测试有没有用,不取决于框架,取决于纪律:每次快照变了,必须有人真的看那张 diff,判断「这个变化是我想要的吗」。是 → 更新。不是 → 这就是它替你抓到的 bug。

把它落进流程:快照的变更必须在 code review 里被当作代码变更一样审。截图快照尤其好审 —— 红绿对比图一目了然。这也直接关系到 AI(下面说)。

另一种关系型断言:契约测试

切换话题,讲一个第 6 章埋的坑。集成测试用 MockWebServer 测「后端返回这坨 JSON 时我能不能解析」—— 但那坨 JSON 是你自己捏的。万一后端真实返回的和你捏的不一样呢?

⚠ 陷阱 · 前端绿、后端绿,接起来炸

前端团队按「我以为后端会返回 userId」写代码和测试,全绿。后端团队按「我返回 user_id」写代码和测试,也全绿。两边各自的测试都没错,但它们对接缝的假设不一致 —— 一联调就炸。谁也没测到这个,因为没有一个测试同时看着两边

契约测试(contract testing)就是那个「同时看着两边」的东西。以 Pact 为代表的消费者驱动契约是这样工作的:

  1. 消费者(前端)写下它对后端的期望:「我调 GET /user/1,期待返回含 idname 字段的 JSON」。这生成一份契约文件。
  2. 提供者(后端)拿这份契约去验证自己:「我真的对这个请求返回了符合契约的响应吗?」
  3. 契约成了双方共享的、可执行的真相。任何一方偷偷改了字段名而没同步,对方的契约验证立刻红。

更轻量的做法是共享 schema:用 OpenAPI / Protobuf 定义接口,前后端都从同一份 schema 生成代码 —— 字段名对不上这种问题在编译期就没了。核心思想都一样:把"接缝两边的约定"变成一个双方都必须遵守、且能自动校验的东西。

共同的内核

◆ 判词 · 快照和契约,都是把「一致性」变成断言

快照测试断言「这次和上次的自己一致」(时间维度)。契约测试断言「我和对面的协作方一致」(空间维度)。它们都在解决「期望值不好直接写、但'和某个参照一致'很好验」的问题。

AI 与快照:一个必须盯死的组合

▲ AI + 快照 = 橡皮图章的完美风暴

让 AI 帮你处理失败的快照测试,它几乎一定会直接更新快照让它变绿 —— 因为「让测试通过」是它理解的目标,而重新生成快照是达成这个目标最快的路。它不会停下来问「这个 UI 变化是不是 bug」。

这等于把第 1 章的问题推到极致:AI 把「有 bug 的新输出」固化成了「正确基准」,还给你一个全绿的 CI。快照的更新,是绝对不能外包给 AI 的动作。diff 必须由人看。这也是为什么截图快照优于纯文本快照 —— 一张红绿对比图,人扫一眼就知道对不对,AI 却容易"盖章了事"。

契约测试:一份双方签字的接口

把消费者驱动契约落成具体的样子。以 Pact 为例,前端(消费者)先写一个测试,声明它对后端的期望,这个测试会生成一份契约文件:

// 消费者端(前端):我期望后端这样响应
val pact = PactBuilder("frontend", "user-service")
    .given("用户 1 存在")
    .uponReceiving("查询用户 1")
    .withRequest(method = "GET", path = "/user/1")
    .willRespondWith(status = 200, body = mapOf(
        "id" to 1, "name" to "Ann"     // ← 我依赖 id 和 name 两个字段
    ))
// 跑这个测试 → 生成 user-service-frontend.json 契约文件

然后后端(提供者)拿这份契约验证自己:「对于 GET /user/1,我真的返回了含 idname 的 200 吗?」如果后端哪天把 name 改成了 full_name,它的契约验证立刻红 —— 在它发布、在联调、在线上炸之前

◆ 判词 · 契约是「跨团队的回归测试」

契约测试的妙处,是它让两个独立部署、独立测试的服务,共享一个可执行的、双方都必须遵守的约定。任何一方单方面改了接口而没同步,对方的契约测试就红。它填的正是第 6 章那个「前端绿、后端绿、接起来炸」的缝 —— 因为现在有一个测试同时看着两边了。相比之下,共享 OpenAPI/Protobuf schema 是更轻量的做法:字段名对不上在编译期就没了,连契约测试都省了。

快照的纪律,落进 CI 和 review

前面说快照的价值全在「更新前那一眼」。怎么保证这一眼真的发生,而不靠自觉?靠流程:

  • 快照文件进版本库:快照(.png / .txt)和代码一起提交、一起 review。快照变了,diff 就出现在 PR 里,审查者必须看。
  • CI 里禁止「就地更新」:CI 上跑快照测试时,不给它 --update 权限 —— 快照不一致就是红,逼你在本地看过 diff、确认无误后再手动更新提交。
  • 截图快照优于文本:一张红绿对比图,审查者扫一眼就知道「这个 UI 变化是我要的吗」;而一大坨文本 diff,人容易划过去盲目盖章。

本质上,这是把「橡皮图章」这个人性弱点,用流程给堵住 —— 让「看 diff」成为合并的必经关卡,而不是一个可以偷懒跳过的自觉动作。

✚ 动手

1. 给一个 Compose 组件加一个 Paparazzi/Roborazzi 截图测试。故意改一下它的 padding,看 CI 给你的红绿对比图。

2. 回顾你项目里的快照测试(如果有):上次它们变更时,有人真的看 diff 了吗,还是盲目 update 了?

3. 找一个前后端字段名对不上踩过的坑,想想一份共享 schema 或一个契约测试,本可以在哪一步就把它挡住。

第三幕(手艺)到此结束。你已经能挑输入、用替身、测异步、写属性、上快照。但接下来才是最扎心的一幕 —— 因为前面教的所有测试,它们自己也会撒谎。第四幕专门拆穿:100% 覆盖率却没有断言、每次结果都不同的 flaky、以及 AI 写的、长得极专业却什么也没验证的测试。先从那个最流行的谎言开始:覆盖率。