关于 Production Smoke Suite
Production Smoke Suite 是一项指导构建生产环境冒烟测试的技能,面向负责部署验证和生产监控的团队。内容涵盖关键用户路径、安全执行、CI/CD 集成及失败告警。
主要功能
- 01聚焦关键用户路径
- 02生产环境只读测试原则
- 03Playwright 与 HTTP 测试模式
- 04重试与稳健断言策略
- 05部署流水线与回滚集成
- 06定时检测和故障告警
适用场景
- 01部署后验证健康检查、首页和登录流程
- 02在发布流水线失败时阻止推广或触发回滚
- 03定时发现证书或第三方服务故障
- 04为 canary 部署验证实例状态
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ pramoddutta/ qaskills --skill production-smoke-suite安装后在 Claude Code 中输入/production-smoke-suite确认已加载。
详细介绍
生产冒烟测试套件
生产冒烟测试是部署与糟糕的用户体验之间的最后一道防线。与可能运行 30 分钟或更久的全面端到端测试套件不同,冒烟测试套件必须在 2 分钟内完成,并验证应用的关键路径是否正常运行。本技能涵盖生产冒烟测试背后的理念、可靠冒烟测试套件的架构、使用 Playwright 和原始 HTTP 客户端的具体实现模式、与部署流水线的集成,以及在真实生产环境中处理第三方依赖、身份验证流程和不稳定网络状况的策略。
核心原则
1. 速度优先于覆盖率
冒烟测试套件不是回归测试套件。它唯一的目的是回答一个问题:“这次部署是否破坏了任何关键功能?”如果测试套件运行时间超过 2 分钟,就太慢了。每项测试都必须证明其必要性,覆盖对收入或用户至关重要的路径。应毫不犹豫地删掉那些无法保护高价值用户旅程的测试。
2. 确保在生产环境中安全执行
冒烟测试会针对真实的生产基础设施运行。它们绝不能创建永久数据、修改用户账户、触发账单事件或发送真实通知。每项测试都必须是只读的,或使用权限受沙盒限制的专用冒烟测试账户。冒烟测试若意外向客户扣款或发送垃圾邮件,还不如完全不做冒烟测试。
3. 确定性断言
生产环境的延迟、CDN 缓存和第三方服务延迟各不相同。冒烟测试必须使用较宽裕的超时时间、重试逻辑,以及能够容忍细微变化的断言。应检查响应是否包含预期字段,而不是要求字符串完全匹配。应验证状态码是否处于可接受范围,而不是断言它必须是某个特定值。
4. 立即触发回滚
冒烟测试套件必须集成到部署流水线中,以便在失败时触发自动回滚。冒烟测试套件通过后,部署才能继续推进;测试失败则会中止发布并通知值班团队。没有这种集成,冒烟测试不过是多了几步操作的监控。
5. 相互独立且彼此隔离
每项冒烟测试都必须完全独立。任何测试都不应依赖其他测试产生的副作用。应尽可能并行运行测试,以缩短套件的总运行时间。测试之间共享状态会带来可靠性风险,最终导致误报或漏报。
项目结构
smoke-tests/
src/
tests/
health-check.spec.ts
homepage.spec.ts
authentication.spec.ts
checkout-flow.spec.ts
api-health.spec.ts
third-party.spec.ts
helpers/
smoke-config.ts
http-client.ts
retry.ts
assertions.ts
alerting.ts
fixtures/
smoke-accounts.ts
playwright.config.ts
package.json
tsconfig.json
Dockerfile
src/tests/ 目录中每个关键路径对应一个文件。src/helpers/ 目录包含用于配置、带重试逻辑的 HTTP 请求、自定义断言和告警集成的共享工具。src/fixtures/ 目录负责管理冒烟测试账户凭据和测试数据。
配置
用于冒烟测试的 Playwright 配置
针对冒烟测试的 Playwright 配置优先考虑速度、可靠性和最低资源消耗:
import { defineConfig, devices } from '@playwright/test';
const BASE_URL = process.env.SMOKE_BASE_URL || 'https://app.example.com';
const TIMEOUT = parseInt(process.env.SMOKE_TIMEOUT || '30000', 10);
export default defineConfig({
testDir: './src/tests',
timeout: TIMEOUT,
expect: {
timeout: 10000,
},
fullyParallel: true,
forbidOnly: true,
retries: 2,
workers: 4,
reporter: [
['list'],
['json', { outputFile: 'smoke-results.json' }],
],
use: {
baseURL: BASE_URL,
screenshot: 'only-on-failure',
video: 'off',
trace: 'retain-on-failure',
actionTimeout: 10000,
navigationTimeout: 15000,
extraHTTPHeaders: {
'X-Smoke-Test': 'true',
},
},
projects: [
{
name: 'smoke-chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});
此配置的关键决策:fullyParallel: true 可并发运行所有测试,从而提升速度。retries: 2 可应对暂时性的网络问题。video: 'off' 可避免为每项测试录制视频所带来的开销。X-Smoke-Test 标头可让后端服务识别并从真实用户分析中筛选出冒烟测试流量。
冒烟测试配置模块
将所有冒烟测试配置集中在一个类型化模块中:
// src/helpers/smoke-config.ts
export interface SmokeConfig {
baseUrl: string;
apiBaseUrl: string;
timeout: number;
retries: number;
smokeAccountEmail: string;
smokeAccountPassword: string;
slackWebhookUrl?: string;
pagerDutyRoutingKey?: string;
environment: 'production' | 'staging';
}
export function getSmokeConfig(): SmokeConfig {
const baseUrl = process.env.SMOKE_BASE_URL;
if (!baseUrl) {
throw new Error('SMOKE_BASE_URL environment variable is required');
}
return {
baseUrl,
apiBaseUrl: process.env.SMOKE_API_URL || `${baseUrl}/api`,
timeout: parseInt(process.env.SMOKE_TIMEOUT || '30000', 10),
retries: parseInt(process.env.SMOKE_RETRIES || '2', 10),
smokeAccountEmail: process.env.SMOKE_ACCOUNT_EMAIL || '',
smokeAccountPassword: process.env.SMOKE_ACCOUNT_PASSWORD || '',
slackWebhookUrl: process.env.SLACK_WEBHOOK_URL,
pagerDutyRoutingKey: process.env.PAGERDUTY_ROUTING_KEY,
environment: (process.env.SMOKE_ENV as 'production' | 'staging') || 'production',
};
}
健康检查测试
API 健康检查端点
每个生产应用都应公开一个健康检查端点。冒烟测试套件应首先验证该端点,因为健康检查失败后,后续所有测试都毫无意义:
// src/tests/api-health.spec.ts
import { test, expect } from '@playwright/test';
import { getSmokeConfig } from '../helpers/smoke-config';
const config = getSmokeConfig();
test.describe('API Health Checks', () => {
test('primary health endpoint returns 200', async ({ request }) => {
const response = await request.get(`${config.apiBaseUrl}/health`);
expect(response.status()).toBe(200);
const body = await response.json();
expect(body).toHaveProperty('status', 'healthy');
expect(body).toHaveProperty('version');
expect(body).toHaveProperty('uptime');
});
test('database connectivity is healthy', async ({ request }) => {
const response = await request.get(`${config.apiBaseUrl}/health/db`);
expect(response.status()).toBe(200);
const body = await response.json();
expect(body).toHaveProperty('database', 'connected');
expect(body.latencyMs).toBeLessThan(500);
});
test('cache layer is responsive', async ({ request }) => {
const response = await request.get(`${config.apiBaseUrl}/health/cache`);
expect(response.status()).toBe(200);
const body = await response.json();
expect(body).toHaveProperty('cache', 'connected');
});
test('critical API endpoints respond within SLA', async ({ request }) => {
const criticalEndpoints = [
{ path: '/api/products', maxLatency: 2000 },
{ path: '/api/categories', maxLatency: 1000 },
{ path: '/api/user/profile', maxLatency: 1500, auth: true },
];
for (const endpoint of criticalEndpoints) {
const start = Date.now();
const headers: Record<string, string> = {};
if (endpoint.auth) {
headers['Authorization'] = `Bearer ${process.env.SMOKE_API_TOKEN}`;
}
const response = await request.get(`${config.baseUrl}${endpoint.path}`, {
headers,
});
const latency = Date.now() - start;
expect(response.status()).toBeLessThan(500);
expect(latency).toBeLessThan(endpoint.maxLatency);
}
});
});
首页和核心页面冒烟测试
// src/tests/homepage.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Core Pages', () => {
test('homepage loads and renders critical elements', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveTitle(/YourApp/);
const hero = page.getByRole('heading', { level: 1 });
await expect(hero).toBeVisible();
const nav = page.getByRole('navigation');
await expect(nav).toBeVisible();
const ctaButton = page.getByRole('link', { name: /get started|sign up/i });
await expect(ctaButton).toBeVisible();
});
test('pricing page loads correctly', async ({ page }) => {
await page.goto('/pricing');
await expect(page).toHaveTitle(/pricing/i);
const plans = page.getByTestId('pricing-plan');
await expect(plans.first()).toBeVisible();
});
test('static assets load without 404 errors', async ({ page }) => {
const failedRequests: string[] = [];
page.on('response', (response) => {
if (response.status() === 404 && response.url().match(/\.(js|css|png|jpg|svg|woff2?)$/)) {
failedRequests.push(`${response.status()} ${response.url()}`);
}
});
await page.goto('/');
await page.waitForLoadState('networkidle');
expect(failedRequests).toEqual([]);
});
test('robots.txt is accessible', async ({ request }) => {
const response = await request.get('/robots.txt');
expect(response.status()).toBe(200);
const text = await response.text();
expect(text).toContain('User-agent');
});
test('sitemap.xml is valid', async ({ request }) => {
const response = await request.get('/sitemap.xml');
expect(response.status()).toBe(200);
const text = await response.text();
expect(text).toContain('<?xml');
expect(text).toContain('<urlset');
});
});
身份验证冒烟测试
身份验证是部署后最需要验证的关键路径之一。登录流程一旦出现故障,所有用户都会立即无法登录:
// src/tests/authentication.spec.ts
import { test, expect } from '@playwright/test';
import { getSmokeConfig } from '../helpers/smoke-config';
const config = getSmokeConfig();
test.describe('Authentication Flow', () => {
test('login page renders correctly', async ({ page }) => {
await page.goto('/login');
await expect(page.getByLabel(/email/i)).toBeVisible();
await expect(page.getByLabel(/password/i)).toBeVisible();
await expect(page.getByRole('button', { name: /sign in|log in/i })).toBeVisible();
});
test('smoke account can authenticate successfully', async ({ page }) => {
test.skip(!config.smokeAccountEmail, 'Smoke account not configured');
await page.goto('/login');
await page.getByLabel(/email/i).fill(config.smokeAccountEmail);
await page.getByLabel(/password/i).fill(config.smokeAccountPassword);
await page.getByRole('button', { name: /sign in|log in/i }).click();
await expect(page).toHaveURL(/dashboard|home/);
await expect(page.getByTestId('user-avatar')).toBeVisible({ timeout: 15000 });
});
test('OAuth providers are available on login page', async ({ page }) => {
await page.goto('/login');
const googleButton = page.getByRole('button', { name: /google/i });
const githubButton = page.getByRole('button', { name: /github/i });
const googleVisible = await googleButton.isVisible().catch(() => false);
const githubVisible = await githubButton.isVisible().catch(() => false);
expect(googleVisible || githubVisible).toBe(true);
});
test('protected route redirects unauthenticated users', async ({ page }) => {
await page.goto('/dashboard');
await expect(page).toHaveURL(/login|signin|auth/);
});
});
关键用户流程冒烟测试
这些测试用于验证直接影响收入和用户满意度的关键用户旅程:
// src/tests/checkout-flow.spec.ts
import { test, expect } from '@playwright/test';
import { getSmokeConfig } from '../helpers/smoke-config';
const config = getSmokeConfig();
test.describe('Critical User Paths', () => {
test('search functionality returns results', async ({ page }) => {
await page.goto('/');
const searchInput = page.getByRole('searchbox').or(page.getByPlaceholder(/search/i));
await searchInput.fill('test query');
await searchInput.press('Enter');
await expect(page.getByTestId('search-results').or(page.locator('[data-search-results]')))
.toBeVisible({ timeout: 10000 });
});
test('product listing page loads with items', async ({ page }) => {
await page.goto('/products');
const productCards = page.getByTestId('product-card');
await expect(productCards.first()).toBeVisible({ timeout: 10000 });
const count = await productCards.count();
expect(count).toBeGreaterThan(0);
});
test('product detail page renders completely', async ({ page }) => {
await page.goto('/products');
const firstProduct = page.getByTestId('product-card').first();
await firstProduct.click();
await expect(page.getByTestId('product-title')).toBeVisible();
await expect(page.getByTestId('product-price')).toBeVisible();
await expect(page.getByRole('button', { name: /add to cart|buy/i })).toBeVisible();
});
test('contact form is accessible and functional', async ({ page }) => {
await page.goto('/contact');
await expect(page.getByLabel(/name/i)).toBeVisible();
await expect(page.getByLabel(/email/i)).toBeVisible();
await expect(page.getByLabel(/message/i)).toBeVisible();
await expect(page.getByRole('button', { name: /send|submit/i })).toBeVisible();
});
});
第三方集成检查
第三方服务可能会独立于你的部署发生故障。冒烟测试应验证这些服务是否可用:
// src/tests/third-party.spec.ts
import { test, expect } from '@playwright/test';
import { getSmokeConfig } from '../helpers/smoke-config';
const config = getSmokeConfig();
test.describe('Third-Party Integrations', () => {
test('CDN serves static assets correctly', async ({ request }) => {
const cdnUrl = process.env.CDN_URL || 'https://cdn.example.com';
const response = await request.get(`${cdnUrl}/health`);
expect(response.status()).toBeLessThan(400);
});
test('payment processor is reachable', async ({ request }) => {
const stripeHealthUrl = 'https://status.stripe.com/api/v2/status.json';
const response = await request.get(stripeHealthUrl);
expect(response.status()).toBe(200);
const body = await response.json();
expect(body.status.indicator).not.toBe('critical');
});
test('email service is operational', async ({ request }) => {
const response = await request.get(`${config.apiBaseUrl}/health/email`);
expect(response.status()).toBe(200);
});
test('analytics script loads on page', async ({ page }) => {
let analyticsLoaded = false;
page.on('response', (response) => {
if (response.url().includes('analytics') || response.url().includes('gtag')) {
if (response.status() === 200) {
analyticsLoaded = true;
}
}
});
await page.goto('/');
await page.waitForLoadState('networkidle');
if (!analyticsLoaded) {
console.warn('Analytics script did not load - verify tracking is operational');
}
});
});
带重试逻辑的 HTTP 客户端
对于无需浏览器的 API 级冒烟检查,可使用内置重试和退避机制的轻量级 HTTP 客户端:
// src/helpers/http-client.ts
interface RetryConfig {
maxRetries: number;
initialDelayMs: number;
maxDelayMs: number;
backoffMultiplier: number;
retryableStatuses: number[];
}
const DEFAULT_RETRY_CONFIG: RetryConfig = {
maxRetries: 3,
initialDelayMs: 1000,
maxDelayMs: 10000,
backoffMultiplier: 2,
retryableStatuses: [502, 503, 504, 429],
};
export async function fetchWithRetry(
url: string,
options: RequestInit = {},
retryConfig: Partial<RetryConfig> = {}
): Promise<Response> {
const config = { ...DEFAULT_RETRY_CONFIG, ...retryConfig };
let lastError: Error | null = null;
let delay = config.initialDelayMs;
for (let attempt = 0; attempt <= config.maxRetries; attempt++) {
try {
const response = await fetch(url, {
...options,
signal: AbortSignal.timeout(30000),
});
if (config.retryableStatuses.includes(response.status) && attempt < config.maxRetries) {
console.warn(
`Attempt ${attempt + 1}/${config.maxRetries + 1}: ${url} returned ${response.status}, retrying...`
);
await sleep(delay);
delay = Math.min(delay * config.backoffMultiplier, config.maxDelayMs);
continue;
}
return response;
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
if (attempt < config.maxRetries) {
console.warn(
`Attempt ${attempt + 1}/${config.maxRetries + 1}: ${url} failed with ${lastError.message}, retrying...`
);
await sleep(delay);
delay = Math.min(delay * config.backoffMultiplier, config.maxDelayMs);
}
}
}
throw new Error(`All ${config.maxRetries + 1} attempts failed for ${url}: ${lastError?.message}`);
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
告警集成
冒烟测试失败时,需要立即通知相关人员:
// src/helpers/alerting.ts
import { getSmokeConfig } from './smoke-config';
interface SmokeResult {
passed: number;
failed: number;
skipped: number;
duration: number;
failures: Array<{ name: string; error: string }>;
environment: string;
url: string;
}
export async function sendSlackAlert(result: SmokeResult): Promise<void> {
const config = getSmokeConfig();
if (!config.slackWebhookUrl) return;
const color = result.failed > 0 ? '#dc2626' : '#16a34a';
const status = result.failed > 0 ? 'FAILED' : 'PASSED';
const failureDetails = result.failures
.slice(0, 5)
.map((f) => `- ${f.name}: ${f.error.substring(0, 100)}`)
.join('\n');
const payload = {
attachments: [
{
color,
title: `Smoke Tests ${status} - ${config.environment}`,
fields: [
{ title: 'Passed', value: String(result.passed), short: true },
{ title: 'Failed', value: String(result.failed), short: true },
{ title: 'Duration', value: `${(result.duration / 1000).toFixed(1)}s`, short: true },
{ title: 'Environment', value: config.environment, short: true },
],
text: result.failed > 0 ? `Failures:\n${failureDetails}` : undefined,
footer: `Smoke Suite | ${new Date().toISOString()}`,
},
],
};
await fetch(config.slackWebhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
}
export async function triggerPagerDuty(result: SmokeResult): Promise<void> {
const config = getSmokeConfig();
if (!config.pagerDutyRoutingKey || result.failed === 0) return;
const payload = {
routing_key: config.pagerDutyRoutingKey,
event_action: 'trigger',
payload: {
summary: `Production smoke tests failed: ${result.failed} failures on ${config.environment}`,
severity: result.failed > 3 ? 'critical' : 'error',
source: `smoke-suite-${config.environment}`,
custom_details: {
passed: result.passed,
failed: result.failed,
failures: result.failures.map((f) => f.name),
},
},
};
await fetch('https://events.pagerduty.com/v2/enqueue', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
}
CI/CD 集成
GitHub Actions 部署后冒烟测试
name: Post-Deployment Smoke Tests
on:
deployment_status:
types: [success]
jobs:
smoke:
if: github.event.deployment_status.state == 'success'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Install dependencies
working-directory: smoke-tests
run: npm ci
- name: Install Playwright browsers
working-directory: smoke-tests
run: npx playwright install chromium --with-deps
- name: Run smoke tests
working-directory: smoke-tests
env:
SMOKE_BASE_URL: ${{ github.event.deployment_status.target_url }}
SMOKE_ACCOUNT_EMAIL: ${{ secrets.SMOKE_ACCOUNT_EMAIL }}
SMOKE_ACCOUNT_PASSWORD: ${{ secrets.SMOKE_ACCOUNT_PASSWORD }}
SMOKE_API_TOKEN: ${{ secrets.SMOKE_API_TOKEN }}
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
SMOKE_ENV: production
run: npx playwright test
- name: Upload failure artifacts
if: failure()
uses: actions/upload-artifact@v4
with:
name: smoke-failure-artifacts
path: smoke-tests/test-results/
retention-days: 7
- name: Trigger rollback on failure
if: failure()
run: |
curl -X POST \
-H "Authorization: Bearer ${{ secrets.DEPLOY_TOKEN }}" \
-H "Content-Type: application/json" \
-d '{"action": "rollback", "deployment_id": "${{ github.event.deployment.id }}"}' \
https://api.example.com/deployments/rollback
基于 Docker 的冒烟测试运行器
将冒烟测试套件打包为 Docker 镜像,以便在不同环境中一致地运行:
FROM mcr.microsoft.com/playwright:v1.40.0-focal
WORKDIR /smoke-tests
COPY package*.json ./
RUN npm ci
COPY . .
ENV CI=true
CMD ["npx", "playwright", "test"]
针对任意目标 URL 运行容器化冒烟测试套件:
// scripts/run-smoke-docker.ts
import { execSync } from 'child_process';
const baseUrl = process.argv[2];
if (!baseUrl) {
console.error('Usage: tsx run-smoke-docker.ts <base-url>');
process.exit(1);
}
try {
execSync(
`docker run --rm \
-e SMOKE_BASE_URL=${baseUrl} \
-e SMOKE_ENV=production \
-v $(pwd)/test-results:/smoke-tests/test-results \
smoke-tests:latest`,
{ stdio: 'inherit' }
);
console.log('Smoke tests passed');
process.exit(0);
} catch {
console.error('Smoke tests failed');
process.exit(1);
}
定期冒烟监控
按 cron 计划运行冒烟测试,在用户报告问题之前发现生产环境问题。这有助于发现由基础设施变更、证书过期或独立于部署发生的第三方服务中断所导致的问题:
// scripts/scheduled-smoke.ts
import { execSync } from 'child_process';
import { readFileSync } from 'fs';
import { sendSlackAlert, triggerPagerDuty } from '../src/helpers/alerting';
interface PlaywrightJsonReport {
stats: {
expected: number;
unexpected: number;
skipped: number;
duration: number;
};
suites: Array<{
specs: Array<{
title: string;
ok: boolean;
tests: Array<{
results: Array<{
error?: { message: string };
}>;
}>;
}>;
}>;
}
async function runScheduledSmoke(): Promise<void> {
const startTime = Date.now();
try {
execSync('npx playwright test --reporter=json', {
stdio: ['pipe', 'pipe', 'pipe'],
env: {
...process.env,
PLAYWRIGHT_JSON_OUTPUT_NAME: 'smoke-results.json',
},
});
} catch {
// Test failures cause non-zero exit code; we handle results via the report file
}
const duration = Date.now() - startTime;
try {
const report: PlaywrightJsonReport = JSON.parse(
readFileSync('smoke-results.json', 'utf-8')
);
const failures: Array<{ name: string; error: string }> = [];
for (const suite of report.suites) {
for (const spec of suite.specs) {
if (!spec.ok) {
const errorMsg = spec.tests[0]?.results[0]?.error?.message || 'Unknown error';
failures.push({ name: spec.title, error: errorMsg });
}
}
}
const result = {
passed: report.stats.expected,
failed: report.stats.unexpected,
skipped: report.stats.skipped,
duration,
failures,
environment: process.env.SMOKE_ENV || 'production',
url: process.env.SMOKE_BASE_URL || '',
};
await sendSlackAlert(result);
if (result.failed > 0) {
await triggerPagerDuty(result);
}
} catch (parseError) {
console.error('Failed to parse smoke results:', parseError);
}
}
runScheduledSmoke();
金丝雀部署验证
对于采用金丝雀或蓝绿部署策略的团队,冒烟测试可在将金丝雀实例提升至承接全部流量之前对其进行验证:
// src/helpers/canary-verifier.ts
interface CanaryConfig {
canaryUrl: string;
stableUrl: string;
healthEndpoint: string;
maxLatencyDifferenceMs: number;
requiredSuccessRate: number;
}
export async function verifyCanary(config: CanaryConfig): Promise<{
canaryHealthy: boolean;
latencyComparison: { canary: number; stable: number; difference: number };
recommendation: 'promote' | 'rollback' | 'investigate';
}> {
const canaryLatencies: number[] = [];
const stableLatencies: number[] = [];
const probeCount = 5;
for (let i = 0; i < probeCount; i++) {
const canaryStart = Date.now();
const canaryResponse = await fetch(`${config.canaryUrl}${config.healthEndpoint}`);
canaryLatencies.push(Date.now() - canaryStart);
const stableStart = Date.now();
const stableResponse = await fetch(`${config.stableUrl}${config.healthEndpoint}`);
stableLatencies.push(Date.now() - stableStart);
if (!canaryResponse.ok) {
return {
canaryHealthy: false,
latencyComparison: { canary: 0, stable: 0, difference: 0 },
recommendation: 'rollback',
};
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
const avgCanary = canaryLatencies.reduce((a, b) => a + b, 0) / canaryLatencies.length;
const avgStable = stableLatencies.reduce((a, b) => a + b, 0) / stableLatencies.length;
const difference = avgCanary - avgStable;
let recommendation: 'promote' | 'rollback' | 'investigate';
if (difference > config.maxLatencyDifferenceMs) {
recommendation = 'investigate';
} else if (avgCanary > avgStable * 2) {
recommendation = 'rollback';
} else {
recommendation = 'promote';
}
return {
canaryHealthy: true,
latencyComparison: {
canary: Math.round(avgCanary),
stable: Math.round(avgStable),
difference: Math.round(difference),
},
recommendation,
};
}
最佳实践
-
将整个测试套件的执行时间控制在 2 分钟以内。 如果耗时更久,说明测试过多或运行过慢。冒烟测试套件应包含 10–25 个有针对性的测试,而不是数百个。
-
使用专用的冒烟测试账号。 切勿使用真实用户凭据。创建权限受限的服务账号,专门用于冒烟测试。定期轮换凭据,并将其存储在密钥管理器中。
-
明确标记冒烟测试流量。 添加
X-Smoke-Test: true等自定义标头,或使用专用的用户代理。这样,监控工具就能将冒烟测试流量与真实用户指标区分开来,并避免冒烟测试污染分析数据。 -
测试部署,而不是测试环境。 冒烟测试应针对实际部署 URL,而非预生产环境。如果部署平台为每次部署提供唯一的预览 URL,请使用该 URL。
-
为第三方检查实现优雅降级。 如果第三方服务不可用,冒烟测试套件应发出警告,而不是导致整个部署失败。区分会阻止部署的第一方故障与应发出警告的第三方故障。
-
仅在失败时捕获屏幕截图和跟踪记录。 为每项测试录制屏幕截图和跟踪记录会浪费资源并拖慢执行速度。配置 Playwright,仅在测试失败时捕获这些产物。
-
尽可能使用 API 级检查。 与 API 检查相比,基于浏览器的测试速度更慢,也更容易出问题。如果可以通过 HTTP 请求验证关键路径,而不必进行完整的浏览器交互,请优先采用 API 方式。
-
按计划运行冒烟测试,而不只是在部署后运行。 基础设施变更、证书过期、数据库迁移或第三方服务中断,都可能独立于部署引发生产问题。
-
与事件管理系统集成。 冒烟测试失败时,应触发 PagerDuty 告警、Slack 通知或团队用于事件响应的其他机制。不要依赖工程师手动查看 CI 仪表板。
-
对冒烟测试配置进行版本管理。 将所有冒烟测试代码、配置和流水线定义存入版本控制。将冒烟测试视为生产基础设施代码,并采用同样严格的评审和部署流程。
-
切勿在生产环境中测试写入操作。 如果冒烟测试套件必须验证表单提交或 API 写入,请使用沙盒端点或模拟模式。冒烟测试意外创建真实订单或发送真实邮件,是常见且代价高昂的错误。
-
将健康检查端点作为第一道关卡。 运行基于浏览器的冒烟测试前,先确认应用的健康检查端点返回健康状态。如果健康检查失败,则跳过成本更高的浏览器测试并立即报告。
应避免的反模式
将完整回归套件用作冒烟套件。 这违背了冒烟测试的初衷。30 分钟的回归套件不算冒烟测试。只提取关键路径,组成专用、轻量的冒烟套件,并严格限制运行时间。
测试实现细节,而非用户结果。 冒烟测试应验证用户能否完成关键任务,而不是验证特定 DOM 元素是否具有特定 CSS 类。断言应针对功能,而不是内部实现选择。
忽视不稳定的冒烟测试。 不稳定的冒烟测试比没有冒烟测试更糟糕,因为它会削弱人们对整个测试套件的信任。冒烟测试一旦变得不稳定,就应立即修复,或者先移除,直到解决根本原因为止。没有折中方案。
将 URL 和选择器硬编码。 使用环境变量指定基础 URL,并使用 data-testid 属性选择元素。部署目标变更时,硬编码的 URL 会失效;每次 UI 重构时,硬编码的 CSS 选择器都会失效。
只在 CI 中运行冒烟测试。 开发者应能在本地针对任意环境运行冒烟套件。这样可以在部署前进行验证,并在冒烟测试于 CI 中失败时更快地调试。
在冒烟测试之间共享状态。 依赖执行顺序的测试很脆弱,并且在并行运行时会不可预测地失败,例如“创建用户”必须先于“用户可以登录”。每项测试都必须完全独立。
没有回滚机制。 没有自动回滚的冒烟测试,只是多了几步操作的监控。部署后冒烟测试的主要价值,在于能够在用户受到影响之前触发自动回滚。
在冒烟套件中过度测试错误路径。 冒烟测试用于验证正常路径是否有效。错误处理、边缘情况和验证规则应放在回归套件中。冒烟套件中的每项测试都应覆盖关键功能的最佳路径。
调试提示
冒烟测试在本地通过,但在 CI 中失败。 检查网络配置、DNS 解析和 SSL 证书是否存在差异。CI 运行器可能无法访问你的生产网络。确认环境变量已正确传递给测试运行器。还要检查生产环境是否启用了基于 IP 的速率限制,从而阻止 CI 运行器的 IP 地址。
多次运行的超时情况不一致。 生产环境的延迟会因流量模式、地理距离和服务负载而异。适当延长默认超时时间,以应对流量高峰期。将 Playwright 的 expect 超时与导航超时分开设置,并为针对生产环境运行的冒烟测试将二者都设置为较长时间。
身份验证测试间歇性失败。 速率限制、CAPTCHA 验证挑战和机器人检测系统可能会阻止冒烟测试登录。请与安全团队合作,将冒烟测试的 IP 地址或用户代理加入白名单。考虑使用基于 API 的令牌身份验证,而不是基于表单的登录。
CDN 缓存清除后测试失败。 部署后,CDN 边缘缓存可能需要几分钟才能填充。为首次请求添加短暂延迟或重试循环,以等待缓存预热。或者,让冒烟测试直接访问源服务器,以绕过 CDN。
屏幕截图显示空白页面。 这通常表示 JavaScript 加载或执行失败。在测试设置中添加 page.on('console', msg => console.log(msg.text())) 和 page.on('pageerror', err => console.error(err)),检查浏览器控制台中的错误。
第三方状态检查不可靠。 外部状态 API 可能响应缓慢或受到速率限制。为第三方检查设置较短的超时时间(最长 5 秒),不要让它们影响部署决策。记录响应缓慢的第三方警告,以便持续跟踪性能下降趋势。
基于 Docker 的冒烟测试运行器无法解析内部 DNS。 在 Docker 中运行冒烟测试时,请确保容器使用正确的 DNS 解析器。本地测试时使用 --network=host,或者根据 CI 环境正确配置容器的 DNS 设置。