mcpField
P
Production Smoke Suite

Production Smoke Suite

github.com/pramodduttaテスト・品質24467 回インストール1 週間前に更新
リポジトリ

Production Smoke Suite について

Production Smoke Suiteは、本番アプリのスモークテスト設計を支援するスキルです。デプロイ検証や重要経路の監視を担うチーム向けに、安全な実行、PlaywrightとHTTPのテスト例、パイプライン連携、失敗通知を扱います。

主な機能

  1. 01重要なユーザー経路に重点
  2. 02本番環境で安全に実行する原則
  3. 03PlaywrightとHTTPのテストパターン
  4. 04リトライと堅牢なアサーション
  5. 05デプロイパイプラインとロールバック連携
  6. 06定期チェックと失敗通知

活用シーン

  1. 01デプロイ後にヘルスチェック、ホームページ、ログインを確認
  2. 02パイプライン検証の失敗時に昇格を停止またはロールバック
  3. 03証明書やサードパーティの障害を定期検知
  4. 04カナリアデプロイ時にインスタンスを検証

インストール

コマンドをコピーしてターミナルで実行します。

$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/ ディレクトリには、重要な経路ごとに 1 つのファイルを置きます。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');
  });
});

認証スモークテスト

認証は、デプロイ後に確認すべき最も重要な経路の 1 つです。ログインフローが壊れると、すべてのユーザーがすぐに締め出されてしまいます。

// 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 を提供する場合は、それを使用します。

  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 をハードコードするとデプロイ先が変わったときに動作しなくなり、CSS セレクターをハードコードすると UI のリファクタリングのたびに壊れます。

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 設定を適切に構成してください。

コメント