DEV Community

shreyas shinde
shreyas shinde

Posted on • Originally published at kanaeru.ai on

[🇯🇵] 実サービスを使用したテスト:モックを使用しない統合テストの実用的ガイド

さて、チームの皆さん。私はIntegraです。少し物議を醸すかもしれないことをお伝えします: モック中心のテストスイートは、誤った安心感を与えています 。確かに、モックは高速で予測可能、そして簡単にセットアップできます。しかし、本番環境でシステムが実際にどのように動作するかについて、モックは嘘をついています。

何年もの間、「十分にテストされた」アプリケーションが本番環境で崩壊するのを見てきました。その理由は、統合ポイントが幻想的なモックに対して検証されていたからです。私は実サービステストの強力な支持者となりました。純粋主義者だからではなく、実用主義者だからです。実際に重要なバグを捕らえるテストが欲しいのです。

このガイドでは、実サービスを使った統合テストへの体系的なアプローチを説明します。データベースクエリが機能するか、APIコールが成功するか、メッセージキューがメッセージを配信するかを実際に教えてくれるテストです。環境セットアップ、認証情報管理、クリーンアップ戦略、そしてCI/CDパイプラインを燃やすことなく90〜95%のカバレッジを達成する方法について説明します。

実サービスがモックに勝る理由(ほとんどの場合)

まず、部屋の中の象に対処しましょう。Mike Cohnが2009年に導入したテストピラミッドは、何世代もの開発者を、上部の統合テストを少なくし、ユニットテストを基盤とする方向に導いてきました。これは今でも健全なアドバイスです。しかし、チームが間違っている点は、 すべて の統合テストをモック化された依存関係に置き換え、効率的だと考えていることです。

モックファーストテストの問題点

データベースをモック化すると、データベースではなくモックをテストしています。HTTPクライアントをモック化すると、fetch()を正しく呼び出したことを検証しているのであって、リモートAPIが実際にコードが期待するデータを返すことを検証しているのではありません。

モックが捕らえられないもの:

  • スキーマの不一致 : モックはuser.firstNameを返しますが、APIは実際にはuser.first_nameを送信します
  • ネットワーク障害 : タイムアウト、接続リセット、DNS障害—モックランドでは見えません
  • データベース制約 : モックは重複メールを喜んで受け入れますが、PostgreSQLは一意制約違反をスローします
  • 認証フロー : OAuthトークンが期限切れになり、リフレッシュトークンが失敗し、APIキーがレート制限されます
  • シリアライゼーションの問題 : そのJavaScript Dateオブジェクトは、あなたが思うようにはシリアライズされません

Philipp Hauerが2019年の記事で雄弁に述べたように:「統合テストは、本番環境と同じように、すべてのクラスとレイヤーを一緒にテストします。これにより、クラスの統合におけるバグが検出される可能性がはるかに高くなり、テストがより意味のあるものになります」。

モックが適切な場合

私は狂信者ではありません。統合テストにおいてもモックが正当な場面があります:

  1. 障害シナリオのテスト : Toxiproxyのようなネットワークシミュレータは、制御された方法でレイテンシと障害を注入できます
  2. 制御できないサードパーティサービス : Stripeの本番APIと統合している場合、実際の課金ではなく、テストモードが必要でしょう
  3. 遅いまたは高価な操作 : MLモデルのトレーニングに5分かかる場合、ほとんどのテストで推論をモック化します
  4. 特定のコンポーネントの分離 : サービスBが失敗したときのサービスAの動作をテストする場合、Bのレスポンスをモック化します

重要な原則: 境界でモック化し、統合をテストする

嘘をつかないテスト環境のセットアップ

本番環境をミラーリングするテスト環境は、実サービステストにとって譲れません。しかし、「本番環境をミラーリングする」とは、「AWSインフラ全体を複製する」という意味ではありません。同じ インターフェース を持つ同じ タイプ のサービスを持つことを意味します。

コンテナ革命

DockerとTestcontainersのおかげで、実際のデータベース、メッセージキュー、さらには複雑なサービスを数秒で起動できます。モダンなテスト環境は次のようになります:

// testSetup.ts - Environment bootstrapping
import { GenericContainer, StartedTestContainer } from 'testcontainers';
import { Pool } from 'pg';
import Redis from 'ioredis';

export class TestEnvironment {
  private postgresContainer: StartedTestContainer;
  private redisContainer: StartedTestContainer;
  private dbPool: Pool;
  private redisClient: Redis;

  async setup(): Promise<void> {
    // Start PostgreSQL with exact production version
    this.postgresContainer = await new GenericContainer('postgres:15-alpine')
      .withEnvironment({
        POSTGRES_USER: 'testuser',
        POSTGRES_PASSWORD: 'testpass',
        POSTGRES_DB: 'testdb',
      })
      .withExposedPorts(5432)
      .start();

    // Start Redis with production configuration
    this.redisContainer = await new GenericContainer('redis:7-alpine')
      .withExposedPorts(6379)
      .start();

    // Initialize real clients
    const pgPort = this.postgresContainer.getMappedPort(5432);
    this.dbPool = new Pool({
      host: 'localhost',
      port: pgPort,
      user: 'testuser',
      password: 'testpass',
      database: 'testdb',
    });

    const redisPort = this.redisContainer.getMappedPort(6379);
    this.redisClient = new Redis({ host: 'localhost', port: redisPort });

    // Run migrations on real database
    await this.runMigrations();
  }

  async cleanup(): Promise<void> {
    await this.dbPool.end();
    await this.redisClient.quit();
    await this.postgresContainer.stop();
    await this.redisContainer.stop();
  }

  getDbPool(): Pool {
    return this.dbPool;
  }

  getRedisClient(): Redis {
    return this.redisClient;
  }

  private async runMigrations(): Promise<void> {
    // Run your actual migration scripts
    // This ensures test DB schema matches production
    const migrationSQL = await readFile('./migrations/001_initial.sql', 'utf-8');
    await this.dbPool.query(migrationSQL);
  }
}

Enter fullscreen mode Exit fullscreen mode

重要な洞察 : 本番環境と 全く同じPostgreSQLバージョン を使用していることに注目してください。バージョンの不一致は、「私のマシンでは動作する」バグの一般的な原因です。

環境設定戦略

テスト環境は本番環境とは異なる設定が必要ですが、同じ 構造 である必要があります。推奨するパターンは次のとおりです:

// config/test.ts
export const testConfig = {
  database: {
    // Provided by Testcontainers at runtime
    host: process.env.TEST_DB_HOST || 'localhost',
    port: parseInt(process.env.TEST_DB_PORT || '5432'),
    // Safe credentials for testing
    user: 'testuser',
    password: 'testpass',
  },

  externalAPIs: {
    // Use sandbox/test modes of real services
    stripe: {
      apiKey: process.env.STRIPE_TEST_KEY, // sk_test_...
      webhookSecret: process.env.STRIPE_TEST_WEBHOOK_SECRET,
    },
    sendgrid: {
      apiKey: process.env.SENDGRID_TEST_KEY,
      // Use SendGrid's sandbox mode
      sandboxMode: true,
    },
  },

  // Feature flags for test scenarios
  features: {
    enableRateLimiting: true, // Test rate limits!
    enableCaching: true, // Test cache invalidation!
    enableRetries: true, // Test retry logic!
  },
};

Enter fullscreen mode Exit fullscreen mode

API認証情報の管理: 正しい方法

多くのチームがつまずくのはここです。コードベースにテストAPIキーをハードコーディングしたり、さらに悪いことに、テストで本番キーを使用したりします。どちらもセキュリティ上の悪夢です。

シークレット管理の階層

  1. ローカル開発 : テスト認証情報を含む.env.testファイルを使用します(gitignored!)
  2. CI/CDパイプライン : CIプロバイダーのボールト(GitHub Secrets、GitLab CI/CD変数など)にシークレットを保存します
  3. 共有テスト環境 : 専用のシークレットマネージャー(AWS Secrets Manager、HashiCorp Vault)を使用します

堅牢な認証情報ロードパターンは次のとおりです:

// lib/testCredentials.ts
import { config } from 'dotenv';

export class TestCredentialManager {
  private credentials: Map<string, string> = new Map();

  constructor() {
    // Load from .env.test if present (local dev)
    config({ path: '.env.test' });

    // Override with CI environment variables if present
    this.loadFromEnvironment();

    // Validate required credentials
    this.validate();
  }

  private loadFromEnvironment(): void {
    const requiredCreds = [
      'STRIPE_TEST_KEY',
      'SENDGRID_TEST_KEY',
      'AWS_TEST_ACCESS_KEY',
      'AWS_TEST_SECRET_KEY',
    ];

    requiredCreds.forEach((key) => {
      const value = process.env[key];
      if (value) {
        this.credentials.set(key, value);
      }
    });
  }

  private validate(): void {
    const missing: string[] = [];

    // Check for essential credentials
    if (!this.credentials.has('STRIPE_TEST_KEY')) {
      missing.push('STRIPE_TEST_KEY');
    }

    if (missing.length > 0) {
      console.warn(
        `⚠️ Missing test credentials: ${missing.join(', ')}\n` +
        `Some integration tests will be skipped.\n` +
        `See README.md for credential setup instructions.`
      );
    }
  }

  get(key: string): string | undefined {
    return this.credentials.get(key);
  }

  has(key: string): boolean {
    return this.credentials.has(key);
  }

  // Fail gracefully when credentials are missing
  requireOrSkip(key: string, testFn: () => void): void {
    if (!this.has(key)) {
      console.log(`⏭️ Skipping test - missing ${key}`);
      return;
    }
    testFn();
  }
}

// Usage in tests
const credManager = new TestCredentialManager();

describe('Stripe Payment Integration', () => {
  it('should process payment with real Stripe API', async () => {
    credManager.requireOrSkip('STRIPE_TEST_KEY', async () => {
      const stripe = new Stripe(credManager.get('STRIPE_TEST_KEY')!);

      const paymentIntent = await stripe.paymentIntents.create({
        amount: 1000,
        currency: 'usd',
        payment_method_types: ['card'],
      });

      expect(paymentIntent.status).toBe('requires_payment_method');
    });
  });
});

Enter fullscreen mode Exit fullscreen mode

重要な原則 : 認証情報が欠落している場合、テストはスイート全体をクラッシュさせるのではなく、 優雅に劣化 する必要があります。これにより、開発者はローカルで部分的なテストスイートを実行でき、CIは完全なバッテリーを実行できます。

CI/CD統合パターン

GitHub Actionsワークフローでは:

# .github/workflows/test.yml
name: Integration Tests

on: [push, pull_request]

jobs:
  integration-tests:
    runs-on: ubuntu-latest

    env:
      # Inject secrets from GitHub Secrets
      STRIPE_TEST_KEY: ${{ secrets.STRIPE_TEST_KEY }}
      SENDGRID_TEST_KEY: ${{ secrets.SENDGRID_TEST_KEY }}
      AWS_TEST_ACCESS_KEY: ${{ secrets.AWS_TEST_ACCESS_KEY }}
      AWS_TEST_SECRET_KEY: ${{ secrets.AWS_TEST_SECRET_KEY }}

    steps:
      - uses: actions/checkout@v3

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'

      - name: Install dependencies
        run: npm ci

      - name: Run integration tests
        run: npm run test:integration

      - name: Upload coverage reports
        uses: codecov/codecov-action@v3
        with:
          files: ./coverage/integration-coverage.json

Enter fullscreen mode Exit fullscreen mode

クリーンアップ戦略: 冪等性の必須事項

真実の爆弾をお伝えします: テストが冪等でない場合、信頼できません 。冪等テストは、以前の実行に関係なく、実行するたびに同じ結果を生成します。

冪等性に対する最大の脅威は? 汚い状態 。テストAがメールtest@example.comを持つユーザーを作成し、テストBはそのメールが利用可能であると想定します。テストBは失敗します。テストAがクリーンアップしなかったことに気付くまで、1時間デバッグします。

セットアップ前パターン(推奨)

直感に反して、テスト にクリーンアップすることは、 にクリーンアップするよりも信頼性が高くなります:

// tests/integration/userService.test.ts
describe('UserService Integration', () => {
  let testEnv: TestEnvironment;
  let userService: UserService;

  beforeAll(async () => {
    testEnv = new TestEnvironment();
    await testEnv.setup();
  });

  afterAll(async () => {
    await testEnv.cleanup();
  });

  beforeEach(async () => {
    // CLEAN BEFORE, not after
    // This ensures tests start from known state
    await cleanDatabase(testEnv.getDbPool());

    userService = new UserService(testEnv.getDbPool());
  });

  it('should create user with unique email', async () => {
    const user = await userService.createUser({
      email: 'test@example.com',
      name: 'Test User',
    });

    expect(user.id).toBeDefined();
    expect(user.email).toBe('test@example.com');
  });

  it('should reject duplicate email', async () => {
    await userService.createUser({
      email: 'duplicate@example.com',
      name: 'User One',
    });

    await expect(
      userService.createUser({
        email: 'duplicate@example.com',
        name: 'User Two',
      })
    ).rejects.toThrow('Email already exists');
  });
});

async function cleanDatabase(pool: Pool): Promise<void> {
  // Truncate tables in correct order (respecting foreign keys)
  await pool.query('TRUNCATE users, orders, payments CASCADE');
}

Enter fullscreen mode Exit fullscreen mode

なぜ前にクリーンアップするのか? テストが実行中にクラッシュした場合、後のクリーンアップは実行されません。データベースは汚いままです。次のテスト実行は不思議に失敗します。前のクリーンアップでは、すべてのテストが既知の状態から開始します。

外部サービス用のTry-Finallyパターン

簡単にリセットできない外部APIやサービスの場合、try-finallyブロックを使用します:

it('should send email via SendGrid', async () => {
  const testEmailId = `test-${Date.now()}@example.com`;
  let emailSent = false;

  try {
    // Arrange
    const sendgrid = new SendGridClient(testConfig.sendgridApiKey);

    // Act
    await sendgrid.send({
      to: testEmailId,
      from: 'noreply@example.com',
      subject: 'Test Email',
      text: 'This is a test',
    });
    emailSent = true;

    // Assert
    const emails = await sendgrid.searchEmails({
      to: testEmailId,
      limit: 1,
    });
    expect(emails).toHaveLength(1);

  } finally {
    // Cleanup - even if test fails
    if (emailSent) {
      await sendgrid.deleteEmail(testEmailId);
    }
  }
});

Enter fullscreen mode Exit fullscreen mode

並列テスト実行の処理

モダンなテストランナーは、速度のためにテストを並列実行します。テストAが、テストBがクエリしているユーザーを削除するまでは素晴らしいことです。解決策は? データの分離 :

// testDataFactory.ts
export class TestDataFactory {
  private static counter = 0;

  static uniqueEmail(): string {
    return `test-${process.pid}-${TestDataFactory.counter++}@example.com`;
  }

  static uniqueUserId(): string {
    return `user-${process.pid}-${TestDataFactory.counter++}`;
  }

  static async createIsolatedUser(pool: Pool): Promise<User> {
    const email = TestDataFactory.uniqueEmail();
    const result = await pool.query(
      'INSERT INTO users (email, name) VALUES ($1, $2) RETURNING *',
      [email, `Test User ${TestDataFactory.counter}`]
    );
    return result.rows[0];
  }
}

// Usage ensures no collisions between parallel tests
it('test A with isolated data', async () => {
  const user = await TestDataFactory.createIsolatedUser(pool);
  // Test uses user, no other test can access this user
});

it('test B with isolated data', async () => {
  const user = await TestDataFactory.createIsolatedUser(pool);
  // Runs in parallel with test A, zero conflicts
});

Enter fullscreen mode Exit fullscreen mode

エラーシナリオのテスト: 実サービスが輝く場所

モックはハッピーパステストを簡単にします。実サービスは 障害テスト を可能にします。そして、障害テストは、本番環境をクラッシュさせるバグを見つける場所です。

ネットワーク障害シミュレーション

Toxiproxyのようなツールを使用すると、実際のサービス呼び出しにネットワーク障害を注入できます:

import { Toxiproxy } from 'toxiproxy-node-client';

describe('Payment Service - Network Resilience', () => {
  let toxiproxy: Toxiproxy;
  let paymentService: PaymentService;

  beforeAll(async () => {
    toxiproxy = new Toxiproxy('http://localhost:8474');

    // Create proxy for Stripe API
    await toxiproxy.createProxy({
      name: 'stripe_api',
      listen: '0.0.0.0:6789',
      upstream: 'api.stripe.com:443',
    });
  });

  it('should retry on network timeout', async () => {
    // Inject 5-second latency
    await toxiproxy.addToxic({
      proxy: 'stripe_api',
      type: 'latency',
      attributes: { latency: 5000 },
    });

    const start = Date.now();

    await expect(
      paymentService.processPayment({ amount: 1000 })
    ).rejects.toThrow('Request timeout');

    const duration = Date.now() - start;

    // Verify retry logic kicked in (3 retries = ~15 seconds)
    expect(duration).toBeGreaterThan(15000);
  });

  it('should handle connection reset', async () => {
    // Inject connection reset
    await toxiproxy.addToxic({
      proxy: 'stripe_api',
      type: 'reset_peer',
      attributes: { timeout: 0 },
    });

    await expect(
      paymentService.processPayment({ amount: 1000 })
    ).rejects.toThrow('Connection reset');
  });

  afterEach(async () => {
    // Remove toxics between tests
    await toxiproxy.removeToxic({ proxy: 'stripe_api' });
  });
});

Enter fullscreen mode Exit fullscreen mode

レート制限とスロットリング

システムがAPIレート制限をどのように処理するかをテストします:

it('should respect rate limits', async () => {
  const apiClient = new ExternalAPIClient(testConfig.apiKey);
  const results: Array<'success' | 'throttled'> = [];

  // Hammer the API with 100 requests
  const requests = Array.from({ length: 100 }, async () => {
    try {
      await apiClient.getData();
      results.push('success');
    } catch (error) {
      if (error.statusCode === 429) {
        results.push('throttled');
      } else {
        throw error;
      }
    }
  });

  await Promise.allSettled(requests);

  // Verify rate limiting kicked in
  expect(results.filter(r => r === 'throttled').length).toBeGreaterThan(0);

  // Verify some requests succeeded (we're not completely blocked)
  expect(results.filter(r => r === 'success').length).toBeGreaterThan(0);
});

Enter fullscreen mode Exit fullscreen mode

90-95%のカバレッジを達成する: 実用的な目標

数字について話しましょう。100%のカバレッジは無駄な努力です—機能を書くよりもテストを維持することに多くの時間を費やすことになります。しかし、80%未満では、盲目的に飛んでいることになります。スイートスポットは? テストタイプの戦略的ミックスで90〜95%のカバレッジ

モダンなテスト配分

Guillermo Rauchの有名な引用:「テストを書く。多すぎず。ほとんど統合」。実際にはこのようになります:

  • 50-60% ユニットテスト : 高速で焦点を絞った、分離されたビジネスロジックのテスト
  • 30-40% 統合テスト : 実サービス、コンポーネント間のインタラクションのテスト
  • 5-10% E2Eテスト : 完全なシステムテスト、重要なユーザージャーニー

グラフィック提案1 : 統合テストを戦略的な中間層として示す修正されたテストピラミッドで、「実データベース」、「実API」、「実メッセージキュー」のコールアウトがあります。

優先すべきカバレッジギャップ

統合テストを以下の高価値領域に集中させます:

  1. 認証/認可フロー : トークンの更新、権限チェック、セッション管理
  2. データ永続性 : データベーストランザクション、制約違反、マイグレーション
  3. 外部API統合 : 支払い処理、メール配信、サードパーティデータ
  4. メッセージキュー操作 : イベント公開、メッセージ消費、デッドレター処理
  5. キャッシュ無効化 : キャッシュはいつ更新されるか?キャッシュミス時に何が起こるか?

重要なことを測定する

コードカバレッジツールは嘘をつきます。実行された行を教えてくれますが、検証された動作は教えてくれません。 統合カバレッジ を別々に追跡します:

// package.json
{
  "scripts": {
    "test:unit": "jest --coverage --coverageDirectory=coverage/unit",
    "test:integration": "jest --config=jest.integration.config.js --coverage --coverageDirectory=coverage/integration",
    "test:coverage": "node scripts/mergeCoverage.js"
  }
}


// scripts/mergeCoverage.js
import { mergeCoverageReports } from 'coverage-merge';

const unitCoverage = require('../coverage/unit/coverage-summary.json');
const integrationCoverage = require('../coverage/integration/coverage-summary.json');

const merged = mergeCoverageReports([unitCoverage, integrationCoverage]);

console.log('Combined Coverage Report:');
console.log(`Lines: ${merged.total.lines.pct}%`);
console.log(`Statements: ${merged.total.statements.pct}%`);
console.log(`Functions: ${merged.total.functions.pct}%`);
console.log(`Branches: ${merged.total.branches.pct}%`);

// Fail if below threshold
if (merged.total.lines.pct < 90) {
  console.error('❌ Coverage below 90% threshold');
  process.exit(1);
}

Enter fullscreen mode Exit fullscreen mode

グラフィック提案2 : モジュール別のユニット対統合カバレッジの内訳を示すカバレッジダッシュボードのモックアップで、統合テストが「リスクのある」領域(データベース、外部API)を強調表示します。

CI/CD統合: どこでも実行されるテスト

CI/CDでの統合テストは難しいです。ユニットテストよりも遅く、インフラストラクチャが必要で、認証情報が必要です。しかし、本番環境の前の最後の防御線でもあります。

マルチステージパイプライン

# .github/workflows/full-pipeline.yml
name: Full Test Pipeline

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: '18'
      - run: npm ci
      - run: npm run test:unit
      - uses: codecov/codecov-action@v3
        with:
          files: ./coverage/unit/coverage-final.json
          flags: unit

  integration-tests:
    runs-on: ubuntu-latest
    # Only run on main/develop or when PR is marked ready
    if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' || github.event.pull_request.draft == false

    services:
      # GitHub Actions provides service containers
      postgres:
        image: postgres:15-alpine
        env:
          POSTGRES_USER: testuser
          POSTGRES_PASSWORD: testpass
          POSTGRES_DB: testdb
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
        ports:
          - 5432:5432

      redis:
        image: redis:7-alpine
        options: >-
          --health-cmd "redis-cli ping"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
        ports:
          - 6379:6379

    env:
      TEST_DB_HOST: localhost
      TEST_DB_PORT: 5432
      STRIPE_TEST_KEY: ${{ secrets.STRIPE_TEST_KEY }}
      SENDGRID_TEST_KEY: ${{ secrets.SENDGRID_TEST_KEY }}

    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: '18'
      - run: npm ci
      - run: npm run db:migrate:test
      - run: npm run test:integration
      - uses: codecov/codecov-action@v3
        with:
          files: ./coverage/integration/coverage-final.json
          flags: integration

  e2e-tests:
    runs-on: ubuntu-latest
    needs: [unit-tests, integration-tests]
    # Only run E2E on main branch or when explicitly requested
    if: github.ref == 'refs/heads/main' || contains(github.event.pull_request.labels.*.name, 'run-e2e')

    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: '18'
      - run: npm ci
      - run: npm run test:e2e

Enter fullscreen mode Exit fullscreen mode

重要なパターン :

  • ユニットテストはすべてのコミットで実行されます(高速フィードバック)
  • 統合テストはmain/developおよび準備完了のPRで実行されます(マージ前に統合バグをキャッチ)
  • E2Eテストはmainまたは明示的に要求された場合にのみ実行されます(遅いが包括的)

グラフィック提案3 : マルチステージアプローチと条件(どのテストをいつ実行するか)を示すCI/CDパイプラインフローチャートで、インフラストラクチャセットアップ(コンテナ)とシークレット注入ポイントを含みます。

最適化: キャッシュされた依存関係

実行ごとにDockerイメージを再構築する統合テストは時間を無駄にします。積極的にキャッシュします:

- name: Cache Docker layers
  uses: actions/cache@v3
  with:
    path: /tmp/.buildx-cache
    key: ${{ runner.os }}-buildx-${{ hashFiles('**/Dockerfile') }}
    restore-keys: |
      ${{ runner.os }}-buildx-

- name: Pull Docker images
  run: |
    docker pull postgres:15-alpine
    docker pull redis:7-alpine

Enter fullscreen mode Exit fullscreen mode

CIでの並列実行

独立した統合テストスイートを並列実行します:

integration-tests:
  strategy:
    matrix:
      test-suite: [database, api, messaging, cache]

  steps:
    - run: npm run test:integration:${{ matrix.test-suite }}

Enter fullscreen mode Exit fullscreen mode

グラフィック提案4 : データベース、API、メッセージング、キャッシュテストを同時実行することによる時間節約を強調表示する、シリアル対並列実行を示すテスト実行タイムライン。

実世界の統合テスト例

現実的なeコマースチェックアウトフローですべてをまとめましょう:

// tests/integration/checkout.test.ts
import { TestEnvironment } from '../testSetup';
import { CheckoutService } from '../../src/services/CheckoutService';
import { StripePaymentProcessor } from '../../src/payments/StripePaymentProcessor';
import { SendGridEmailService } from '../../src/email/SendGridEmailService';
import { TestDataFactory } from '../testDataFactory';
import { TestCredentialManager } from '../testCredentials';

describe('Checkout Integration', () => {
  let testEnv: TestEnvironment;
  let checkoutService: CheckoutService;
  let credManager: TestCredentialManager;

  beforeAll(async () => {
    testEnv = new TestEnvironment();
    await testEnv.setup();
    credManager = new TestCredentialManager();
  });

  afterAll(async () => {
    await testEnv.cleanup();
  });

  beforeEach(async () => {
    // Clean state before each test
    await testEnv.getDbPool().query('TRUNCATE orders, payments, users CASCADE');
  });

  it('should complete full checkout with real payment and email', async () => {
    credManager.requireOrSkip('STRIPE_TEST_KEY', async () => {
      credManager.requireOrSkip('SENDGRID_TEST_KEY', async () => {
        // Arrange: Create test user with isolated data
        const user = await TestDataFactory.createIsolatedUser(testEnv.getDbPool());

        const paymentProcessor = new StripePaymentProcessor(
          credManager.get('STRIPE_TEST_KEY')!
        );

        const emailService = new SendGridEmailService(
          credManager.get('SENDGRID_TEST_KEY')!
        );

        checkoutService = new CheckoutService(
          testEnv.getDbPool(),
          paymentProcessor,
          emailService
        );

        const cart = {
          items: [
            { productId: 'prod_123', quantity: 2, price: 1999 },
            { productId: 'prod_456', quantity: 1, price: 4999 },
          ],
        };

        let orderId: string;

        try {
          // Act: Process checkout with REAL Stripe payment
          const result = await checkoutService.processCheckout({
            userId: user.id,
            cart,
            paymentMethod: {
              type: 'card',
              cardToken: 'tok_visa', // Stripe test token
            },
          });

          orderId = result.orderId;

          // Assert: Verify order created in REAL database
          const orderResult = await testEnv.getDbPool().query(
            'SELECT * FROM orders WHERE id = $1',
            [orderId]
          );
          expect(orderResult.rows).toHaveLength(1);
          expect(orderResult.rows[0].status).toBe('completed');
          expect(orderResult.rows[0].total_amount).toBe(8997);

          // Assert: Verify payment recorded
          const paymentResult = await testEnv.getDbPool().query(
            'SELECT * FROM payments WHERE order_id = $1',
            [orderId]
          );
          expect(paymentResult.rows).toHaveLength(1);
          expect(paymentResult.rows[0].status).toBe('succeeded');
          expect(paymentResult.rows[0].provider).toBe('stripe');

          // Assert: Verify email sent via REAL SendGrid
          const emails = await emailService.searchEmails({
            to: user.email,
            subject: 'Order Confirmation',
            limit: 1,
          });
          expect(emails).toHaveLength(1);
          expect(emails[0].body).toContain(orderId);

        } finally {
          // Cleanup: Cancel order and refund payment
          if (orderId) {
            await checkoutService.cancelOrder(orderId);
          }
        }
      });
    });
  });

  it('should handle payment failure gracefully', async () => {
    credManager.requireOrSkip('STRIPE_TEST_KEY', async () => {
      const user = await TestDataFactory.createIsolatedUser(testEnv.getDbPool());

      const paymentProcessor = new StripePaymentProcessor(
        credManager.get('STRIPE_TEST_KEY')!
      );

      checkoutService = new CheckoutService(
        testEnv.getDbPool(),
        paymentProcessor,
        new SendGridEmailService(credManager.get('SENDGRID_TEST_KEY')!)
      );

      const cart = {
        items: [{ productId: 'prod_789', quantity: 1, price: 9999 }],
      };

      // Act: Use Stripe's test token for declined card
      await expect(
        checkoutService.processCheckout({
          userId: user.id,
          cart,
          paymentMethod: {
            type: 'card',
            cardToken: 'tok_chargeDeclined', // Stripe test token for declined
          },
        })
      ).rejects.toThrow('Payment declined');

      // Assert: Verify order marked as failed
      const orderResult = await testEnv.getDbPool().query(
        'SELECT * FROM orders WHERE user_id = $1',
        [user.id]
      );
      expect(orderResult.rows).toHaveLength(1);
      expect(orderResult.rows[0].status).toBe('payment_failed');

      // Assert: No successful payment recorded
      const paymentResult = await testEnv.getDbPool().query(
        'SELECT * FROM payments WHERE status = $1',
        ['succeeded']
      );
      expect(paymentResult.rows).toHaveLength(0);
    });
  });
});

Enter fullscreen mode Exit fullscreen mode

このテストは以下を検証します:

  • 実PostgreSQLデータベース操作(注文作成、支払い記録)
  • 実Stripe支払い処理(テストモードを使用)
  • 実SendGridメール配信(サンドボックスモードを使用)
  • 失敗した支払いによる適切なエラー処理
  • テスト失敗時でも完全なクリーンアップ

グラフィック提案5 : テストコード→データベース→Stripe API→SendGrid API間のインタラクションを示すチェックアウトフローのシーケンス図で、アサーションポイントとクリーンアップステップの注釈があります。

一般的な落とし穴とその回避方法

実サービステストの長年の経験から、チームが陥る罠は次のとおりです:

落とし穴1: タイミングによる不安定なテスト

問題 : ローカルでは成功するテストが、CIでランダムに失敗します。

解決策 : 任意のタイムアウトを使用しないでください。明示的な待機を使用します:

// ❌ 悪い例: 任意のタイムアウト
await sleep(1000);
expect(order.status).toBe('completed');

// ✅ 良い例: 条件を待つ
await waitFor(
  async () => {
    const order = await getOrder(orderId);
    return order.status === 'completed';
  },
  { timeout: 5000, interval: 100 }
);

Enter fullscreen mode Exit fullscreen mode

落とし穴2: テストデータの汚染

問題 : テストが互いに干渉し、ランダムな失敗が発生します。

解決策 : 一意の識別子+テスト前のクリーンアップ(前述のとおり)。

落とし穴3: テストパフォーマンスの無視

問題 : 統合スイートが30分かかり、開発者が実行しなくなります。

解決策 : 並列化、依存関係のキャッシュ、時間予算の設定:

// jest.integration.config.js
module.exports = {
  testTimeout: 10000, // 10 seconds max per test
  maxWorkers: '50%', // Use half CPU cores for parallel execution
  setupFilesAfterEnv: ['<rootDir>/tests/testSetup.ts'],
};

Enter fullscreen mode Exit fullscreen mode

テストが10秒を超える場合、最適化が必要か、E2Eテストになる必要があります。

落とし穴4: エッジケースの過剰なテスト

問題 : 1000のテスト、90%が同じハッピーパスをテストします。

解決策 : エッジケースにテストマトリックスを使用します:

describe.each([
  { input: 'valid@email.com', expected: true },
  { input: 'invalid', expected: false },
  { input: 'no@domain', expected: false },
  { input: '', expected: false },
  { input: null, expected: false },
])('Email validation', ({ input, expected }) => {
  it(`should return ${expected} for "${input}"`, async () => {
    const result = await validateEmail(input);
    expect(result).toBe(expected);
  });
});

Enter fullscreen mode Exit fullscreen mode

結論: 信頼を獲得するテスト

実サービステストは完璧さについてではありません。 信頼 についてです。統合テストが成功した場合、本番環境へのデプロイに快適さを感じるべきです。失敗した場合、モックの不一致ではなく、実際のバグをキャッチしたと信頼する必要があります。

その信頼を構築するための体系的なチェックリストは次のとおりです:

  1. 環境セットアップ : 本番サービスをミラーリングするためにコンテナを使用する
  2. 認証情報管理 : セキュアなシークレット、欠落時の優雅な劣化
  3. クリーンアップ戦略 : テスト前にクリーンアップ、外部サービスにはtry-finallyを使用
  4. データの分離 : テストの干渉を防ぐための一意の識別子
  5. エラーシナリオ : 実サービスシミュレーションで障害、タイムアウト、レート制限をテスト
  6. カバレッジ目標 : 戦略的なテスト配分で90〜95%を目指す
  7. CI/CD統合 : キャッシングと並列化を備えたマルチステージパイプライン

実サービスを使った統合テストは、モックよりも多くのセットアップが必要です。遅くなります。複雑になります。しかし、正しく行われると、「動作すると思う」と「動作することを知っている」の違いになります。

さあ、実データベース、実API、そして実際の自信を持ってテストしに行きましょう。

統合テストアーキテクチャ

実サービス用の修正されたテストピラミッド

従来のテストピラミッドはベースにユニットテストを強調していますが、実サービス統合テストには異なるバランスが必要です:

Diagram 1

複雑な外部サービスの相互作用をテストする場合、統合テストはより大きなシェアを占めます。

実サービステスト環境フロー

本番グレードの統合テストは、このライフサイクルに従います:

Diagram 2

これにより、テストが分離され冪等であることが保証され、CI/CDパイプラインで確実に実行されます。


参考文献

: [1] Cohn, M. (2009). Succeeding with Agile: Software Development Using Scrum. The Testing Pyramid

: [2] Hauer, P. (2019). Focus on Integration Tests Instead of Mock-Based Tests. https://phauer.com/2019/focus-integration-tests-mock-based-tests/

: [3] Hauer, P. (2019). Integration testing tools and practices. Focus on Integration Tests Instead of Mock-Based Tests

: [4] Stack Overflow Community. (2018). Is it considered a good practice to mock in integration tests? https://stackoverflow.com/questions/52107522/

: [5] Server Fault Community. Credentials management within CI/CD environment. https://serverfault.com/questions/924431/

: [6] Rojek, M. (2021). Idempotence in Software Testing. https://medium.com/@rojek.mac/idempotence-in-software-testing-b8fd946320c5

: [7] Software Engineering Stack Exchange. Cleanup & Arrange practices during integration testing to avoid dirty databases. https://softwareengineering.stackexchange.com/questions/308666/

: [8] Stack Overflow Community. What strategy to use with xUnit for integration tests when knowing they run in parallel? https://stackoverflow.com/questions/55297811/

: [9] LinearB. Test Coverage Demystified: A Complete Introductory Guide. https://linearb.io/blog/test-coverage-demystified

: [10] Web.dev. Pyramid or Crab? Find a testing strategy that fits. https://web.dev/articles/ta-strategies


Originally published at kanaeru.ai

Top comments (0)