冒烟测试(smoke test)是一小组用于验证已部署系统基本”存活”的检查项:进程已启动、主页面返回 200、用户能够登录、数据库能够响应查询。它不判断软件是否正确,而是判断运行其余测试套件是否值得花这个时间。
如果你多年来一直在向 CI 交付代码,却从未需要过这个定义,你并不孤单。这个术语往往在没有任何铺垫的情况下出现,而最近它开始出现在 pull request 里:某个编码 Agent 在完成其他工作的同时顺手添加了一个 smoke.sh 或 smoke.spec.ts。
本文将介绍冒烟测试应该包含什么、它在哪里运行、一个最小实现的代码是什么样子、用什么标准把东西挡在外面,以及为什么 Agent 如此可靠地产出它们。
关键要点
- 冒烟测试检查已部署系统是否存活(启动、主页面返回 200、登录、一次真实的数据库读取),耗时应以秒计,而非以分钟计。
- 它运行两次:作为 CI 中的第一道门禁,以及部署完成后立即针对实际部署到的环境运行;在 localhost 上运行无法捕获部署失败。
- 一项检查只有同时满足以下三点才属于冒烟测试套件:它的失败会阻塞所有用户、所有用户都会经过它、它可能在部署过程中损坏。不满足全部三点的,归入回归测试。
- 编码 Agent 会写冒烟测试,是因为一项完成的任务需要一个廉价、二元、快速的信号来确认没有破坏任何根本性的东西——而这正是冒烟测试所产出的。
- 为套件设定一个硬性上限,并把超出部分全部移到回归测试中;一个耗时十二分钟的冒烟套件,只是一个名字取错了的回归套件。
什么是冒烟测试?
冒烟测试回答一个问题:“这个构建版本是否足够’活着’,值得进一步测试?“——它的名字通常被追溯到给新硬件通电并观察是否冒烟的做法。在软件中,形态是一样的:对每个用户都依赖的路径做一次快速、浅层的遍历,结果是二元的,且不对正确性发表任何意见。
它位于常规测试金字塔之外,而不是处于其中某一层;各层本身在集成测试 vs 端到端测试和 JavaScript 中的单元测试 vs 集成测试:何时用哪种中有介绍。冒烟测试是挡在这些套件前面的一道门禁,而不是它们中的一员。
冒烟测试在哪里运行?
冒烟测试在两个地方运行:作为 CI 中在更长的套件启动前的第一道门禁,以及在部署完成后立即针对实际部署到的环境运行。第二个位置才是真正体现价值的地方,也是最常被跳过的地方。
针对 localhost 运行冒烟测试就违背了它的初衷,因为它存在的意义就是捕获那些只在部署时才会发生的失败:缺失的环境变量、从未执行的数据库迁移、从未发布的资源包。一个在 CI runner 上以 APP_URL=localhost 启动、配着全新内存数据库的进程,根本不具备这些失败模式。那种运行属于启动检查(boot check),启动检查是有用的,但它是另一种、更弱的东西。针对 mock 服务的本地运行,不可能因为部署失败的任何一个原因而失败,所以它不该顶着这个名字。
部署后的运行也是回滚的天然触发点:AWS CodeDeploy 会在新版本开始承接测试流量后运行你的验证函数,验证失败则触发回滚。不过要记住这个信号的局限性。部署后冒烟测试通过,只证明系统有响应,并不证明用户旅程可用;观看发版后最初几个真实会话的 session replay,才是区分这两者的手段——因为一个因 chunk hash 变更而抛错的结算按钮,在状态码里永远看不出来。
冒烟测试长什么样?
一个完整的冒烟套件可以就是一个简短的脚本,不使用任何 mock,直接打真实的部署目标:对健康检查端点做有界等待、一次带认证的请求,以及一次经由应用层抵达数据库的读取。
#!/usr/bin/env bash
# smoke.sh: runs against the deployed target in $APP_URL, never localhost
set -euo pipefail
: "${APP_URL:?set APP_URL to the deployed base URL}"
: "${SMOKE_USER:?}" "${SMOKE_PASS:?}"
status() { curl --silent --output /dev/null --write-out '%{http_code}' "$@"; }
# 1. Wait for the process to come up. This absorbs container start-up, nothing else.
for _ in $(seq 1 "${SMOKE_RETRIES:-10}"); do
[ "$(status "$APP_URL/health")" = "200" ] && break
sleep 3
done
[ "$(status "$APP_URL/health")" = "200" ] || { echo "health: not 200"; exit 1; }
# 2. Login. Assert the one status your app returns (200 for a JSON API, 302 for a form post).
code=$(status --data-urlencode "email=$SMOKE_USER" \
--data-urlencode "password=$SMOKE_PASS" "$APP_URL/login")
[ "$code" = "200" ] || { echo "login: got $code, expected 200"; exit 1; }
# 3. A real read through the app, with the credentials the app was deployed with.
body=$(curl --silent --fail "$APP_URL/api/products?limit=1") || { echo "query: request failed"; exit 1; }
[ -n "$body" ] || { echo "query: empty body"; exit 1; }
echo "smoke: ok"
status 辅助函数利用 curl 的 --write-out '%{http_code}' 捕获响应码,同时 --output /dev/null 丢弃响应体。set -euo pipefail 让脚本在第一次失败时就退出。重试循环的存在只是为了吸收部署后的启动时间,它不是用来掩盖不稳定检查项的手段。
每条断言都指明一个确切的状态码。RFC 9110 中的每个状态码都有其含义,因此一个接受”任意响应”的检查根本不算检查:一个本应存在的路由返回 404 就是部署失败,而 302 只有在契约本身就是重定向的情况下才可接受。第三项检查比看上去更重要。用 pg_isready 这类工具去 ping 数据库服务器,只能确认服务器接受连接;而一次经由应用层的读取,则确认了应用能用它被部署时所带的连接字符串、凭据和 schema 访问到数据库——而这正是冒烟测试存在的意义所针对的失败类别。
什么不属于冒烟测试
边缘情况、业务逻辑、任何慢的东西、任何不稳定的东西,都不属于冒烟测试。一项检查只有在三个条件全部成立时才属于冒烟套件:它的失败会让每个用户什么都做不了、每个用户都会经过它、它可能在部署过程中损坏。满足条件不足三条的检查,应归入回归测试套件。这套三问框架至少出现在某家 CI 测试厂商的指南中,它是一条经验法则,而非标准。
| 候选检查项 | 阻塞所有用户? | 所有用户都会触及? | 部署时可能损坏? | 判定 |
|---|---|---|---|---|
| 健康端点返回 200 | 是 | 是 | 是 | 冒烟 |
| 用测试账号登录 | 是 | 是 | 是 | 冒烟 |
| 优惠码生效折扣 | 否 | 否 | 是 | 回归 |
| 管理员 CSV 导出下载 | 否 | 否 | 是 | 回归 |
| 密码重置邮件送达 | 否 | 否 | 是 | 回归 |
不稳定性本身就足以取消资格。一个随机失败的冒烟检查会训练团队养成重跑红色门禁的习惯,而一个人们会反复重跑直到通过的门禁,已经不再是门禁了。
为什么编码 Agent 总在写冒烟测试?
编码 Agent 会写冒烟测试,是因为一个正在收尾任务的 Agent 需要一个廉价、快速、无歧义的信号来确认自己没有把整个系统搞坏,而这恰恰就是冒烟测试产出的信号。一个刚改完代码的 Agent 承担不起每次迭代都跑完整套件的成本,也无法靠肉眼检查判断正确性,于是它会去抓那个能在几秒内回答”它还活着吗”并返回干净退出码的检查。
这并非偶然。Anthropic 的 Claude Code 文档建议开发者给 Agent 提供一个它可以运行来检查自己工作成果的东西,无论那是测试套件、构建、linter,还是一个小脚本。有了一个它自己能读懂的信号,Agent 就能持续工作并反复自检,而不必等人来发现错误。冒烟脚本高度契合这个描述,这也是为什么由 Agent 编写的代码库往往会长出一个冒烟脚本——哪怕团队从来没用过这个词。这就是开发者如今开始遭遇”冒烟测试”这个概念的原因,而且往往是在一份自己没写过的 diff 里发现它的。
当 PR 中出现冒烟测试时,请对照四个问题审查它。它是否从环境变量读取目标 URL,而不是硬编码 localhost?它是否断言了具体的状态码,而不是”不是错误”?它是否能在几秒内跑完?每项检查是否都通过了上面的三问过滤?一个由 Agent 编写、在上述任何一点上不合格的冒烟测试,要么是启动检查,要么是贴错了标签的回归测试。
失败模式:套件不断膨胀
冒烟套件一旦膨胀到超过几秒钟,就不再是门禁了。这个模式是可预测的:每个功能都”以防万一”加一项检查,Agent 每完成一次任务又加一项,终于有一天这道门禁要跑十二分钟,人们开始跳过它。到那个时候,它就是一个名字取错了的慢速回归套件。
解法是设一个硬性上限,并把超出部分全部移到回归测试。我们推荐的默认值是少量检查,量级在五个左右,几秒内跑完;TestingXperts 将外部上限定为十分钟,超过这个时间团队就会开始跳过门禁。具体数字并不那么重要,重要的是把它写下来,并在代码审查中强制执行——包括对 Agent 新增内容的审查。
结论
冒烟测试是对”部署是活的”这件事所能做出的最小证明:健康检查、登录、一次真实读取,针对你实际交付到的环境运行,几秒内完成。其余一切都是回归测试。下次 Agent 递给你一个 smoke.sh 时,检查它是否指向真实 URL、是否断言确切状态码、是否保持在上限之内,然后把它接进每次部署之后的流程里。
常见问题
冒烟测试和健康检查有什么区别?
健康检查是一个端点,由编排器或负载均衡器轮询,以决定是否路由流量或重启容器;Kubernetes 把这类称为 liveness 和 readiness 探针。冒烟测试每次部署运行一次,除了调用该端点外还包括一次登录和一次数据库读取,并返回一个用于控制流水线门禁的退出码。健康端点是冒烟测试的第一条断言,而不是它的替代品。
冒烟测试和 sanity 测试有什么区别?
冒烟测试是广而浅的:它在更深入的测试开始前,检查一个构建的核心路径是否存活。而在传统 QA 术语中,sanity 测试是窄而深的:它在已通过冒烟测试的构建上验证某个特定修复或变更是否生效,通常被视为回归测试的一个子集。在 CI 流水线中,冒烟套件是门禁;sanity 检查则应与回归测试放在一起。
冒烟测试应该在生产环境运行吗?这样安全吗?
应该。部署后的运行应当针对用户实际访问的环境,包括生产环境,因为部署失败只会在那里显现。要保证安全,可以使用通过 CI secrets 提供的专用预置测试账号,将检查限制在登录和只读请求内,并排除任何写数据或发送邮件的操作。如果写操作不可避免,就把它限定在一个测试租户内,并在同一个脚本中清理掉。
我可以用 Playwright 或 Cypress 而不是 shell 脚本来写冒烟测试吗?
可以,前提是遵循同样的规则:从环境变量读取基础 URL、断言确切的状态码或可见元素、几秒内跑完。浏览器 runner 会为每项检查增加启动和页面加载时间,所以浏览器冒烟套件应控制在一到两条用户旅程,HTTP 检查仍交给 curl。把冒烟 spec 放在独立文件中,这样 CI 就能单独运行它而不必跑整个套件。