mcpField
P
Production Smoke Suite

Production Smoke Suite

github.com/pramoddutta测试与质量24467 次安装1 周前更新
代码仓库

关于 Production Smoke Suite

Production Smoke Suite 是一项指导构建生产环境冒烟测试的技能,面向负责部署验证和生产监控的团队。内容涵盖关键用户路径、安全执行、CI/CD 集成及失败告警。

主要功能

  1. 01聚焦关键用户路径
  2. 02生产环境只读测试原则
  3. 03Playwright 与 HTTP 测试模式
  4. 04重试与稳健断言策略
  5. 05部署流水线与回滚集成
  6. 06定时检测和故障告警

适用场景

  1. 01部署后验证健康检查、首页和登录流程
  2. 02在发布流水线失败时阻止推广或触发回滚
  3. 03定时发现证书或第三方服务故障
  4. 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,
  };
}

最佳实践

  1. 将整个测试套件的执行时间控制在 2 分钟以内。 如果耗时更久,说明测试过多或运行过慢。冒烟测试套件应包含 10–25 个有针对性的测试,而不是数百个。

  2. 使用专用的冒烟测试账号。 切勿使用真实用户凭据。创建权限受限的服务账号,专门用于冒烟测试。定期轮换凭据,并将其存储在密钥管理器中。

  3. 明确标记冒烟测试流量。 添加 X-Smoke-Test: true 等自定义标头,或使用专用的用户代理。这样,监控工具就能将冒烟测试流量与真实用户指标区分开来,并避免冒烟测试污染分析数据。

  4. 测试部署,而不是测试环境。 冒烟测试应针对实际部署 URL,而非预生产环境。如果部署平台为每次部署提供唯一的预览 URL,请使用该 URL。

  5. 为第三方检查实现优雅降级。 如果第三方服务不可用,冒烟测试套件应发出警告,而不是导致整个部署失败。区分会阻止部署的第一方故障与应发出警告的第三方故障。

  6. 仅在失败时捕获屏幕截图和跟踪记录。 为每项测试录制屏幕截图和跟踪记录会浪费资源并拖慢执行速度。配置 Playwright,仅在测试失败时捕获这些产物。

  7. 尽可能使用 API 级检查。 与 API 检查相比,基于浏览器的测试速度更慢,也更容易出问题。如果可以通过 HTTP 请求验证关键路径,而不必进行完整的浏览器交互,请优先采用 API 方式。

  8. 按计划运行冒烟测试,而不只是在部署后运行。 基础设施变更、证书过期、数据库迁移或第三方服务中断,都可能独立于部署引发生产问题。

  9. 与事件管理系统集成。 冒烟测试失败时,应触发 PagerDuty 告警、Slack 通知或团队用于事件响应的其他机制。不要依赖工程师手动查看 CI 仪表板。

  10. 对冒烟测试配置进行版本管理。 将所有冒烟测试代码、配置和流水线定义存入版本控制。将冒烟测试视为生产基础设施代码,并采用同样严格的评审和部署流程。

  11. 切勿在生产环境中测试写入操作。 如果冒烟测试套件必须验证表单提交或 API 写入,请使用沙盒端点或模拟模式。冒烟测试意外创建真实订单或发送真实邮件,是常见且代价高昂的错误。

  12. 将健康检查端点作为第一道关卡。 运行基于浏览器的冒烟测试前,先确认应用的健康检查端点返回健康状态。如果健康检查失败,则跳过成本更高的浏览器测试并立即报告。

应避免的反模式

将完整回归套件用作冒烟套件。 这违背了冒烟测试的初衷。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 设置。

评论

支持 Markdown