Zenn
GENIEE TechBlog
🐾

「壊れにくい」Goの単体テスト 〜モックを活用した保守性の高いテスト設計〜

に公開1
8

はじめに

こんにちは。GENIEE CHAT機能開発チームリーダーの渡邉です。

私たちのチームでは、Goを使用してプロダクトのバックエンドを構築しています。その開発をする上で、単体テストの保守性に関する以下のような問題が浮上することがあります。

  • テストコードの修正に多くの時間を取られる
  • 実装の些細な変更でテストが失敗する
  • テストの実行に時間がかかり、開発効率が低下する

本記事では、これらの課題を解決するアプローチの一つとして、モックを使用した「壊れにくい」単体テストの実装例をご紹介します。

ここで言う「壊れにくい」とは、内部実装への依存が少なく、機能の本質的な変更以外ではテストが失敗しにくい状態を指しています。

壊れやすいテストの例

以下のようなテストは、保守性が低く「壊れやすい」例といえるでしょう。

func TestUserService_Create_Fragile(t *testing.T) {
    service := &UserService{
        internalCache: NewInMemoryCache(), // 内部実装で使用している関数を直接利用
        maxRetries: 3,  // 内部実装に依存
        timeout: time.Second * 5,  // 内部実装に依存
    }

    // maxRetries, timeoutの値を変更するとこのテストは失敗する
    err := service.CreateUser(&User{Name: "test"})
    assert.NoError(t, err)
    
    // キャッシュの内部実装を直接検証している
    cached := service.internalCache.Get("test")
    assert.NotNil(t, cached)
}

上記のテストには以下のような問題があります。

  • 内部実装への依存
    • キャッシュの仕組みやリトライ回数などの実装詳細に依存
    • 内部実装の変更がテストの失敗につながりやすい
  • 外部システムへの依存
    • テストの実行速度が遅くなる
    • テストの実行が不安定になる
    • テスト環境の準備が煩雑

これらの問題はモックの使用によって軽減できます。以下では、実際にプロダクトで使用しているライブラリとともに、モックを使用した「壊れにくい」単体テストの実装例をご紹介します。

モックを使用した単体テストの実装例

1. インターフェースのモック化(gomock)

外部システムとの境界をインターフェースで定義し、テスト時にはそのモック実装を使用する方法です。gomockを使用することで、インターフェースからモックコードを自動生成できます。

https://pkg.go.dev/go.uber.org/mock/gomock

//go:generate mockgen -source=user_repository.go -destination=mock_user_repository.go -package=user
type UserRepository interface {
    Create(ctx context.Context, user *User) error
    FindByID(ctx context.Context, id string) (*User, error)
}

//go:generate コメントは、go generateコマンドでモックコードを生成するための指示です。生成されたモックを使用して、以下のように単体テストを書くことが可能です。

func TestUserService_Create(t *testing.T) {
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()
    mockRepo := NewMockUserRepository(ctrl)
    
    t.Run("一時的なデータベースエラー時のリトライ", func(t *testing.T) {
        mockRepo.EXPECT().
            Create(gomock.Any(), gomock.Any()).
            Return(errors.New("temporary error")).
            Times(2)  // 2回目までは失敗しエラーを返す
        mockRepo.EXPECT().
            Create(gomock.Any(), gomock.Any()).
            Return(nil).
            Times(1)  // 3回目で成功する
            
        service := NewUserService(mockRepo)
        err := service.Create(context.Background(), &User{
            Email: "test@example.com",
        })
        
        assert.NoError(t, err)
    })
    
    t.Run("永続的なエラー時の適切なエラー返却", func(t *testing.T) {
        mockRepo.EXPECT().
            Create(gomock.Any(), gomock.Any()).
            Return(&PermanentError{Msg: "invalid state"})
            
        service := NewUserService(mockRepo)
        err := service.Create(context.Background(), &User{
            Email: "test@example.com",
        })
        
        assert.Error(t, err)
        var permErr *PermanentError
        assert.True(t, errors.As(err, &permErr))
    })
}

上記の例では、まずmockgenコマンドで生成したモックを使用してテストを実装しています。主な要素は以下の通りです。

  • gomock.NewController(t)でモックのコントローラーを作成
  • EXPECT()で期待する振る舞いを定義
    • メソッドの呼び出し順序や回数、引数の値なども指定可能
  • gomock.Any()で任意の引数にマッチ
  • Return(...)で期待する返り値を検証
  • Times(1), Times(2)で「何回呼び出されるか」を検証

2. HTTPサーバーのモック化(httptest)

外部APIとの通信が発生するコードをテストする場合、標準ライブラリのhttptestパッケージを使用してモックサーバーを作成できます。

https://pkg.go.dev/net/http/httptest

func TestExternalAPIClient(t *testing.T) {
    // 正常系のモックサーバー
    tsOK := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // リクエストの検証
        assert.Equal(t, "POST", r.Method)
        assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
        
        // リクエストボディの検証
        body, _ := io.ReadAll(r.Body)
        var requestBody map[string]interface{}
        json.Unmarshal(body, &requestBody)
        assert.Equal(t, "test@example.com", requestBody["email"])
        
        // レスポンスの設定
        w.WriteHeader(http.StatusOK)
        w.Write([]byte(`{"status": "success"}`))
    }))
    defer tsOK.Close()

    // 異常系のモックサーバー
    tsBadRequest := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusBadRequest)
        w.Write([]byte(`{"error": "invalid request"}`))
    }))
    defer tsBadRequest.Close()

    // 正常系のテスト
    t.Run("success", func(t *testing.T) {
        client := NewAPIClient(tsOK.URL)
        resp, err := client.CreateUser(&User{Email: "test@example.com"})
        
        assert.NoError(t, err)
        assert.Equal(t, "success", resp.Status)
    })

    // 異常系のテスト
    t.Run("bad request", func(t *testing.T) {
        client := NewAPIClient(tsBadRequest.URL)
        resp, err := client.CreateUser(&User{Email: "invalid"})
        
        assert.Error(t, err)
        assert.Nil(t, resp)
    })
}

上記の例では正常系のモックサーバー(tsOK)と異常系のモックサーバー(tsNG)を実装しており、それぞれのふるまいを定義しています。これらのサーバーを引数としてNewAPIClient関数へと外部注入することにより、テストケースのハンドリングを実現しています。

3. Redisのモック化(miniredis)

miniredisの使用により、メモリ上で実際のRedisと同じような動作をするモックを作成できます。これにより、実際のRedisサーバーを必要とせずにテストを実行できます。

https://pkg.go.dev/github.com/alicebob/miniredis/v2

func TestRedisCache(t *testing.T) {
    // miniredisサーバーの起動
    s, err := miniredis.Run()
    if err != nil {
        t.Fatal(err)
    }
    defer s.Close()

    // Redisクライアントの作成
    client := redis.NewClient(&redis.Options{
        Addr: s.Addr(),
    })

    // キャッシュ操作のテスト
    t.Run("basic operations", func(t *testing.T) {
        cache := NewUserCache(client)

        // データの保存
        user := &User{ID: "123", Name: "Test User"}
        err = cache.Set(user)
        assert.NoError(t, err)

        // データの取得
        cached, err := cache.Get("123")
        assert.NoError(t, err)
        assert.Equal(t, user.Name, cached.Name)
    })

    // 有効期限のテスト
    t.Run("expiration", func(t *testing.T) {
        cache := NewUserCache(client)
        
        user := &User{ID: "456", Name: "Expiring User"}
        err = cache.SetWithExpiration(user, time.Hour)
        assert.NoError(t, err)

        // 時間を進める
        s.FastForward(time.Hour + time.Minute)
        
        // 有効期限切れの確認
        _, err = cache.Get("456")
        assert.Error(t, err)
        assert.True(t, errors.Is(err, redis.Nil))
    })
}

上記の例では、miniredisを使用してインメモリのRedisサーバーを実装しています。主な特徴は以下の通りです。

  • miniredis.Run()で一時的なRedisサーバーを起動
    • 上記の例ではSetした値をGetして検証している
  • FastForward()で時間を進める(keyのTTLを減少させる)ことができる

テスタブルなコードの設計

ここまで3つのライブラリについて実装例を見てきましたが、モックを効果的に活用するためには、プロダクションコードの設計も重要です。

依存性の注入

悪い例:ハードコードされた依存

以下は、依存性が直接コードに組み込まれている例です。

// 依存がハードコードされているため、テストが困難
type UserService struct {}

func NewUserService() *UserService {
    return &UserService{}
}

func (s *UserService) CreateUser(user *User) error {
    // 直接RedisClientを生成
    redis := redis.NewClient(&redis.Options{
        Addr: "localhost:6379",
    })
    
    // 直接HTTPクライアントを生成
    client := &http.Client{
        Timeout: time.Second * 10,
    }
    
    // これらの依存をモックに置き換えることができない
    // ...
}

このコードには以下のような問題があります。

  • テストが困難
    • 実際のRedisサーバーが必要
    • 実際の通知サービスが必要
    • テストの実行が遅く不安定
  • 設定変更が困難
    • 環境ごとの設定変更にコード変更が必要
  • 責務が混在
    • キャッシュ処理と通知処理が密結合
    • エラーハンドリングの共通化が困難

良い例:依存性注入を使用

依存性注入を使用して、上記の問題を解決した例が以下です。

// 依存をインターフェースとして定義
type CacheRepository interface {
    Set(key string, value interface{}) error
    Get(key string) (interface{}, error)
}

type NotificationService interface {
    Send(userID string, message string) error
}

// 依存を外部から注入
type UserService struct {
    cache    CacheRepository
    notifier NotificationService
    config   *Config
}

// コンストラクタで依存を受け取る
func NewUserService(cache CacheRepository, notifier NotificationService, config *Config) *UserService {
    return &UserService{
        cache:    cache,
        notifier: notifier,
        config:   config,
    }
}

func (s *UserService) CreateUser(user *User) error {
    // 注入された依存を使用
    err := s.cache.Set(user.ID, user)
    if err != nil {
        return fmt.Errorf("failed to cache user: %w", err)
    }
    
    return s.notifier.Send(user.ID, "Welcome!")
}

このアプローチのメリットは以下です。

  • テストの容易性
    • モックを使用して依存を置き換え可能
    • 外部システムなしでテスト可能
    • テストが高速で安定
  • 柔軟な設定管理
    • テスト用の設定を簡単に注入可能
    • 関心の分離
  • 各コンポーネントの責務が明確
    • インターフェースを通じた疎結合な設計
    • 実装の詳細を隠蔽
    • 機能追加時の影響範囲が限定的

このように依存性注入を活用することで、保守性の高いコードを実現できます。
ただし、以下のようなトレードオフも考慮する必要があります。

  • 初期の実装コストが高い
  • コード量が増える
  • 小規模なアプリケーションでは過剰な場合もある

これらのトレードオフを考慮しつつ、プロジェクトの規模や要件に応じて適切に判断することが重要です。

まとめ

本記事では、gomock, httptest, miniredis を使用した、「壊れにくい」単体テストの実装例について解説しました。
単体テストはプロダクトの品質を支える重要な要素です。本記事で紹介させていただいた例が保守性の高いテスト実装のご参考になれば幸いです。

8
GENIEE TechBlog
GENIEE TechBlog

Discussion

budougumi0617budougumi0617

gomockのバージョンが0.5.0ならば Finish メソッドは呼ぶ必要ないです。テスト終了時に内部的に自動で呼ばれるようになりました。
https://pkg.go.dev/go.uber.org/mock@v0.5.0/gomock#Controller.Finish

gomock.NewController(t) メソッドは t.Run するごとに呼んだほうがよいでしょう。 NewController メソッドの単位でモックが正しく呼ばれたか検査されます。
TestUserService_Create の書き方ですと、どちらかの t.Run がfailだったとき、両方ともfailになります。

また、Go1.14からテストのなかで t.Cleanup メソッドが使えるようになりました。テストコード内でdeferを使いたいとき、大体のパターンは t.Cleanup に変えて良いと思います。
https://pkg.go.dev/testing#T.Cleanup

ログインするとコメントできます