← 中文资源

简体中文版本

冒烟测试(Smoke Tests)

冒烟测试是一组快速而浅层的检查,用来确认构建或部署基本可用,然后再进入更深入的测试或接收真实流量。

阅读英文原文

上线前的通行检查

冒烟测试回答一个直接的问题:这个构建是否还活着,值得继续测试?它们登录、加载首页、调用健康端点,也许执行一次无害写入,在几分钟甚至几秒内完成。名称来自硬件:给电路板上电并观察是否冒烟,能在深入诊断前发现灾难性故障。软件冒烟测试同理:在影响用户或消耗漫长测试周期之前,发现损坏的部署。

冒烟与健全性测试

两个词常被混用。冒烟测试范围广而浅,检查主要功能是否启动;健全性测试范围窄而深,确认某个修复或改动行为正确。部署门禁通常需要冒烟测试,缺陷修复需要健全性检查。二者都是回归测试的子集,但回答的是不同问题。

作为部署门禁

现代交付流水线在发布后立即运行冒烟测试,然后再切换流量。检查失败时,流水线停止或自动回滚。这在渐进式交付中尤其有价值:金丝雀先接收一小部分流量。冒烟测试应针对已部署的产物,而不是本地构建的副本,并验证数据库、缓存和队列等依赖。

设计与局限

保持小而稳定。选择失败后发布就失去意义的路径:认证、主要读路径和主要写路径。断言有意义的响应,而不只是 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))
}