冒烟测试:先确认「它根本没炸」
前面四层是按「范围」分的。冒烟测试(smoke test)不在这个坐标系里 —— 它是按目的分的一小撮测试,横跨所有层。它只回答一个最粗的问题:这玩意儿到底还能不能用?App 能启动吗?登录进得去吗?核心页面渲染出来了吗?它不追求覆盖率,只追求「快速、广度、致命」—— 用最短的时间,确认系统没有从根上塌掉。它是 CI 流水线的守门员:先花 8 秒确认没白炸,值得,再花那 8 分钟跑全套。
名字的由来:通电,不冒烟
「冒烟测试」这个词不是软件发明的。硬件工程师组装好一块电路板,第一件事不是测每个功能,而是通上电,看它冒不冒烟。要是一通电就冒烟(短路、元件烧了),后面所有测试都不用做了 —— 先把这个致命问题解决。管道工也有类似的:新装的管道里打进烟雾,看哪儿漏。
核心思想被原样搬进了软件:在投入昂贵的完整测试之前,先做一个最廉价的、只查「根本性崩溃」的检查。如果连电都通不了(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 的分层结构:每个阶段跑哪些测试、要多久。切换阶段看看。
注意「冒烟」在每一个阶段都跑,而且跑在最前面。这是它的核心用法:
如果一次改动从根上就坏了(比如依赖注入配错了,App 根本起不来),那么后面几分钟的完整测试必然全红 —— 跑它们纯属浪费机器和你的时间。
冒烟测试守在最前面:它先花几秒确认「地基没塌」。塌了,立刻失败、立刻反馈,别浪费后面的资源。没塌,再放行去跑昂贵的全量。越靠前的关卡越快、越便宜,把明显的错误尽早挡住 —— 这是分层流水线的灵魂(第 22 章展开)。
冒烟 vs 回归 vs 全量:别搞混
这几个词常被混着用,理一下:
| 类型 | 问的问题 | 范围 | 什么时候跑 |
|---|---|---|---|
| 冒烟 | 它根本上能用吗? | 广而浅,几十个 | 每次,最前面 |
| 回归 | 以前修好的 bug,又回来了吗? | 针对已知问题,越积越多 | 合并 / 发版前 |
| 全量 | 所有已知行为都对吗? | 你写过的全部测试 | 合并后 / 夜间 |
特别说一下回归测试:它不是一种独立写法,而是一个习惯 —— 每修一个 bug,就补一个「专门复现这个 bug」的测试。这个测试从此留在套件里,守着这个 bug永不复活。
线上报了个 bug?别急着改。先写一个测试,精确复现它 —— 这个测试现在应该是红的(它抓到了 bug)。然后你改代码,让它变绿。
好处有三:① 你确认了自己真的理解了 bug(能复现);② 你确认了你的修复真的有效(红转绿);③ 这个 bug 以后再也回不来了 —— 谁不小心把它改回去,这个测试立刻红。这是「先看到红」(第 3 章)在 bug 修复上的应用。
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。