📒

gomockによる共通処理モック時のジレンマを解決したい

2024/08/06に公開

開発一般の話として複数の関数に記述された共通処理を別の関数に切り出すことはよくあると思います。
Go言語で同様の対応を行ってテストを書いてみたのですが、モックまわりでいろいろと課題があるように感じました。

この記事ではGo言語における共通処理の切り出しとモック化の課題について解決策を探っていきます。
なおモックの作成にはgomockを使用します。

共通処理をモック化するときの課題

usecase層にパブリックな関数Aと関数Bがあり、共通の処理を関数Cとして切り出すことを想定します。
関数Cはusecase層内でのみ使用できるようにしたいので、プライベートな関数として定義します。

package usecase

type Repository interface {
    GetUserOrders(userId ulid.ULID) ([]Order, error)
    UpdateUserPoints(userId ulid.ULID, points int64) error
    GetBasePointRate(now time.Time) float64
    GetPremiumUserThreshold(userId ulid.ULID) int64
    GetPremiumUserBonus(userId ulid.ULID, orderCount int) float64
}

type Usecase struct {
    repo Repository
}

func NewUsecase(repo Repository) *Usecase {
    return &Usecase{
        repo: repo,
    }
}

// 関数A
func (u *Usecase) GetUserPoints(ctx context.Context, userId ulid.ULID) (int64, error) {
    orders, err := u.repo.GetUserOrders(userId)
    if err != nil {
	return nil, err
    }

    points := calculatePoints(orders)

    return points, nil
} 

// 関数B
func (u *Usecase) UpdateUserPoints(ctx context.Context, userId ulid.ULID, newOrder Order) error {
  existingOrders, err := u.repo.GetUserOrders(userId)
  if err != nil {
      return err
  }

  allOrders := append(existingOrders, newOrder)
  newPoints := calculatePoints(allOrders)

  err = u.repo.UpdateUserPoints(userId, newPoints)
  if err != nil {
      return err
  }

  return nil
}

// 関数C
func calculatePoints(userId ulid.ULID, orders []Order) int64 {
    var totalPoints int64

    basePointRate := u.repo.GetBasePointRate(time.Now())
    premiumUserThreshold := u.repo.GetPremiumUserThreshold(userId)
    premiumUserBonus := u.repo.GetPremiumUserBonus(userId, len(orders))

    // totalPointsの算出処理
    ...

    return totalPoints
}

A, Bをテストする場合、gomockを使うのであればA, B内部のrepository層の関数をモックすると思います。
その場合、関数C内のrepository層の関数もモックで置き換える必要があります。

これが結構厄介で、単独処理/共通処理それぞれのモックを1つのテストに記述することになるため、関数の数が多い場合にどのモックがどの関数の処理内容なのか把握が難しくなります。

またA, Bが関数Cの処理詳細を知ることになってしまい、責務の分離としては正しくありません。

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

    mockRepo := mock_repository.NewMockUserRepository(ctrl)
    usecase := NewUserUsecase(mockRepo)

    userID := ulid.MustNew(ulid.Timestamp(time.Now()), ulid.DefaultEntropy())
    orders := []Order{{Amount: 1000}, {Amount: 2000}}
    now := time.Now()


    // どれが関数A,Cのロジックなのかわからない。さらに増えるとよりわからなくなる
    mockRepo.EXPECT().GetUserOrders(userID).Return(orders, nil)
    mockRepo.EXPECT().GetBasePointRate(now).Return(0.1)
    mockRepo.EXPECT().GetPremiumUserThreshold(userID).Return(5000)
    mockRepo.EXPECT().GetPremiumUserBonus(userID, len(orders)).Return(1000)

    points, err := usecase.GetUserPoints(context.Background(), "user1")

    assert.NoError(t, err)
    assert.Equal(t, int64(4000), points)
}

そのため可能であれば関数Cを単独でテストし、それを関数A、Bでモックして使いたいです。
そうすれば上記のような問題が解決すると考えられます。

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

    mockRepo := mock_repository.NewMockUserRepository(ctrl)
    mockUsecase := mock_usecase.NewMockUserUsecase(ctrl)
    usecase := NewUserUsecase(mockRepo)

    userID := ulid.MustNew(ulid.Timestamp(time.Now()), ulid.DefaultEntropy())
    orders := []Order{{Amount: 1000}, {Amount: 2000}}
    now := time.Now()

    mockRepo.EXPECT().GetUserOrders("user1").Return(orders, nil)
    // プライベート関数をモックする
    mockUsecase.EXPECT().calculatePoints(userID, orders)

    points, err := usecase.GetUserPoints(context.Background(), "user1")

    assert.NoError(t, err)
    assert.Equal(t, int64(4000), points)
}

これを実現するためにどうすればいいのか探っていきます。

案1. gomockを使ってモックする

gomockは名前のとおりモックを作成するライブラリで、各層のinterfaceに記述された関数をモックしてくれます。

しかし今回のケースだと関数Cはモックの作成対象になりません。
Go言語ではプライベート関数をinterfaceに含めることはできないため、gomockがモックを作成してくれることはありません。

この問題を直接的に解決するのであれば関数Cをパブリックに昇格させ、interfaceに定義する必要があります。
この時点でプライベートではなくなってしまうので、どうしても露出したくない関数は自前で定義する必要がありそうです。

案2. パブリック関数にしてgomockを使ってモックする

関数Cをパブリックに昇格させinterfaceを定義します。

type UserUsecase interface {
    ...
    CalculatePoints(orders []Order) int64
}

この状態でgo:generate mockgenを実行するとモックが作成されます。
先程のテストの共通関数を作成したモックに変更します。

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

    mockRepo := mock_repository.NewMockUserRepository(ctrl)
    mockUsecase := mock_usecase.NewMockUserUsecase(ctrl)
    usecase := NewUserUsecase(mockRepo)

    orders := []Order{{Amount: 1000}, {Amount: 2000}, {Amount: 3000}}

    mockRepo.EXPECT().GetUserOrders("user1").Return(orders)
    // 作成したモックを使用
    mockUsecase.EXPECT().CalculatePoints(orders)

    points, err := usecase.GetUserPoints(context.Background(), "user1")

    assert.NoError(t, err)
    assert.Equal(t, int64(4000), points)
}

これで解決かと思いきやUnexpected call to *repository.MockUserRepository.GetBasePointRate, because: there are no expected calls of the method "GetBasePointRate" for that receiverというエラーが発生してしまいました。
これはアプリ側でusecase.CalculatePoints内部のrepo.GetBasePointRateが呼び出されているにもかかわらず、テスト上ではその呼び出しが期待されていないことを示しています。
呼び出しを期待する場合は、mockRepo.EXPECT().GetBasePointRate()のようにEXPECT()メソッドを使用して明示的に記述する必要があります。したがって、このエラーを解決するにはこの記述を追加する必要があります。

このことからusecase層のモックを作成してusecase層で呼び出した場合、その内部で使用されるrepository層の関数までモックできないことが分かります。
同じ層内のモックは呼び出せないのでその1つ下の層のモックを呼び出す必要があるということになります。

解決策として関数をrepository層に移動させる方法も考えられますが、これは必ずしも最適な解決策ではないと考えています。
usecase層では複数のrepository層の関数を呼び出すことができるので、異なるrepositoryを参照している場合はusecase層に残さざるを得ないです。
単一のrepositoryを参照している場合repository層に移動させることもできますが、同じ層内のモックを呼び出すことはできないので移したところでrepository層のモックを使用したテストはできないと考えられます。

そう考えるとgomockの力を借りず、自前で定義する必要があるように思います。

案3. mock呼び出しを共通化する

もし自前で用意する場合、関数Cで呼び出されるすべてのモックを1つの関数として定義する方法もありそうです。
アプリ側と同様に共通処理のmock呼び出しを共通化する方法です。

この場合、関数の引数が呼び出したい関数の引数・戻り値の分だけ増えてしまいます。

func mockCalculatePoints(mockRepo *mock_repository.MockUserRepository, userID ulid.ULID, now time.Time, orderCount int, basePointRate float64, premiumThreshold int64, premiumBonus int64) {
    mockRepo.EXPECT().GetBasePointRate(now).Return(basePointRate)
    mockRepo.EXPECT().GetPremiumUserThreshold(userID).Return(premiumThreshold)
    mockRepo.EXPECT().GetPremiumUserBonus(userID, orderCount).Return(premiumBonus)
}

これを可読性の観点から問題ないと判断するのであればこの対応でも問題はなさそうです。

まとめ

共通処理関数のモック化についていくつかの方法を試してきましたが、下記課題が存在するため満足できる解決策を見つけることは難しいように感じました。

  1. gomockを使用する方法はプライベート関数をモック化できない
  2. その層のテストと同じ層のモックを作成しても呼び出せない
  3. モック呼び出しを共通化する方法は、関数の引数が増えすぎて可読性が低下する恐れがある

これらの方法にはそれぞれ長所と短所があるため、一部の共通処理をテストごとに重複させるなどプロジェクトの状況に応じて適切な妥協点を見つける必要がありそうです。
最適な解決策は重視するコード品質(可読性、保守性など)によっても異なるので、 プロジェクトの要件や優先事項など適切なバランスを取りながら検討する必要があると思います。

フィシルコム

Discussion