幕 II · 测试的形状CH 08进度 08/24

冒烟测试:先确认「它根本没炸」

前面四层是按「范围」分的。冒烟测试(smoke test)不在这个坐标系里 —— 它是按目的分的一小撮测试,横跨所有层。它只回答一个最粗的问题:这玩意儿到底还能不能用?App 能启动吗?登录进得去吗?核心页面渲染出来了吗?它不追求覆盖率,只追求「快速、广度、致命」—— 用最短的时间,确认系统没有从根上塌掉。它是 CI 流水线的守门员:先花 8 秒确认没白炸,值得,再花那 8 分钟跑全套。

名字的由来关键少数快速失败守门员冒烟 vs 回归

名字的由来:通电,不冒烟

「冒烟测试」这个词不是软件发明的。硬件工程师组装好一块电路板,第一件事不是测每个功能,而是通上电,看它冒不冒烟。要是一通电就冒烟(短路、元件烧了),后面所有测试都不用做了 —— 先把这个致命问题解决。管道工也有类似的:新装的管道里打进烟雾,看哪儿漏。

🕘 掌故 · 从电路板到软件

核心思想被原样搬进了软件:在投入昂贵的完整测试之前,先做一个最廉价的、只查「根本性崩溃」的检查。如果连电都通不了(App 起不来、构建都跑不通),就没必要浪费机器跑那几千个单元测试了 —— 反正肯定全红。

软件里的冒烟测试长什么样

它是从你所有测试里,精心挑出的一小撮(通常几十个),特点是:

◆ 判词 · 冒烟测试:广度优先,浅尝辄止
  • 覆盖广:碰到系统的每个主要部分(能启动、能登录、主页面能开、核心操作能走通),但每个只碰最浅的一层 —— 走通就行,不测边界。
  • 只测 happy path:冒烟不管「空输入报不报错」这种细节(那是单元测试的活)。它只问「正常流程能不能跑完」。
  • :整套几秒到一两分钟。快到可以在任何阶段先跑一遍。
  • 致命:冒烟测试红了,意味着「这个版本基本废了」,应该立刻停下,而不是继续往下测。

一个 Android App 的冒烟套件可能就这么几条:

// 冒烟:每一条都是「这条路彻底断了 = 版本报废」
@Test fun `App 能冷启动不崩`() { launchActivity<MainActivity>().use { /* 起来了 */ } }
@Test fun `登录页能渲染`()      { compose.onNodeWithText("登录").assertIsDisplayed() }
@Test fun `正常账号能登录进首页`() { /* 一条最短的登录旅程 */ }
@Test fun `首页能加载出列表`()   { /* 主数据流通了 */ }

对比一下:一个功能可能有 50 个测试覆盖它的各种边界(单元),但冒烟套件里只放那 1 个「它的核心能用」。冒烟不是要测得全,是要测得快、广、命中要害

守门员:先冒烟,再全量

冒烟测试最大的价值,在 CI 流水线里 —— 它让整条流水线快速失败(fail fast)。下面这个 Demo 就是 CI 的分层结构:每个阶段跑哪些测试、要多久。切换阶段看看。

注意「冒烟」在每一个阶段都跑,而且跑在最前面。这是它的核心用法:

◆ 判词 · 用 8 秒,保护那 8 分钟

如果一次改动从根上就坏了(比如依赖注入配错了,App 根本起不来),那么后面几分钟的完整测试必然全红 —— 跑它们纯属浪费机器和你的时间。

冒烟测试守在最前面:它先花几秒确认「地基没塌」。塌了,立刻失败、立刻反馈,别浪费后面的资源。没塌,再放行去跑昂贵的全量。越靠前的关卡越快、越便宜,把明显的错误尽早挡住 —— 这是分层流水线的灵魂(第 22 章展开)。

冒烟 vs 回归 vs 全量:别搞混

这几个词常被混着用,理一下:

类型问的问题范围什么时候跑
冒烟它根本上能用吗?广而浅,几十个每次,最前面
回归以前修好的 bug,又回来了吗?针对已知问题,越积越多合并 / 发版前
全量所有已知行为都对吗?你写过的全部测试合并后 / 夜间

特别说一下回归测试:它不是一种独立写法,而是一个习惯 —— 每修一个 bug,就补一个「专门复现这个 bug」的测试。这个测试从此留在套件里,守着这个 bug永不复活

◈ 落地 · 修 bug 的正确姿势:先写一个红的回归测试

线上报了个 bug?别急着改。先写一个测试,精确复现它 —— 这个测试现在应该是的(它抓到了 bug)。然后你改代码,让它变绿。

好处有三:① 你确认了自己真的理解了 bug(能复现);② 你确认了你的修复真的有效(红转绿);③ 这个 bug 以后再也回不来了 —— 谁不小心把它改回去,这个测试立刻红。这是「先看到红」(第 3 章)在 bug 修复上的应用。

AI 时代:冒烟是巨型改动的第一道闸

AI 让「大改动」变得廉价 —— 它可能一次重构几十个文件、生成一整个新模块。改动越大,「从根上就搞坏了」的概率越高(某个依赖没接上、某个初始化顺序乱了)。

▲ AI 与冒烟:先确认它没把地基拆了

面对一个 AI 生成的大 diff,别一上来就细看每一行逻辑。先跑冒烟:App 还能起来吗?核心旅程还通吗?这是最廉价的「它没有从根上崩」的确认。

冒烟绿了,你才有必要往下investigate细节(单元、集成有没有被改坏)。冒烟红了,说明这个大改动有根本性问题,连细看的价值都没有 —— 直接打回。用最便宜的检查,决定要不要投入昂贵的检查,在 AI 产出海量代码的年代格外划算。

冒烟在流水线里长什么样

把「先冒烟再全量」落成具体的 CI 配置。用 GitHub Actions 举例,关键是让冒烟成为后续任务的前置依赖:

jobs:
  smoke:                      # 第一关:又快又致命
    runs-on: ubuntu-latest
    steps:
      - run: ./gradlew smokeTest    # 只跑打了 @Tag("smoke") 的那几十个
    # ~30 秒

  full:                       # 全量:必须等冒烟绿了才开始
    needs: smoke              # ← 关键:冒烟红,这一关根本不启动
    runs-on: ubuntu-latest
    steps:
      - run: ./gradlew test         # 上千个单元 + 集成
    # ~几分钟

那句 needs: smoke 就是守门员的具体形态:冒烟这关红了,后面那个跑几分钟的全量任务压根不会启动 —— 省下的机器时间和你的等待,都是白赚的。在 Gradle/JUnit5 里,「哪些算冒烟」通常用一个 @Tag("smoke") 标注,再配一个只跑这个 tag 的任务。

「冒烟」这个词,在不同语境下

冒烟的思想到处都是,只是换了名字。理解这一点,你会在很多地方认出它:

语境「冒烟」是什么不冒烟才继续做什么
构建能不能编译通过、App 能不能安装启动再跑那几千个测试
测试套几十个覆盖核心旅程的快测试再跑慢的集成/端到端
部署后健康检查:新版本起来了吗、核心接口通吗再把全部流量切过去(灰度)
发版前在预发环境手点几条关键路径再点发布按钮

尤其是部署后的冒烟(健康检查)值得单说:代码测试全绿,不代表部署成功 —— 配置错了、环境变量漏了、依赖的服务连不上,都会让一个"测试全过"的版本在生产环境起不来。部署完立刻跑一小撮真打生产环境的冒烟(登录能进吗、首页接口 200 吗),是把「部署事故」和「代码事故」分开的关键一步。这也是为什么金丝雀发布总是「先切 1% 流量,冒烟通过,再逐步放大」。

回归测试:一个 bug,一个永久哨兵

上面提了回归是「习惯」而非「写法」,这里把它讲透,因为它是性价比最高的测试类型之一。每一个被修复的 bug,都该留下一个测试,像哨兵一样站在原地:

// 线上事故:某优惠券在金额正好等于门槛时不生效
// 修复的同时,留下这个哨兵。名字里最好带上工单号,方便追溯。
@Test fun `regression PROJ-1234 券在金额等于门槛时生效`() {
    val result = applyCoupon(total = 100, coupon = "SAVE20")   // 边界:正好 100
    assertEquals(80, result)   // 修复前这里是红的
}
◆ 判词 · 回归测试守的是「同一个坑别掉第二次」

真实软件里,大量 bug 是复发的 —— 一个 bug 修好了,几个月后另一次重构不小心又把它引了回来。回归测试让这几乎不可能发生:那个 bug 一旦复活,当年留下的哨兵立刻变红,而且它的名字直接告诉你「这是三个月前修过的 PROJ-1234」。你的回归套件,本质上是团队踩过的所有坑的一份可执行清单 —— 它只会越积越厚,而每一条都在替你守着一个具体的、真实发生过的错误。

✚ 动手

1. 为你的 App 列一份冒烟清单:哪 5-8 条路径,断了就等于「这个版本没法用」?这就是你的冒烟套件。

2. 回到 Demo,对比「保存时」和「合并到主干」两个阶段的墙钟时间。感受为什么本地不该跑全量、而合并必须跑全量。

3. 下次修 bug,强迫自己先写一个能复现它的红测试,再动手改。体会「红转绿」带来的那种确定感。

第二幕收尾。你现在有了完整的测试地形图:单元(底盘)、集成(接缝)、端到端(旅程)、冒烟(守门员)。第三幕转向手艺 —— 同样是这些测试,写好和写坏差十倍。第一课最基础也最关键:怎么挑输入,才抓得到 bug。