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つのテストファイルを移行し、次に進む前に各ファイルが合格することを確認
-
生成されたモック - すべてのモックを
internal/mocks/に配置して整理 - 明確な期待値 - EXPECT()パターンでテストが自己文書化
- 型安全性 - コンパイラがインターフェースの不一致を即座にキャッチ
課題と解決策
| 課題 | 解決策 |
|---|---|
| 学習曲線 | 包括的な.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. 今後の改善
計画された機能強化
-
統合テスト
- Lambda関数のエンドツーエンドテストを追加
- 実際のAWSサービスでテスト(LocalStack)
-
プロパティベーステスト
- クリティカルパスのファジングに
gopterを使用 - ランダムなテストケースを自動生成
- クリティカルパスのファジングに
-
ミューテーションテスト
-
go-mutestingを追加してテスト品質を確保 - テストが実際のバグをキャッチすることを検証
-
-
カバレッジゲート
- 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%の型安全性 モックで
重要なポイント
- 手動モックを書かない - 生成されたモックはあらゆる面で優れています
- EXPECT()を使用 - 明示的な期待値が早期にバグをキャッチ
- 型安全性が重要 - コンパイラ検証されたモックはランタイムエラーを防ぐ
- 明確なテスト - gomockでテストの意図が明確に
- 保守性 - 生成されたモックは自動的に同期を維持
推奨事項
まだGoで手動モックを書いている場合は、今すぐやめてgomockに切り替えてください。最初の学習曲線は小さいですが、長期的な利点は巨大です。
ソースコード
Discussion