メインコンテンツにスキップ

サーバーレスアプリを盤石に!僕らが実践する5層テストピラミッド戦略

タグ: 🏷 AWS ,CI/CD ,Golang ,Playwright

背景

サーバーレス開発を始めたばかりの頃は、「とりあえず動けばいいや」なんて思っていました。でも、実際にユーザーが使うサービスを運用し始めると、色々な問題に直面するんですよね。

  • 個別の関数は動くのに、全体で見ると不具合が起きる
  • ローカルでは動くのに、AWSの本番環境だとダメ
  • フロントエンドと繋げると予期しないエラー
  • テストが甘い部分で本番障害

従来の3層テストピラミッドだけではカバーしきれない部分があったので、思い切って5層に拡張した構成を試してみることにしました。

テストピラミッドとは?

基本概念

テストピラミッドとは、ピラミッドの下に行くほど実行が速くて軽いテストをたくさん行い、上に行くほど重いけれどより本格的なテストを少なくしていく、という考え方です。

車の品質管理で例えると:

  • 下層: 部品一つ一つの検査(速い・安い・大量)
  • 中層: エンジン単体の動作テスト
  • 上層: 完成車のテストドライブ(遅い・高い・少数)
        🔺 E2Eテスト(遅い、高コスト、現実的)
       🔺🔺 統合テスト(中程度)  
      🔺🔺🔺 単体テスト(速い、低コスト、分離)

なぜ5層に拡張したのか?

サーバーレスには従来の3層ではカバーしきれない特有の課題があったため、5層に拡張しました:

                🔺 5. E2Eテスト(実際のユーザー操作)
               🔺🔺 4. AWS統合テスト(本番環境での動作確認)
              🔺🔺🔺 3. ローカル統合テスト(SAM Localでの検証)
             🔺🔺🔺🔺 2. コンプライアンステスト(全Lambda共通品質チェック)
            🔺🔺🔺🔺🔺 1. 単体テスト(関数レベルの検証)

各層で別々の問題を捕まえられるので、サーバーレス特有の課題(クラウド環境差、品質ばらつき)を効率よく見つけられる。

第1層: 単体テスト(Unit Tests)

目的:部品単体の機能を検証

一番下の層。関数とかメソッドを、他の部品と切り離した状態で動かして確認する。

単体テストではテスト対象以外を「モック」(偽物)に置き換える。エンジンをテストしたい時に本物のバッテリーじゃなくてテスト用電源を使うみたいな感じ。そうすると失敗した時にエンジンの問題だとはっきりわかる。

実装例:バリデーション処理のテスト

// pkg/utils/validation_test.go
func TestValidateRegistrationInput(t *testing.T) {
    // テストしたいケースを複数用意する
    tests := []struct {
        name    string
        input   RegistrationInput
        wantErr bool // エラーを期待するかどうか
        errMsg  string // 期待するエラーメッセージ
    }{
        {
            name: "有効な入力データ",
            input: RegistrationInput{ Email: "user@example.com", Password: "ValidPass123@" },
            wantErr: false, // エラーは発生しないはず
        },
        {
            name: "無効なメールアドレス",
            input: RegistrationInput{ Email: "invalid-email", Password: "ValidPass123@" },
            wantErr: true, // エラーが発生するはず
            errMsg:  "メールアドレスの形式が不正です",
        },
        // ... 他のテストケース
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            // 実際にバリデーション関数を実行
            err := ValidateRegistrationInput(tt.input)
            
            if tt.wantErr {
                // エラーが発生することを期待するテスト
                assert.Error(t, err) // エラーがあったか?
                assert.Contains(t, err.Error(), tt.errMsg) // エラーメッセージは期待通りか?
            } else {
                // エラーが発生しないことを期待するテスト
                assert.NoError(t, err) // エラーがなかったか?
            }
        })
    }
}

第2層: コンプライアンステスト

目的:全部品がルールを守っているか検査

全てのLambda関数が同じ品質基準を満たしているかの自動チェック。車で言うと「全部品が安全基準クリアしてるか」の検査。

第3層: ローカル統合テスト(SAM Local)

目的:部品を組み合わせた状態でテスト

Lambda関数を自分のPC上の擬似AWS環境(SAM Local)で動かす。車のエンジンを車体に載せる前にテスト台で動かすような感じ。

SAM LocalはAWSが出してるツールで、PCでLambdaとかAPI Gatewayの環境を再現できる。実際にAWSに上げなくてもローカルで動作確認できるので便利。

自動化されたローカルテストスクリプト

#!/bin/bash
# run_local_integration_test.sh

# ... (中略) ...

# 正常ケーステスト
echo "Testing valid registration..."
# curlコマンドで、ローカルサーバーにHTTPリクエストを送信
curl -X POST http://127.0.0.1:3000/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"test@example.com","password":"TestPass123@","name":"テストユーザー"}'

# ... (他のテストケース)

第4層: AWS統合テスト

目的:本番に近い環境での最終チェック

ローカルで動いても、AWS本番だと権限設定とかネットワーク構成で動かないことがある。実際にAWSにデプロイしたやつに対してテストして、クラウド環境特有の問題を見つける段階。

車で言うとエンジンを車体に載せて、テストコースで実際に走らせてみる感じ。

Go言語による統合テスト

// integration_test.go

func TestFullUserLifecycle_Integration(t *testing.T) {
    // ... (テストユーザー情報の設定)
    
    // 環境変数から、デプロイされたAPIのエンドポイントURLを取得
    apiEndpoint := os.Getenv("API_GATEWAY_ENDPOINT")
    
    t.Run("ユーザー登録", func(t *testing.T) {
        // 実際のAPIエンドポイントに対してHTTPリクエストを送信
        response, err := makeHTTPRequest("POST", registerURL, payload)
        require.NoError(t, err)
        assert.Equal(t, 200, response.StatusCode)
    })
    
    t.Run("管理者によるユーザー確認", func(t *testing.T) {
        // AWSのコマンドラインツール(CLI)を実行して、テストユーザーを有効化
        cmd := exec.Command("aws", "cognito-idp", "admin-confirm-sign-up", ...)
        err := cmd.Run()
        require.NoError(t, err, "Failed to confirm user")
    })
    
    // ... (ログイン、情報取得、削除のテストが続く)
}

第5層: E2Eテスト(Playwright)

目的:ユーザー目線での最終動作確認

E2E(End-to-End)テストはピラミッドの頂点。実際のユーザー操作をブラウザで再現して、フロントエンドからバックエンドまで全体の流れをテストする。

Playwrightは、プログラムコードでブラウザを自動操作できるツール。「ページ開く」「フォーム入力」「ボタンクリック」とかの操作を自動化できる。

完全なユーザーフローテスト

// tests/auth-flow.spec.ts
import { test, expect } from '@playwright/test';

test('完全なユーザーライフサイクル', async ({ page }) => {
    // 1. ユーザー登録
    await test.step('ユーザー登録', async () => {
      // 指定したURLのページを開く
      await page.goto('/register');
      
      // フォームの各項目に文字を入力する
      await page.fill('input[name="email"]', testEmail);
      await page.fill('input[name="password"]', testPassword);
      
      // 送信ボタンをクリックする
      await page.click('button[type="submit"]');
      
      // 特定の要素に期待したテキストが表示されるか確認
      await expect(page.locator('.success-message')).toContainText('ユーザー登録が完了');
    });

    // ... (ログイン、情報表示、削除のステップが続く)
});

各層の連携と全体最適化

CI/CDパイプラインでの統合

この5層のテストをCI/CDパイプラインに組み込むと、コード変更のたびに自動でテストが回って品質が保たれる。

各層の責任分離

各層で何をテストするかを明確に分けるのが重要。入力値の細かいチェック(バリデーション)は、高速な単体テストで何百パターンもやる。これをE2Eテストでやると遅すぎて非効率。

まとめ

5層テストピラミッドを使うようになって、こんな感じで良くなった。

  • 色々な種類のバグを捕まえられる: 各層で違う種類の問題を発見できるので、システムの信頼性が上がる
  • 効率的にバグを見つけられる: 下の層(速いテスト)で問題を見つけて修正コストを抑える
  • 安心してリリースできる: テストが自動で回るので、新機能をリリースするときの不安が減る

テストは「面倒くさいもの」じゃなくて「品質を保証して開発を加速するための投資」だと思う。最初から完璧な5層を作る必要はなくて、まずは単体テストから始めて、プロジェクトが成長するにつれて徐々に層を厚くしていけばいい。

一度この仕組みを構築してしまえば、チーム全体の生産性向上に長期的に貢献してくれるはずです。


実際に使用している各層の実行コマンド:

# Layer 1: 単体テスト
go test ./... -v

# Layer 2: コンプライアンステスト  
go test -v ./pkg/lambda -run TestLambdaFunctionCompliance

# Layer 3: ローカル統合テスト
./run_local_integration_test.sh

# Layer 4: AWS統合テスト
go test -tags integration -v ./...

# Layer 5: E2Eテスト
npm test