開発者全員が書けるE2Eテスト ─ 普通のgo testで実現するテスト基盤

はじめに

こんにちは、Cross Border (XB) EngineeringでSRE & Enablingを担当している@ryotaraiです。

本記事は連載企画:メルカリ初の世界共通アプリ「メルカリ グローバルアプリ」の開発舞台裏の一環として、このプロジェクトのバックエンドAPIのE2E(End-to-End)テストについて深掘りします。特に、開発者全員がメンテナンスできるE2Eテスト基盤をどのように実現したのか、その設計思想と実装について紹介します。

なぜE2Eテストの改善が必要だったのか

従来のE2Eテストが抱えていた課題

バックエンドAPIのE2Eテストは、システム全体が正しく動作することを確認する重要な役割を担っています。しかし、多くのプロジェクトで以下のような課題に直面します:

  1. セットアップの複雑さ: テスト環境の準備に時間がかかり、開発者が気軽に実行できない
  2. 並列実行の難しさ: テスト間でリソースの競合が発生し、実行時間が長くなる
  3. 属人化: QAチームが主にメンテナンスし、開発者が触りにくい
  4. 学習コストの高さ: 専用のフレームワークやDSLを学ぶ必要がある

このプロジェクトでも当初、これらの課題に直面していました。特に、QAチームのみがE2Eテストのメンテナンスを担当する状況では、以下の問題が発生します:

  • APIの変更時に、E2Eテストの更新が後回しになる
  • テストの追加に時間がかかり、カバレッジが低下する
  • 開発者がテストの実装を理解せず、デバッグが困難になる

目指した姿:開発者全員が参加できるE2Eテスト

私たちが目指したのは、QAチームだけでなく、APIのコードを書いている開発者全員がE2Eテストのメンテナンスを行える体制です。

これを実現するためには、以下の要件を満たす必要がありました:

  • 開発者が日常的に使っている技術でテストを書ける
  • 学習コストが低く、すぐに書き始められる
  • IDEの補完やリファクタリング機能が使える
  • ローカルでもCIでも同じように実行できる

フレームワークの設計思想

「普通のgo testで書ける」という哲学

メルカリ グローバルアプリのバックエンドAPIは、Goで実装されています。そのため、最終的に私たちは普通のGoコード(go test)でE2Eテストを書く方式を選択しました。その理由は:

  1. 学習コストがゼロ: 開発者は既にgo testの書き方を知っている
  2. 型安全性: Connectのクライアントコードをそのまま使え、コンパイル時に型をチェックできる
  3. IDEの恩恵: 補完、リファクタリング、定義ジャンプなどが使える
  4. デバッグのしやすさ: 通常のGoプログラムとしてデバッグできる
  5. 既存コードの活用: テストヘルパー関数やモックなどを再利用できる

この決定により、E2Eテストを特別なものではなく、日常の開発フローの一部にすることができました。

私たちが実装したE2Eテストフレームワークの核心は、「普通のgo testで書ける」という設計思想です。

実際のテストコード例を見てみましょう:

func TestUpdateNickname(t *testing.T) {
    t.Parallel()

    tests := []struct {
        name     string
        userID   int64
        nickname string
        wantCode connect.Code
    }{
        {
            name:     "Success",
            userID:   createTestUser(t).ID,
            nickname: "NewNickname",
            wantCode: connect.CodeOK,
        },
        {
            name:     "Blank nickname returns error",
            userID:   readonlyUser().ID,
            nickname: "",
            wantCode: connect.CodeInvalidArgument,
        },
        {
            name:     "Non-logged in user returns error",
            userID:   0,
            nickname: "TestNickname",
            wantCode: connect.CodeUnauthenticated,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel()

            testenv.Run(t, func(params env.RunParams) {
                client := accountv1connect.NewBFFAccountServiceClient(
                    http.DefaultClient,
                    params.Server.URL,
                )

                req := connect.NewRequest(&accountv1.UpdateNicknameRequest{
                    Nickname: tt.nickname,
                })

                if tt.userID != 0 {
                    // 認証ヘッダーを設定
                    setAuthHeader(t.Context(), req.Header(), tt.userID)
                }

                _, err := client.UpdateNickname(t.Context(), req)
                if connect.CodeOf(err) != tt.wantCode {
                    t.Errorf("error code = %v, want %v",
                        connect.CodeOf(err), tt.wantCode)
                }
            })
        })
    }
}

このコードは、go testのテーブル駆動テストという標準的なパターンで書かれています:

  • t.Parallel()で並列実行を指定(通常のgo testと同じ)
  • テストケースを構造体のスライスで定義
  • t.Run()でサブテストを実行し、各サブテストも並列実行
  • testenv.Run()の中でテストサーバのURLを取得
  • Connectの自動生成されたクライアントをそのまま使用
  • 通常のgo testと同じアサーション

E2Eテスト特有の複雑な記述はほとんどなく、普段のユニットテストと同じように書けることが分かります。

さらに、普通のGoコードで書けるということは、Claude CodeなどのAIコーディングツールも効果的に活用できるということです。テストケースの追加やエッジケースの洗い出しなど、AIの支援を受けながら効率的にテストを書くことができます。
QAチームなど、バックエンドエンジニア以外でGo言語に慣れていないメンバーもいますが、AIを活用することでテストコードを作成できるようになっています。

実際に、既存のJestで実装されていたE2Eテストをこのフレームワークに移行する際にも、AIを大いに活用しました。既存のテストコードを参照しながら、AIがGoのテストコードを生成し、開発者がレビュー・調整することで、移行作業を効率的に進めることができました。

全体アーキテクチャ

E2Eテストの実行方法として、全員がアクセスできる共有のdevelopment環境にデプロイしたアプリケーションに対してテストを実行するという選択肢もあります。しかし、この方法では開発中のバックエンドの変更に対してすぐにテストを実行できないという問題があります。

私たちは、アプリケーションコードを変更しながらE2Eテストを通したり、追加・修正できたりする環境を重視しました。そのため、テストごとにサーバを動的に起動する設計を採用しています。これにより、開発者は自分の変更をすぐにE2Eテストで検証でき、テスト駆動での開発が可能になります。

フレームワークの主な責務は:

  1. テストサーバの自動起動と管理: 必要に応じてサーバを起動し、プールで管理
  2. データベースの自動準備: AlloyDB Omniを起動し、論理データベースを作成してマイグレーションを実行
  3. 並列実行のサポート: 複数のテストが同時に実行できるようリソースを管理
  4. クリーンアップの自動化: テスト終了時に自動的にデータをクリーンアップし、リソースをプールに返却

開発者から見ると、これらの複雑な処理は完全に隠蔽されており、testenv.Run()を呼ぶだけでテスト環境が整う仕組みになっています。

内部実装の工夫

ここからは、フレームワークがどのように並列実行とリソース管理を実現しているか、内部実装を見ていきます。

リソースプールによる並列実行

E2Eテストの並列実行を実現するために、サーバをプール管理しています。

重要なのは、testenv.Run()に渡した関数が終了すると、自動的にサーバがプールに返却される仕組みです。開発者は明示的にリソースを返却する必要がなく、通常のテストと同じように書くだけで、フレームワークが自動的にクリーンアップとプールへの返却を行います。

この仕組みにより:

  • 並列実行時にリソースの競合が発生しない
  • サーバの起動コストを最小化(プールから再利用)
  • テスト間のデータ汚染を防ぐ(TRUNCATEで初期化)
  • リソース管理が透明(開発者は意識する必要がない)

データベースの管理

データベースについては、AlloyDB Omniのコンテナは1つだけ起動します。その中で、テストごとに論理データベースを自動的に作成し、マイグレーションを実行します。

この設計により:

  • データベースコンテナの起動コストを削減(1つだけ起動すればよい)
  • 並列実行でもデータが分離される(論理DBごとに独立)
  • マイグレーションの実行も自動化(開発者は意識不要)

論理データベースもプールで管理されており、テスト終了後はTRUNCATEでデータをクリーンアップしてから再利用されます。

コードカバレッジの収集

このフレームワークは、Go 1.20以降で導入されたgo build -coverをサポートしています。

通常のテストカバレッジ(go test -cover)はテストコード内での実行しか計測できませんが、E2Eテストではサーバプロセスとして実行されているコードのカバレッジを計測する必要があります。これを実現するのがgo build -cover機能です。

フレームワークの実装では:

  1. サーバごとに独立したカバレッジディレクトリを自動作成
    • 各サーバ起動時に一時ディレクトリを作成
    • GOCOVERDIR環境変数を自動設定
  2. 並列実行でもカバレッジが正確に収集される
    • 各サーバが独立したディレクトリに書き込むため、競合しない
  3. テスト終了時に自動マージ
    • すべてのサーバのカバレッジデータをgo tool covdata mergeで統合
    • 最終的に1つの統合されたカバレッジデータが生成される

開発者は特定の環境変数を設定するだけで、複数サーバのカバレッジを自動的に収集・マージできます:

# カバレッジ付きでサーババイナリをビルド
go build -cover -o server ./server

# カバレッジを収集しながらテスト実行
GLOBAL_GOCOVERDIR=/tmp/coverage go test ./e2etest/...

# カバレッジレポート生成
go tool covdata percent -i /tmp/coverage

この仕組みにより、E2Eテストでのコードカバレッジを正確に計測し、APIの品質を定量的に評価できるようになりました。

Kubernetes上での実行

E2Eテストは、開発環境ではローカルで実行し、CIではKubernetes上で実行します。ここでは、Kubernetes上での実行方法について、いくつかの興味深い工夫を紹介します。

go test -c を使った高速なデプロイ

通常、Kubernetes上でテストを実行する場合、以下の手順を踏むかと思います:

  1. コンテナイメージをビルド
  2. イメージをレジストリにプッシュ
  3. Kubernetes Podでイメージをプル
  4. コンテナを起動

しかし、この方法には各ステップに時間がかかるという問題があります。E2Eテストでは実行速度が重要なので、私たちは異なるアプローチを取りました:

# テストバイナリをビルド
go test -c \
    -o package/e2etest \
    ./path/to/e2etest

# サーババイナリをビルド
go build \
    -o package/server \
    ./path/to/server

# tarでアーカイブしてkubectl execで転送
tar -czf - -C ./package . | \
    kubectl exec -c main -i -n ${POD_NAMESPACE} ${POD_NAME} -- \
    tar xzf - -C /tmp/e2e

# Pod内で直接実行
kubectl exec -c main -it -n ${POD_NAMESPACE} ${POD_NAME} -- \
    /path/to/entrypoint.sh

go test -cを使うことで、テストコードを実行可能なバイナリにコンパイルできます。これにより:

  • コンテナイメージのビルドが不要
  • レジストリへのプッシュ・プルが不要
  • kubectl execで直接ファイルを転送

この方法により、テスト実行までの時間を大幅に短縮できました。具体的には1分半程度でビルドからテストの開始ができています。

なお、Kubernetes上で実行する理由は、並列実行に必要な十分なリソースを確保するためです。並列度を上げるとレースコンディションを防ぐためにその数だけサーバが必要となり、必要なリソースが線形に増えるため、Kubernetesクラスタを使用しています。

まとめ

本記事では、メルカリ グローバルアプリのバックエンドAPIのE2Eテストについて紹介しました。
このアプローチにより、E2Eテストは特別なものではなく、日常の開発フローの一部となります。開発者はAPIを変更した際に、躊躇なくE2Eテストを追加・修正できるようになっています。

もちろん、まだ改善の余地はあります。例えば:

  • テストの実行時間のさらなる短縮
    • AIを利用して、変更箇所に関係するテストのみを実行する取り組みを進めています
  • テストデータのセットアップの簡略化
  • テスト結果のレポーティング

しかし、開発者体験を最優先にした設計により、持続可能なE2Eテスト基盤を構築できたと考えています。

同じような課題を抱えているプロジェクトの参考になれば幸いです。

  • X
  • Facebook
  • linkedin
  • このエントリーをはてなブックマークに追加