上线前的通行检查
冒烟测试回答一个直接的问题:这个构建是否还活着,值得继续测试?它们登录、加载首页、调用健康端点,也许执行一次无害写入,在几分钟甚至几秒内完成。名称来自硬件:给电路板上电并观察是否冒烟,能在深入诊断前发现灾难性故障。软件冒烟测试同理:在影响用户或消耗漫长测试周期之前,发现损坏的部署。
冒烟与健全性测试
两个词常被混用。冒烟测试范围广而浅,检查主要功能是否启动;健全性测试范围窄而深,确认某个修复或改动行为正确。部署门禁通常需要冒烟测试,缺陷修复需要健全性检查。二者都是回归测试的子集,但回答的是不同问题。
作为部署门禁
现代交付流水线在发布后立即运行冒烟测试,然后再切换流量。检查失败时,流水线停止或自动回滚。这在渐进式交付中尤其有价值:金丝雀先接收一小部分流量。冒烟测试应针对已部署的产物,而不是本地构建的副本,并验证数据库、缓存和队列等依赖。
设计与局限
保持小而稳定。选择失败后发布就失去意义的路径:认证、主要读路径和主要写路径。断言有意义的响应,而不只是 HTTP 200。让套件不依赖每天变化的数据。例如,真实下单是不好的冒烟测试,读取固定的商品目录条目更好。冒烟测试不能证明正确性、性能或安全性,它们有意忽略大部分产品;其价值取决于保持快速和可信,因此要抵制把每个新功能都加进去的压力。
代码示例
// e2e/smoke.spec.ts
import { test, expect } from '@playwright/test';
test('visitor can search and open a result', async ({ page }) => {
await page.goto('/search');
await page.getByLabel('Search').fill('rate limiting');
await page.getByRole('button', { name: 'Search' }).click();
await expect(page.getByRole('listitem')).not.toHaveCount(0);
await page.getByRole('link', { name: /rate/i }).first().click();
await expect(page).toHaveURL(/\/examples\//);
});
test('health endpoint is green', async ({ request }) => {
const res = await request.get('/healthz');
expect(res.ok()).toBeTruthy();
expect(await res.json()).toMatchObject({ status: 'up' });
});
// .github/workflows/smoke.yml(节选)
// smoke:
// runs-on: ubuntu-latest
// steps:
// - uses: actions/checkout@v4
// - run: corepack enable
// - run: pnpm install --frozen-lockfile
// - run: pnpm exec playwright install --with-deps chromium
// env:
// PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.cache/ms-playwright
// - run: pnpm exec playwright test --project=chromium --retries=2
// - uses: actions/upload-artifact@v4
// if: failure()
// with:
// name: traces
// path: test-results/
package main
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"sync/atomic"
)
type StubResponse struct {
Status int
Body string
}
func NewStubServer(responses map[string]StubResponse, hits *int64) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if hits != nil {
atomic.AddInt64(hits, 1)
}
response, ok := responses[r.URL.Path]
if !ok {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(response.Status)
_, _ = io.WriteString(w, response.Body)
}))
}
func main() {
var hits int64
server := NewStubServer(map[string]StubResponse{
"/users/1": {Status: http.StatusOK, Body: `{"id":1,"name":"ada"}`},
"/health": {Status: http.StatusOK, Body: `{"status":"ok"}`},
}, &hits)
defer server.Close()
for _, requestPath := range []string{"/users/1", "/health", "/missing"} {
response, err := http.Get(server.URL + requestPath)
if err != nil {
panic(err)
}
body, err := io.ReadAll(response.Body)
response.Body.Close()
if err != nil {
panic(err)
}
fmt.Printf("%s -> %d %s\n", requestPath, response.StatusCode, string(body))
}
fmt.Println("total requests:", atomic.LoadInt64(&hits))
}