🧪

Go言語によるLaravel風統合テストフレームワークの構築 - Testcontainersとtestify/suiteの活用

に公開

Go言語によるLaravel風統合テストフレームワークの構築 - Testcontainersとtestify/suiteの活用

はじめに

こんにちは、クロステックマネジメントDevops Dept所属の水本です。

DevOps Dept.はクロステック・マネジメントが提供するプロダクトのエンジニアリングを担当し、スクラム開発を軸に継続的な改善と価値提供を行なっています。
本記事では、Go言語でLaravelのFeatureテスト(APIエンドポイント全体を対象とし、データベースの状態やレスポンスまで検証するテスト)に類似した仕組みを実現する、統合テストフレームワークの実装例を紹介します。

Testcontainers-go、testify/suite、go-faker、gomock を組み合わせることで、本番環境に限りなく近い条件でAPIを包括的にテストできるフレームワークを構築しました。

使用技術

  • Testcontainers-go: Dockerコンテナを使用したテスト用データベースの管理
  • testify/suite: テストスイートの構造化(Setup/TearDown管理)
  • go-faker: テストデータの自動生成
  • gomock: 依存関係(リポジトリや外部APIクライアント)のモック生成
  • Gin: (例として) Webフレームワーク
  • PostgreSQL: (例として) データベース

アーキテクチャ

ディレクトリ構造

テストコードは src/test/ のような専用ディレクトリに集約し、アプリケーションコードと明確に分離しています。

src/test/
├── README.md                           # フレームワークのドキュメント
├── foundation/                         # テスト基盤(DBコンテナ管理)
│   ├── test_foundation.go
│   └── ...
├── suite/                             # 共通テストスイート
│   └── base_integration_test_suite.go # 全API共通の基底スイート
├── factory/                           # テストデータファクトリー
│   ├── factory.go                     # ファクトリー管理
│   ├── user_factory.go               # ユーザーファクトリー
│   └── ...
├── container/                        # コンテナ設定
│   └── postgres.go                   # PostgreSQLコンテナ設定
└── integration_tests/                # 実際の統合テスト
    └── v1/
        ├── user/
        │   ├── suite.go              # User API共通スイート
        │   ├── update_test.go        # Update APIテスト
        │   └── ...
        └── project/
            ├── suite.go
            └── ...

レイヤー構成

テストフレームワークは、責務ごとに4つの層で構成されています。

┌─────────────────────────────────────┐
│     Integration Tests               │ ← 個別APIテスト (update_test.go)
│     (正常系/エラー系/モック)          │
├─────────────────────────────────────┤
│     {Resource}APITestSuite          │ ← APIリソース別 共通スイート (user/suite.go)
│   (API固有のルーティング設定など)     │
├─────────────────────────────────────┤
│    BaseIntegrationTestSuite         │ ← 全体共通 基底スイート (suite/base_integration_test_suite.go)
│  (認証・HTTP・DB・Mock管理)           │
├─────────────────────────────────────┤
│       TestFoundation                │ ← テスト基盤 (foundation/test_foundation.go)
│ (DB接続・スキーマ作成・クリーンアップ)  │
├─────────────────────────────────────┤
│     Container Management            │ ← コンテナ管理 (Testcontainers)
│    (PostgreSQL Container)           │
└─────────────────────────────────────┘

この階層構造により、個別のAPIテスト(最上位層)は、データベースの起動やHTTPリクエストの組み立てといった下位層の詳細を意識することなく、テストロジックの実装に集中できます。

コアコンポーネント詳解

1. TestFoundation - テスト基盤の中核

TestFoundation は、テストデータベースのライフサイクル(起動、接続、後片付け)全体を管理する中核コンポーネントです。

//go:build integration

package foundation

// TestFoundationは、テスト全体の基盤(DBコンテナ)を管理します
type TestFoundation struct {
    Container          *container.PostgresContainer // Testcontainersのコンテナインスタンス
    DB                 *sqlx.DB                     // データベース接続
    Context            context.Context
    MasterDataCache    *MasterDataCache    // マスターデータ管理
    PerformanceMonitor *PerformanceMonitor // パフォーマンス計測
}

// SetupDatabase はDBコンテナを起動し、接続を確立します
func (tf *TestFoundation) SetupDatabase() error {
    // 1. PostgreSQLコンテナを起動
    pgContainer, err := container.StartPostgres(tf.Context)
    if err != nil {
        return fmt.Errorf("Postgresコンテナの起動に失敗: %w", err)
    }
    tf.Container = pgContainer

    // 2. データベース接続を確立(コンテナ起動直後は失敗することがあるためリトライ)
    maxRetries := 30
    retryInterval := 1 * time.Second

    for i := 0; i < maxRetries; i++ {
        // DSN (e.g., "postgres://user:pass@host:port/db") を使って接続試行
        db, err := sqlx.Connect("postgres", pgContainer.DSN)
        if err == nil {
            if err := db.Ping(); err == nil {
                tf.DB = db
                return nil // 接続成功
            }
            db.Close()
        }
        time.Sleep(retryInterval)
    }
    return fmt.Errorf("DB接続リトライに失敗: %w", err)
}

// CreateSchema はDBスキーマ(テーブル定義)を適用します
func (tf *TestFoundation) CreateSchema() error {
    // schema.sql ファイルを読み込んで実行
    schemaSQL, err := tf.readDatabaseFile("schema.sql")
    if err != nil {
        return fmt.Errorf("schema.sqlの読み込みに失敗: %w", err)
    }

    if _, err := tf.DB.Exec(string(schemaSQL)); err != nil {
        return fmt.Errorf("スキーマの実行に失敗: %w", err)
    }
    return nil
}

// TruncateAllTables はマスターデータを除く全テーブルをクリーンアップします
func (tf *TestFoundation) TruncateAllTables() error {
    // TRUNCATE TABLE ... RESTART IDENTITY; を実行
    // (実コードでは .sql ファイルの読み込みなどで実装)
    // マスターデータ(例: roles, permissions)は除外するロジック
    // これにより、各テストケースの独立性を保証します
    return nil
}

主な責務:

  • PostgreSQLコンテナの起動・停止
  • データベース接続の確立(リトライロジックを含む)
  • schema.sql の適用
  • マスターデータの挿入(SetupSuite時)とキャッシュ
  • テストケース間のテーブルクリーンアップ(SetupTest時)

2. BaseIntegrationTestSuite - 共通テストスイート

testify/suite を活用した基底スイートです。全ての統合テストは、これを(直接または間接的に)継承します。

package suite

type BaseIntegrationTestSuite struct {
    suite.Suite

    // テストインフラ
    Foundation *foundation.TestFoundation // データベース基盤
    App        *gin.Engine                // テスト対象のHTTPサーバー
    MockCtrl   *gomock.Controller         // モックコントローラー
    Factory    *factory.Factory           // テストデータ生成用

    // テストで多用する共通データ
    TestUser    *model.User // 認証に用いるデフォルトユーザー
    TestProject *model.Project
    AuthToken   string      // TestUserの認証トークン

    // ...
}

ライフサイクルメソッド

testify/suite のライフサイクルメソッドを活用することで、テストのセットアップと後処理を効率化します。

// スイート全体で1回だけ(最初に)実行されます
func (s *BaseIntegrationTestSuite) SetupSuite() {
    // 1. テスト基盤(TestFoundation)を初期化
    s.Foundation = foundation.NewTestFoundation()

    // 2. データベースコンテナ起動(最も高コストな処理)
    err := s.Foundation.SetupDatabase()
    s.Require().NoError(err) // 失敗した場合はテストを即時中断

    // 3. スキーマ作成
    err = s.Foundation.CreateSchema()
    s.Require().NoError(err)

    // 4. マスターデータ挿入
    err = s.Foundation.InsertMasterData()
    s.Require().NoError(err)

    // 5. Ginアプリケーション初期化(DIやルーティング設定)
    s.setupGinApplication()

    // 6. ファクトリー初期化
    s.Factory = factory.NewFactory(s.GetDB())
}

// スイート全体で1回だけ(最後に)実行されます
func (s *BaseIntegrationTestSuite) TearDownSuite() {
    if s.Foundation != nil {
        s.Foundation.TeardownDatabase() // コンテナを停止
    }
}

// 各テストケースの「実行前」に必ず実行されます
func (s *BaseIntegrationTestSuite) SetupTest() {
    // 1. すべてのテーブルをトランケート(マスターデータ除く)
    err := s.Foundation.TruncateAllTables()
    s.Require().NoError(err)

    // 2. マスターデータを(キャッシュから)再挿入
    err = s.Foundation.InsertMasterData()
    s.Require().NoError(err)

    // 3. テスト用基本データ作成(認証用ユーザーなど)
    s.createBaseTestData()

    // 4. モックコントローラ作成
    s.MockCtrl = gomock.NewController(s.T())
}

// 各テストケースの「実行後」に必ず実行されます
func (s *BaseIntegrationTestSuite) TearDownTest() {
    if s.MockCtrl != nil {
        s.MockCtrl.Finish() // モックの期待値が満たされたかチェック
    }
}

重要な設計のポイント:

  • コンテナの再利用: 重量級の処理であるコンテナ起動(SetupDatabase)を SetupSuite で一度だけ実行し、スイート全体で再利用します。
  • データの完全分離: SetupTest ごとに TruncateAllTables を実行し、各テストケースの独立性を保証します。
  • パフォーマンス: コンテナ起動を1回に抑えることで、テストスイート全体の実行時間を大幅に短縮します。

3. HTTP関連ヘルパーメソッド

APIテストを容易にするためのヘルパーメソッド群です。

// 認証付きリクエスト(推奨)
func (s *BaseIntegrationTestSuite) MakeAuthenticatedRequest(
    method string,
    url string,
    body interface{}, // リクエストボディ (JSONにシリアライズされます)
    user *model.User,   // 認証させたいユーザー
) *httptest.ResponseRecorder {

    token := s.GenerateAuthToken(user.UserID) // JWTトークンなどを生成
    headers := map[string]string{
        "Authorization": "Bearer " + token,
        "Content-Type":  "application/json",
    }
    return s.MakeRequest(method, url, body, headers)
}

// 基本リクエスト
func (s *BaseIntegrationTestSuite) MakeRequest(
    method string,
    url string,
    body interface{},
    headers map[string]string,
) *httptest.ResponseRecorder {
    // bodyをJSONに変換
    var bodyReader io.Reader
    if body != nil {
        jsonBody, _ := json.Marshal(body)
        bodyReader = bytes.NewBuffer(jsonBody)
    }

    // HTTPリクエスト作成
    req := httptest.NewRequest(method, url, bodyReader)
    for key, value := range headers {
        req.Header.Set(key, value)
    }

    // レスポンスを記録
    w := httptest.NewRecorder()
    // Ginルーターにリクエストを投げる
    s.App.ServeHTTP(w, req)
    return w
}

// 成功レスポンスのアサーション
func (s *BaseIntegrationTestSuite) AssertSuccessResponse(
    w *httptest.ResponseRecorder,
    expectedStatus int,
) {
    s.Equal(expectedStatus, w.Code,
        "期待したステータス: %d, 実際: %d. ボディ: %s",
        expectedStatus, w.Code, w.Body.String())
}

// エラーレスポンスのアサーション
func (s *BaseIntegrationTestSuite) AssertErrorResponse(
    w *httptest.ResponseRecorder,
    expectedStatus int,
) {
    s.Equal(expectedStatus, w.Code)

    // 独自のエラーレスポンス構造体(あれば)をパースしてチェック
    var errorResponse common.ErrorResponse // 例: 共通のエラー型
    err := json.Unmarshal(w.Body.Bytes(), &errorResponse)
    s.Require().NoError(err)
    s.NotEqual(common.ResultOK, errorResponse.Result) // 成功コードでないことを確認
}

4. Factory Pattern - テストデータ生成

go-faker を活用し、現実的なテストデータを容易に生成するためのファクトリーパターンです。

package factory

// Factoryは、すべてのファクトリーを管理します
type Factory struct {
    User    *UserFactory
    Project *ProjectFactory
    Meeting *MeetingFactory
    // ... 他のファクトリー
}

func NewFactory(db *sqlx.DB) *Factory {
    return &Factory{
        User:    NewUserFactory(db),
        Project: NewProjectFactory(db),
        Meeting: NewMeetingFactory(db),
        // ...
    }
}

// --- UserFactoryの例 ---

type UserFactory struct {
    db *sqlx.DB
}

// CreateOptions は、特定のフィールド値を指定したい場合に使用します
type UserCreateOptions struct {
    FullName *string
    Email    *string
    // ...
}

// Create は、fakerでランダムなデータを生成してDBに保存します
func (f *UserFactory) Create(opts ...UserCreateOptions) (*model.User, error) {
    user := model.User{}

    // fakerでデフォルト値を設定(ランダムな名前やEmail)
    faker.FakeData(&user)

    // オプションが指定されていれば上書き
    if len(opts) > 0 && opts[0].FullName != nil {
        user.FullName = *opts[0].FullName
    }
    // ...

    // DBに保存
    // "INSERT INTO users (...) VALUES (...) RETURNING *"
    err := f.db.QueryRowxContext(ctx, query, ...).StructScan(&user)

    return &user, err
}

// CreateWithAuth のようなヘルパーメソッドも定義可能
func (f *UserFactory) CreateWithAuth(opts ...UserCreateOptions) (*model.User, string, error) {
    user, err := f.Create(opts...)
    if err != nil {
        return nil, "", err
    }
    // トークン生成ロジック(実際にはSuite層で行うことが多い)
    token := generateTokenForUser(user.UserID)
    return user, token, nil
}

ファクトリーの使用例:
テストコード側では、以下のように簡潔に記述できます。

// ランダムなユーザーを作成
user1, err := s.Factory.User.Create()

// 特定のEmailを持つユーザーを作成
customEmail := "custom@example.com"
user2, err := s.Factory.User.Create(factory.UserCreateOptions{
    Email: &customEmail,
})

// ユーザーに紐づくプロジェクトを作成
project, err := s.Factory.Project.Create(user1.UserID)

ファクトリーの利点:

  • 一貫性: 必須カラム漏れなどがなく、一貫したデータ構造を保証します。
  • 再利用性: 複数のテストケースでロジックを使い回せます。
  • 保守性: DBスキーマ変更時、ファクトリーの修正のみで対応可能です。
  • 可読性: テストコードが「何を生成したいか」という意図の記述に集中できます。

実践例: ユーザー更新APIの統合テスト

ステップ1: API共通スイートの作成

まず、User API(/user/ 以下のエンドポイント群)で共通する設定をまとめた UserAPITestSuite を作成します。

//go:build integration

package user_tests

import (
    ".../test/suite" // BaseIntegrationTestSuite
    ".../handlers"   // APIハンドラ
    ".../repository" // DBリポジトリ
    ".../service"    // ビジネスロジック
)

// UserAPITestSuite はUser API全体の共通基底スイートです
type UserAPITestSuite struct {
    suite.BaseIntegrationTestSuite // 基底スイートを継承

    // User APIで共通して使用するサービスやリポジトリ
    UserService service.UserServiceInterface
    UserRepo    repository.UserRepositoryInterface
}

// SetupTest は各テストケース前に実行されます
// (実コードでは SetupSuite で親を呼び出しつつ setupUserAPI を呼ぶパターンも有)
func (s *UserAPITestSuite) SetupTest() {
    // 最初に基底スイートのSetupTest(DBクリーンアップなど)を実行
    s.BaseIntegrationTestSuite.SetupTest()
    // その後、User API固有のセットアップを実行
    s.setupUserAPI()
}

// setupUserAPI はUser API共通の設定を行います
func (s *UserAPITestSuite) setupUserAPI() {
    // 1. リポジトリとサービスを初期化(DI)
    s.UserRepo = repository.NewUserRepository(s.GetDB())
    s.UserService = service.NewUserService(s.UserRepo, s.GetDB())

    // 2. 認証ミドルウェアを設定(TestUserで認証)
    s.SetupAuthMiddleware(s.TestUser) // (BaseIntegrationTestSuite層で定義されている想定)

    // 3. 実際のAPIルートをテストサーバーに登録
    s.setupUserRoutes()
}

// setupUserRoutes はテスト用のUser APIルートを設定します
func (s *UserAPITestSuite) setupUserRoutes() {
    userHandlers := handlers.NewUserHandlers(s.UserService)

    v1Group := s.App.Group("/api/v1") // アプリケーションのルート
    user := v1Group.Group("/user")
    {
        user.PUT("/:userId", userHandlers.UpdateHandler)
        user.GET("/me", userHandlers.GetMyInfoHandler)
        // ... 他のエンドポイント
    }
}

ステップ2: 個別テストスイートの定義

次に、PUT /user/:userId のテスト専用スイート UserUpdateAPITestSuite を定義します。

//go:build integration

package user_tests

import (
    "testing"
    "github.com/stretchr/testify/suite"
)

// UserUpdateAPITestSuite はUser Update APIの統合テストスイートです
type UserUpdateAPITestSuite struct {
    UserAPITestSuite // User API共通スイートを継承
}

// TestSuite を実行するGo標準のテスト関数
func TestUserUpdateAPITestSuite(t *testing.T) {
    suite.Run(t, new(UserUpdateAPITestSuite))
}

/*
(補足: SetupTestは親スイート(UserAPITestSuite)のメソッドが
自動的に呼び出されるため、ここでは定義不要です)
*/

ステップ3: テストケースの実装

UserUpdateAPITestSuite に、Test プレフィックスを持つテストケースメソッドを実装していきます。

正常系テスト

// TestUpdateUser_Success はユーザー更新成功のテストです
func (s *UserUpdateAPITestSuite) TestUpdateUser_Success() {
    // 1. 準備 (Arrange)
    newFullName := "更新された太郎"
    newEmail := "updated@example.com"

    // リクエストボディ(DTO)を作成
    requestBody := dto.UpdateUserRequest{
        UserID:      s.TestUser.UserID, // (リクエストに含まれる場合)
        FullName:    &newFullName,
        Email:       &newEmail,
    }

    // 2. 実行 (Act)
    url := fmt.Sprintf("/api/v1/user/%d", s.TestUser.UserID)
    w := s.MakeAuthenticatedRequest( // 認証済みリクエスト
        http.MethodPut,
        url,
        requestBody,
        s.TestUser, // s.TestUser (自分自身) で認証
    )

    // 3. 検証 (Assert)
    // レスポンスの検証
    s.AssertSuccessResponse(w, http.StatusOK)

    var responseBody dto.UpdateUserResponse
    err := json.Unmarshal(w.Body.Bytes(), &responseBody)
    s.Require().NoError(err)
    s.Equal(newEmail, responseBody.User.Email)
    s.Equal(newFullName, responseBody.User.FullName)

    // **データベースの状態確認(正常系では必須)**
    updatedUser := s.GetUserByID(s.TestUser.UserID) // (BaseIntegrationTestSuite層で定義されているヘルパー想定)
    s.Equal(newEmail, updatedUser.Email)
    s.Equal(newFullName, updatedUser.FullName)
}

エラー系テスト

// TestUpdateUser_Unauthorized は未認証エラーのテストです
func (s *UserUpdateAPITestSuite) TestUpdateUser_Unauthorized() {
    // 1. 準備
    requestBody := dto.UpdateUserRequest{/* ... */}
    url := fmt.Sprintf("/api/v1/user/%d", s.TestUser.UserID)

    // 2. 実行 (認証なしリクエスト)
    w := s.MakeRequest(
        http.MethodPut,
        url,
        requestBody,
        nil, // ヘッダーなし
    )

    // 3. 検証
    s.AssertErrorResponse(w, http.StatusUnauthorized)
}

// TestUpdateUser_ValidationError はバリデーションエラーのテストです
func (s *UserUpdateAPITestSuite) TestUpdateUser_ValidationError() {
    // 1. 準備 (不正なメールアドレス)
    invalidEmail := "invalid-email"
    requestBody := dto.UpdateUserRequest{
        Email:  &invalidEmail,
    }
    url := fmt.Sprintf("/api/v1/user/%d", s.TestUser.UserID)

    // 2. 実行
    w := s.MakeAuthenticatedRequest(
        http.MethodPut,
        url,
        requestBody,
        s.TestUser,
    )

    // 3. 検証
    s.AssertErrorResponse(w, http.StatusBadRequest)
}

// TestUpdateUser_OtherUserForbidden は他ユーザーのデータ更新(認可)を防ぐテストです
func (s *UserUpdateAPITestSuite) TestUpdateUser_OtherUserForbidden() {
    // 1. 準備 (別のユーザーを作成)
    otherUser, _, err := s.Factory.User.Create()
    s.Require().NoError(err)

    requestBody := dto.UpdateUserRequest{
        DisplayName: stringPtr("Hacked"), // (ポインタヘルパーがある想定)
    }

    // 2. 実行 (TestUserでotherUserの更新を試みる)
    url := fmt.Sprintf("/api/v1/user/%d", otherUser.UserID)
    w := s.MakeAuthenticatedRequest(
        http.MethodPut,
        url,
        requestBody,
        s.TestUser, // 権限のないユーザー
    )

    // 3. 検証
    s.AssertErrorResponse(w, http.StatusForbidden)
}

モックテスト

リポジトリや外部APIクライアントがエラーを返した場合のテストです。

// TestUpdateUser_RepositoryError はリポジトリエラーのテストです
func (s *UserUpdateAPITestSuite) TestUpdateUser_RepositoryError() {
    // 1. 準備 (Arrange)
    // クリーンなユーザーデータを作成
    cleanUser, _, err := s.Factory.User.Create()
    s.Require().NoError(err)

    // UserRepositoryのモックを作成
    mockUserRepo := mock.NewMockUserRepositoryInterface(s.MockCtrl)

    // モックの期待動作を設定
    // GetUserByIDは成功する
    mockUserRepo.EXPECT().
        GetUserByID(gomock.Any(), cleanUser.UserID).
        Return(cleanUser, nil).
        Times(1)

    // UpdateUserでエラーを返す
    mockUserRepo.EXPECT().
        UpdateUser(gomock.Any(), gomock.Any()).
        Return(fmt.Errorf("database connection failed")).
        Times(1)

    // **依存関係の差し替え**
    // 元のサービスを退避
    originalService := s.UserService
    // モックを使ったサービスで上書き
    s.UserService = service.NewUserService(mockUserRepo, s.GetDB())
    s.setupUserRoutes() // ルート再登録(新しいサービスをハンドラに反映)

    // 2. 実行 (Act)
    requestBody := dto.UpdateUserRequest{/* ... */}
    url := fmt.Sprintf("/api/v1/user/%d", cleanUser.UserID)
    w := s.MakeAuthenticatedRequest(
        http.MethodPut,
        url,
        requestBody,
        cleanUser,
    )

    // 3. 検証 (Assert)
    s.Equal(http.StatusInternalServerError, w.Code)

    // **後処理: 差し替えた依存関係を元に戻す**
    s.UserService = originalService
    s.setupUserRoutes() // 他のテストに影響しないように必ず復元
}

外部APIのモックについて

上記の TestUpdateUser_RepositoryError と同じアプローチで、外部API(例: google_auth のクライアント)をモック化できます。

  1. 外部APIクライアントのインターフェースを定義します。
  2. gomock でそのインターフェースのモックを生成します。
  3. SetupTest またはテストケースの冒頭で、モッククライアントを期待通りに動作させ(例: EXPECT().VerifyToken(...).Return(nil))、そのモックをサービスにDI(差し替え)します。
  4. APIリクエストを実行し、外部APIが正しく呼び出されたか、またはエラー時に期待したHTTPステータスが返るかを検証します。

テスト分離戦略

このフレームワークでは、複数のレベルでテストの分離性を確保しています。

1. コンテナレベルの分離

  • 各テストスイート(厳密には go test の各プロセス)は、理論上、独自のPostgreSQLコンテナで実行可能です。(ただし、パフォーマンスのためスイート全体で共有することが多い)
  • Testcontainers の Reuse 設定により、コンテナを再利用しつつ、論理的な分離を保ちます。

2. テストケースレベルの分離

  • SetupTest で TruncateAllTables() を実行します。
  • これにより、各テストケース(Test...関数)は、常にクリーンな(マスターデータのみが存在する)状態のデータベースで開始されます。
  • テストケース間でのデータ汚染を防ぎ、テストの順序依存性を排除します。

3. データレベルの分離

  • go-faker を活用したファクトリーパターンにより、テストケースごとにユニークなデータ(ランダムなEmailや名前)を生成します。
  • これにより、email_unique のようなDB制約違反によるテスト失敗を防ぎます。

ベストプラクティス

1. 正常系テストでは必ずDBを検証する

レスポンスが200 OKでも、データが正しく永続化されているとは限りません。

// ✅ 良い例
func (s *TestSuite) TestCreate_Success() {
    w := s.MakeAuthenticatedRequest(...)
    s.AssertSuccessResponse(w, http.StatusCreated)

    // **必ずデータベースの状態を直接確認する**
    var created Model
    err := s.GetDB().Get(&created, "SELECT * FROM table WHERE id = $1", id)
    s.Require().NoError(err)
    s.Equal(expectedValue, created.Field)
}

// ❌ 悪い例(レスポンスのみチェック)
func (s *TestSuite) TestCreate_Success() {
    w := s.MakeAuthenticatedRequest(...)
    s.AssertSuccessResponse(w, http.StatusCreated)
    // データベース確認なし - 実際に保存されているか不明
}

2. ファクトリーを積極的に活用する

テストコード内で直接SQLを記述すると、保守性が低下します。

// ✅ 良い例: ファクトリー使用
user, _, err := s.Factory.User.Create()
project, err := s.Factory.Project.Create(user.UserID)

// ❌ 悪い例: 直接SQL
s.GetDB().Exec(`
    INSERT INTO users (email, full_name, ...)
    VALUES ($1, $2, ...)
`, "test@example.com", "Test User", ...)

3. モックテスト後の復元を徹底する

モックで依存関係を差し替えた後は、TearDownTest や defer、またはテストケースの最後に、必ず元の依存関係に戻します。(実践例の TestUpdateUser_RepositoryError を参照)

// ✅ 良い例
func (s *TestSuite) TestWithMock() {
    // 元のサービスを保存
    originalService := s.YourService

    // モックで置き換え
    mockRepo := mock.NewMockRepository(s.MockCtrl)
    s.YourService = service.New(mockRepo, s.GetDB())
    s.setupRoutes() // ルート再登録

    // テスト実行...

    // **必ず元に戻す**
    s.YourService = originalService
    s.setupRoutes()
}

4. テストケースの命名規則

命名は「何をテストしているか」が明確になるようにします。

// パターン: Test{API名}_{シナリオ}
func (s *TestSuite) TestUpdateUser_Success()
func (s *TestSuite) TestUpdateUser_PartialUpdate()
func (s *TestSuite) TestUpdateUser_Unauthorized()
func (s *TestSuite) TestUpdateUser_ValidationError()
func (s *TestSuite) TestUpdateUser_OtherUserForbidden()
func (s *TestSuite) TestUpdateUser_RepositoryError()

テスト実行

ビルドタグによるテストの分離

本フレームワークでは、統合テストとして扱うテストファイル(_test.go)の先頭行に、以下のような ビルドタグ(ビルド制約) を記述します。

//go:build integration

package user_tests // または他のパッケージ

import (
    // ...
)

ビルドタグ(//go:build)とは

ビルドタグは、Goコンパイラに対して「このファイルは特定の条件下でのみビルド(コンパイル)対象とする」ことを伝えるディレクティブ(指示)です。

//go:build integration と記述した場合、go コマンド実行時に -tags=integration というオプションが指定された場合のみ、このファイルがビルド対象となります。

逆に、-tags オプションを指定しない場合(例: go test ./...)、このタグが付いたファイルはビルド対象から除外(無視)されます。

なぜ統合テストでビルドタグを使うのか?

主な理由は、テストの分離、成果物のクリーン化、実行環境の制御のためです。

1. テストの分離と速度

  • ユニットテスト: DBや外部環境に依存せず、メモリ内で完結するため、非常に高速に実行できます。
  • 統合テスト: Testcontainers によるDockerコンテナの起動・停止を伴うため、低速です。
    日常の開発サイクルでは「ユニットテストだけを素早く実行し、コードの正当性を確認したい」という場面が頻繁にあります。ビルドタグでこれら2種類のテストを明確に分離することにより、時間のかかる統合テストを除外した、高速なテスト実行が可能になります。

2. 本番バイナリへの非混入(成果物のクリーン化)
本番環境向けのバイナリをビルドする際(例: go build -o my-app)、通常は -tags=integration を指定しません。その結果、//go:build integration タグが付与された全てのテスト関連ファイル(テストスイート、ファクトリー、testcontainers をインポートするコードなど)は、コンパイル対象から完全に除外されます。
これにより、以下のメリットが生まれます。

  • バイナリサイズの削減: テスト専用のコードや、testify・gomock といったテスト時にしか使用しないライブラリへの依存が、最終的な実行ファイルに含まれません。
  • セキュリティの向上: テスト用のロジックやモック、テスト用ライブラリの脆弱性などが、本番環境の成果物に残ることを防ぎます。

3. 環境依存の制御
この統合テストは、Testcontainers を介してDocker環境が利用可能であることを前提としています。もしCI環境や他の開発者のローカル環境でDockerが利用できない場合、タグを指定しなければ統合テスト関連のコード(Testcontainers のインポートなど)がビルド対象外となり、コンパイルエラーを防ぐことができます。

実行コマンド

このビルドタグの設定により、テストの実行コマンドは以下のように明確に使い分けます。

# 1. 統合テストのみを実行 (integrationタグを指定)
# これにより //go:build integration が付いたファイルがビルド対象となる
go test -tags=integration ./src/test/integration_tests/... -v

# 2. ユニットテストのみを実行 (タグを指定しない)
# integrationタグが付いたファイルは「無視」されるため、
# ユニットテスト(ビルドタグなし、または別のタグ)のみが実行される
go test ./... -v

# (補足) ユニットテスト側に //go:build unit のようなタグを付け、
# go test -tags=unit ./... のように明示的に分離することも可能です

# --- 特定のテストを実行する場合 ---

# 特定のAPIの統合テスト
go test -tags=integration ./src/test/integration_tests/v1/user -v

# 特定のテストケース
go test -tags=integration ./src/test/integration_tests/v1/user -v \
    -run TestUserUpdateAPITestSuite/TestUpdateUser_Success

導入した所感と今後の課題

本フレームワークを導入し、運用してみた所感を共有します。

1. テストコードの記述量について

Laravelがフレームワークレベルで提供するFeatureテストと比較した場合、本アプローチではテスト基盤(BaseIntegrationTestSuite や TestFoundation)やデータ生成(Factory)など、自前で記述・維持する必要があるコード量は多くなる傾向にあります。

ただし、この基盤コードは一度構築すれば、プロジェクト内の全てのテストで再利用可能です。

また、個別のテストケース(TestUpdateUser_Success など)の実装においても一定の記述量が必要ですが、近年のAIアシスタント(Claudeなど)を活用することで、テストの意図(「何をテストしているか」)を読み解いたり、定型的なテストコードを生成したりするコストは大幅に削減できる可能性を感じています。

2. Testcontainers のログ出力

Testcontainers は非常に強力なツールですが、デフォルトのまま go test で実行すると、Dockerコンテナの起動・停止に関する詳細なログが標準出力にそのまま出力(垂れ流し)され、テスト結果の概要を掴みにくくなる場合があります。

この課題に対し、我々のプロジェクトでは gotestsum のようなテストランナーを導入しました。これにより、Testcontainers の詳細ログを抑制しつつ、テスト結果(成功・失敗・スキップ)をきれいに整形して表示することが可能になりました。

今後の展望

現状でもAPIの品質担保という目的は達成できていますが、これらの所感を踏まえ、テスト基盤のコードをさらに抽象化・共通化し、「より少ない記述で、より簡単に」統合テストを実装できるようなプロジェクト固有のヘルパーやツールを整備していくことが、今後の課題であると考えています。

まとめ

本記事で紹介したフレームワークは、以下の特徴を持ちます。

  • 本番環境に近いテスト: Testcontainers により、実際のPostgreSQL(または他のDB)をDocker上で使用します。
  • 優れたテスト分離: コンテナ、テストケース(データクリーンアップ)、データ(Faker)の各レベルで分離を担保します。
  • 高い保守性: 階層化されたスイート構造とファクトリーパターンにより、テストコードの保守性を高めます。
  • パフォーマンス: コンテナをスイート全体で再利用することで、テスト実行時間を短縮します。
  • 開発体験: 共通ヘルパーやファクトリーにより、開発者はテストロジックの記述に集中できます。

このフレームワークにより、APIの品質を担保しながら、信頼性の高い開発プロセスを構築することが可能になります。

おわりに

クロステック・マネジメント社は、「教育×AI」で次世代の学びを創造するために生まれた芸術大学発のスタートアップです。
ご興味のある方は、ぜひお気軽にお問い合わせいただけると嬉しいです!

参考リンク

GitHubで編集を提案
クロステックマネジメント(京都芸術大学)

Discussion