💨

Gomockへの移行:手動モックから型安全なテストへ

に公開

1. 課題:手動モックのメンテナンス地獄

クリーンアーキテクチャの原則に従って認証システムを構築した当初、テスト用に手動でモックを作成していました。最初は機能していましたが、すぐにメンテナンスの負担になりました:

// 手動モック - インターフェースが変更されると手動で更新が必要
type MockTokenGenerator struct {
    generateFn func(userInfo ports.UserInfo) (string, string, error)
}

func (m *MockTokenGenerator) GenerateTokenPair(userInfo ports.UserInfo) (string, string, error) {
    if m.generateFn != nil {
        return m.generateFn(userInfo)
    }
    return "mock-access-token", "mock-refresh-token", nil
}

func (m *MockTokenGenerator) RefreshAccessToken(refreshToken string) (string, error) {
    return "new-access-token", nil
}

// ... さらに5つのメソッドを手動で実装

直面した問題:

  • 🔴 83行以上のボイラープレート がテストファイル全体に散在
  • 🔴 コンパイル時の安全性なし - インターフェースの変更がモックを壊さない
  • 🔴 一貫性のない動作 - 各モックが異なる方法で実装されている
  • 🔴 保守が困難 - インターフェースの変更のたびに手動更新が必要
  • 🔴 メソッドの実装漏れ - 実装を忘れるとランタイムパニック

2. 解決策:型安全なモックのためのGomock

go.uber.org/mock(旧golang/mock)に移行し、インターフェースから自動的にモックを生成するようにしました。

インストール

# gomock依存関係を追加
go get go.uber.org/mock/gomock@latest

# mockgenツールをインストール
go install go.uber.org/mock/mockgen@latest

モック生成

手動でモックを書く代わりに、生成します:

# TokenGeneratorのモックを生成
go run go.uber.org/mock/mockgen@latest \
  -source=internal/application/ports/token_generator.go \
  -destination=internal/mocks/mock_token_generator.go \
  -package=mocks

# OAuthValidatorのモックを生成
go run go.uber.org/mock/mockgen@latest \
  -source=internal/application/ports/oauth_validator.go \
  -destination=internal/mocks/mock_oauth_validator.go \
  -package=mocks

# UserRepositoryのモックを生成
go run go.uber.org/mock/mockgen@latest \
  -source=internal/domain/user/repository.go \
  -destination=internal/mocks/mock_user_repository.go \
  -package=mocks

これにより、インターフェースの変更に自動的に同期する型安全なモックが作成されます。

3. テストの変革:変更前と変更後

変更前:手動モック(エラーが起こりやすい)

func TestGoogleLoginUseCase_NewUser_Success(t *testing.T) {
    ctx := context.Background()
    repo := NewMockUserRepository()  // 手動モック
    tokenGen := &MockTokenGenerator{}  // 手動モック
    oauthValidator := &MockOAuthValidator{
        validateFn: func(ctx context.Context, idToken string, audience string) (*ports.OAuthUserInfo, error) {
            return &ports.OAuthUserInfo{
                UserID:        "google-user-123",
                Email:         "newuser@example.com",
                EmailVerified: true,
                Name:          "New User",
                Picture:       "https://example.com/photo.jpg",
            }, nil
        },
    }

    useCase := NewGoogleLoginUseCase(repo, oauthValidator, tokenGen, "test-client-id")
    result, err := useCase.Execute(ctx, "valid-google-token")

    require.NoError(t, err)
    assert.Equal(t, "Login successful", result.Message)
}

問題点:

  • メソッドが実際に呼び出されたかの検証がない
  • モック実装の型チェックがない
  • すべてのシナリオがカバーされているか確認が困難

変更後:Gomock(型安全で検証済み)

func TestGoogleLoginUseCase_NewUser_Success(t *testing.T) {
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()

    ctx := context.Background()
    mockRepo := mocks.NewMockRepository(ctrl)
    mockOAuth := mocks.NewMockOAuthValidator(ctrl)
    mockTokenGen := mocks.NewMockTokenGenerator(ctrl)

    oauthInfo := &ports.OAuthUserInfo{
        UserID:        "google-user-123",
        Email:         "newuser@example.com",
        EmailVerified: true,
        Name:          "New User",
        Picture:       "https://example.com/photo.jpg",
    }

    // 期待値を設定 - 正しく呼び出されない場合はテスト失敗
    mockOAuth.EXPECT().
        ValidateToken(ctx, "valid-google-token", "test-client-id").
        Return(oauthInfo, nil)

    userID, _ := user.NewUserID("google-user-123")
    mockRepo.EXPECT().
        FindByID(ctx, userID).
        Return(nil, shared.ErrUserNotFound)

    mockRepo.EXPECT().
        Save(ctx, gomock.Any()).
        Return(nil)

    mockTokenGen.EXPECT().
        GenerateTokenPair(gomock.Any()).
        Return("mock-access-token", "mock-refresh-token", nil)

    useCase := NewGoogleLoginUseCase(mockRepo, mockOAuth, mockTokenGen, "test-client-id")
    result, err := useCase.Execute(ctx, "valid-google-token")

    require.NoError(t, err)
    assert.Equal(t, "Login successful", result.Message)
}

利点:

  • ✅ 期待されるメソッドが呼び出されない場合、テスト失敗
  • ✅ 間違ったパラメータで呼び出された場合、テスト失敗
  • ✅ 間違った回数呼び出された場合、テスト失敗
  • ✅ 型安全 - モックがインターフェースと一致する場合のみコンパイル可能
  • ✅ 明確な意図 - EXPECT()でテストの期待値が明示的

4. バグ修正:GetCurrentUserハンドラー

テストの移行中に、リファクタリング後のコードで重大なバグを発見しました:

バグの内容

クリーンアーキテクチャのリファクタリング後、GetCurrentUserハンドラーがインメモリリポジトリからユーザーを検索しようとしていました:

// ❌ 間違い - リポジトリから取得しようとする
func (h *AuthHandler) GetCurrentUser(c *gin.Context) {
    claims, _ := c.Get("claims")

    // ユーザーがリポジトリに存在しないため失敗!
    user, err := h.getCurrentUserUseCase.ExecuteFromClaims(ctx, claims)
    if err != nil {
        c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
        return
    }

    c.JSON(http.StatusOK, gin.H{"user": user})
}

問題: JWTトークンにすべてのユーザー情報が含まれているのに、ユーザーを永続化しないリポジトリから検索しようとしていました!

修正方法

JWTクレームから直接ユーザーデータを返す(元々の意図通り):

// ✅ 正しい - JWTトークンからデータを返す
func (h *AuthHandler) GetCurrentUser(c *gin.Context) {
    claims, _ := c.Get("claims")

    // JWTクレームから直接ユーザー情報を返す
    c.JSON(http.StatusOK, gin.H{
        "user": gin.H{
            "id":      claims.UserID,
            "email":   claims.Email,
            "name":    claims.Name,
            "picture": claims.Picture,
        },
    })
}

学んだこと: 手動モックではアーキテクチャの不一致をキャッチできませんでした。Gomockの厳格な期待値設定があれば、もっと早く発見できたはずです!

5. 移行統計

変更されたファイル

ファイル 変更前 変更後 変更
google_login_test.go 350行(モック含む) 334行(gomock使用) -16行、より明確な意図
get_current_user_test.go 255行(モック含む) 220行(gomock使用) -35行
refresh_token_test.go 104行(モック含む) 85行(gomock使用) -19行
手動モックコード合計 83行 0行 -83行 ✨

テストカバレッジの進化

メトリック 変更前 変更後 変更
総カバレッジ 91.4% 95.6% +4.2% ✅
総テスト数 100 133 +33テスト
ドメイン層 95% 100% +5%
アプリケーション層 88% 93.6% +5.6%
インフラストラクチャ層 91% 94.1% +3.1%
プレゼンテーション層 92% 100% +8%

コード品質の向上

  • ✅ 手動モックゼロ - すべてのモックが生成される
  • ✅ 型安全性 - コンパイル時の検証
  • ✅ 明確な期待値 - EXPECT()パターン
  • ✅ より良いエラーメッセージ - gomockが詳細な失敗情報を提供
  • ✅ 一貫したパターン - すべてのテストが同じ構造に従う

6. 主要な実装詳細

テスト構造パターン

すべてのテストが以下の一貫したパターンに従います:

func TestFeature_Scenario(t *testing.T) {
    // 1. コントローラーのセットアップ
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()

    // 2. モックの作成
    mockDep1 := mocks.NewMockDependency1(ctrl)
    mockDep2 := mocks.NewMockDependency2(ctrl)

    // 3. 期待値の設定
    mockDep1.EXPECT().
        Method(expectedParam).
        Return(expectedResult, nil)

    // 4. テストの実行
    result := ExecuteFunction(mockDep1, mockDep2)

    // 5. 結果の検証
    assert.Equal(t, expected, result)
}

モックマッチャー

Gomockはアサーション用の柔軟なマッチャーを提供します:

// 完全一致
mockRepo.EXPECT().
    Save(ctx, specificUser).
    Return(nil)

// 任意の値
mockRepo.EXPECT().
    Save(ctx, gomock.Any()).
    Return(nil)

// カスタムマッチャー
mockRepo.EXPECT().
    Save(ctx, gomock.AssignableToTypeOf(&user.User{})).
    Return(nil)

複数回の呼び出し

// 正確に1回呼び出される(デフォルト)
mockRepo.EXPECT().FindByID(ctx, userID)

// 正確にN回呼び出される
mockRepo.EXPECT().FindByID(ctx, userID).Times(3)

// 少なくとも/最大でN回呼び出される
mockRepo.EXPECT().FindByID(ctx, userID).MinTimes(1)
mockRepo.EXPECT().FindByID(ctx, userID).MaxTimes(3)

// 任意の回数(ゼロを含む)
mockRepo.EXPECT().FindByID(ctx, userID).AnyTimes()

7. CI/CD統合

GitHub Actionsワークフロー

テストカバレッジはすべてのプッシュで自動的に検証されます:

- name: カバレッジ付きでテストを実行
  run: |
    cd backend
    go test -coverprofile=coverage.out -covermode=atomic ./internal/...

- name: カバレッジ閾値をチェック
  run: |
    COVERAGE=$(go tool cover -func=coverage.out | grep total: | awk '{print $3}' | sed 's/%//')
    if [ $(echo "$COVERAGE < 80" | bc -l) -eq 1 ]; then
      echo "❌ カバレッジ $COVERAGE% は閾値80%を下回っています"
      exit 1
    fi
    echo "✅ カバレッジ $COVERAGE% は閾値を満たしています"

カバレッジ更新ポリシー

.cursorrulesにポリシーを追加しました:

カバレッジ更新ルール: テストカバレッジが±1%変更された場合、README.mdを更新:

  • 新しいカバレッジパーセンテージバッジ
  • 更新されたテスト数
  • レイヤー別の更新されたカバレッジ内訳

8. 学んだこと

うまくいったこと

  1. 段階的な移行 - 一度に1つのテストファイルを移行し、次に進む前に各ファイルが合格することを確認
  2. 生成されたモック - すべてのモックをinternal/mocks/に配置して整理
  3. 明確な期待値 - EXPECT()パターンでテストが自己文書化
  4. 型安全性 - コンパイラがインターフェースの不一致を即座にキャッチ

課題と解決策

課題 解決策
学習曲線 包括的な.cursorrulesガイドラインを作成
モック生成 makeコマンドを追加:make generate-mocks
テストの冗長性 受け入れ - テストでは簡潔さよりも明確さを優先
既存のテスト 移行中も合格を維持

確立されたベストプラクティス

// ✅ すべき:gomock.NewControllerを使用しdeferでFinish
ctrl := gomock.NewController(t)
defer ctrl.Finish()

// ✅ すべき:明確な期待値を設定
mockRepo.EXPECT().
    Save(ctx, gomock.Any()).
    Return(nil)

// ✅ すべき:成功パスと失敗パスの両方をテスト
mockOAuth.EXPECT().
    ValidateToken(ctx, "invalid", clientID).
    Return(nil, errors.New("invalid token"))

// ❌ すべきでない:もう手動モックを書かない
type ManualMock struct {
    // これは.cursorrulesで禁止されています
}

// ❌ すべきでない:必要でない限りAnyTimes()を使用しない
mockRepo.EXPECT().
    FindByID(ctx, userID).
    AnyTimes()  // 許可的すぎる - 具体的に!

9. パフォーマンスへの影響

ビルド時間

  • 変更前: 2.3秒(モック生成なし)
  • 変更後: 2.4秒(モックは一度生成、キャッシュ)
  • 影響: 無視できる(+0.1秒)

テスト実行時間

  • 変更前: 4.2秒(100テスト)
  • 変更後: 5.1秒(133テスト)
  • 影響: テスト数の増加に比例

開発者体験

  • 変更前:

    • 手動モック作成: インターフェースあたり約10分
    • インターフェース変更時のモック更新: 約5分
    • モック問題のデバッグ: 約15分
  • 変更後:

    • モック生成: 約5秒
    • モック更新: 自動(再生成)
    • 問題のデバッグ: gomockからの明確なエラーメッセージ

インターフェースあたりの時間節約: 約25分 → 約5秒 ✨

10. 今後の改善

計画された機能強化

  1. 統合テスト

    • Lambda関数のエンドツーエンドテストを追加
    • 実際のAWSサービスでテスト(LocalStack)
  2. プロパティベーステスト

    • クリティカルパスのファジングにgopterを使用
    • ランダムなテストケースを自動生成
  3. ミューテーションテスト

    • go-mutestingを追加してテスト品質を確保
    • テストが実際のバグをキャッチすることを検証
  4. カバレッジゲート

    • CI/CDで95%以上のカバレッジを強制
    • 閾値未満のPRをブロック

モック生成の自動化

Makefileに追加:

.PHONY: generate-mocks
generate-mocks:
	@echo "モックを生成中..."
	@go run go.uber.org/mock/mockgen@latest \
		-source=internal/application/ports/token_generator.go \
		-destination=internal/mocks/mock_token_generator.go \
		-package=mocks
	@go run go.uber.org/mock/mockgen@latest \
		-source=internal/application/ports/oauth_validator.go \
		-destination=internal/mocks/mock_oauth_validator.go \
		-package=mocks
	@go run go.uber.org/mock/mockgen@latest \
		-source=internal/domain/user/repository.go \
		-destination=internal/mocks/mock_user_repository.go \
		-package=mocks
	@echo "✅ モックが正常に生成されました"

11. まとめ

手動モックからgomockへの移行は、テスト戦略のゲームチェンジャーでした:

定量化された利点

  • 📊 +4.2%のテストカバレッジ (91.4% → 95.6%)
  • 📝 -83行の手動モックコード
  • 🧪 +33テスト (100 → 133)
  • ⚡ 約25分 → 5秒 モック更新あたり
  • 🐛 1つの重大なバグを発見 (GetCurrentUser)
  • ✅ 100%の型安全性 モックで

重要なポイント

  1. 手動モックを書かない - 生成されたモックはあらゆる面で優れています
  2. EXPECT()を使用 - 明示的な期待値が早期にバグをキャッチ
  3. 型安全性が重要 - コンパイラ検証されたモックはランタイムエラーを防ぐ
  4. 明確なテスト - gomockでテストの意図が明確に
  5. 保守性 - 生成されたモックは自動的に同期を維持

推奨事項

まだGoで手動モックを書いている場合は、今すぐやめてgomockに切り替えてください。最初の学習曲線は小さいですが、長期的な利点は巨大です。

ソースコード

https://github.com/yuki5155/go-google-auth-public

Discussion