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)