Recommendその他

【Go言語】 gomock モックメソッドの指定方法まとめ

Recommend
この記事は約12分で読めます。

Go 言語でモックを使ったテストコードを書く際、gomock を使用することが多いかと思います。ここでは、gomock の使用時に欠かせない、モックメソッドの挙動指定方法についてまとめます。

はじめに

gomock を使うと、以下のようなメソッドチェーンにより、テストメソッド内に存在するモックメソッドの挙動をコントロールできます。

モック化させるオブジェクト.EXPECT().モックメソッド(引数1, 引数2, …).Return(戻り値1, 戻り値2, …)
  • 「引数」:モックメソッド実行時に渡すことが想定される値
  • 「戻り値」:モックメソッド実行後に返却する値

例えば以下のケースで、 IsExistEntity() のテストを行いたい場合、

package app

type Entity struct {
    Id       int
    Name     string
    Category int
}

//go:generate mockgen -source=$GOFILE -package=mock -destination=mock/$GOFILE
type EntityRepository interface {
	ListEntities() ([]Entity, error)
	GetEntity(int) (*Entity, error)
	CreateEntity(string, int) error
	UpdateEntity(*Entity) error
	DeleteEntity(int) error
}

type Service struct {
	EntityRepo EntityRepository
}

func (s *Service) IsExistEntity(id int) (bool, error) {
	e, err := s.EntityRepo.GetEntity(id)
	if err != nil {
		return false, err
	}

	return e != nil, nil
}

テスト関数は以下のように記述します。

// ID が 1 であるレコードが存在する
func TestIsExistEntity(t *testing.T) {
	ctrl := gomock.NewController(t)
	defer ctrl.Finish()

	// モックメソッドの挙動を指定
	repoMock := mock.NewMockEntityRepository(ctrl)
	repoMock.EXPECT().GetEntity(1).Return(&app.Entity{1, "dummy", 0}, nil)

	// モックを注入
	srv := app.Service{repoMock}

	// テストメソッドを実行
	ok, err := srv.IsExistEntity(1)
	if err != nil {
		t.Errorf("予期せぬエラー: %v", err)
	} else if !ok {
		t.Errorf("期待: %v, 実際: %v", true, ok)
	}
}

以上は、モックメソッド GetEntity() がテスト関数 TestIsExistEntity() 内で一回呼ばれるという単純な例です。しかしモックメソッドが複数回呼ばれ、その呼び出し毎に引数・戻り値が変化する場合などに追加の考慮が必要となります。
本記事では、そのような「考慮」が必要となる方に向け、gomock が用意してくれているメソッドの使い方をまとめます。

なお、引き続き上記のサンプルコードを用います。

各種メソッド

さて、本題です。

最初にご紹介した、「モック化させるオブジェクト.EXPECT().モックメソッド(引数1, 引数2, ...) 」につなげられるメソッドは以下です(詳しくは公式ドキュメントを見てください)。

メソッド名役割・用途
Times呼び出し回数を明示する
AnyTimes呼び出し回数を指定しない
MinTimesMaxTimes呼び出し回数の下限、上限を指定する
After呼び出される順番を指定する
DoAndReturn処理を行い、戻り値を返す。
DoDoAndReturn の、「戻り値」が不要な場合に使う
ReturnDoAndReturn の、「処理」が不要な場合に使う
SetArgポインタで指定された引数の値を変えたい場合に使う

これらを順番に解説していきます。

〜Times (Times, AnyTimes, MinTimes, MaxTimes)

呼び出し回数を指定します。指定する場合の注意点は以下です。

  • デフォルトの場合、 times(1) と同義になる
  • モックメソッドが呼ばれない場合でも、Times(0) を指定しないとエラーになる

例えば、以下の CreateOrUpdateEntity() にて、エンティティの更新のみが行われるテストしたい場合、

func (s *Service) CreateOrUpdateEntity(name string, cat int) error {
	entities, err := s.EntityRepo.ListEntities()
	if err != nil {
		return err
	}

	for _, entity := range entities {
		if entity.Name == name {
			err := s.EntityRepo.UpdateEntity(&Entity{entity.Id, name, cat})
			if err != nil {
				return err
			}
			return nil
		}
	}

	err = s.EntityRepo.CreateEntity(name, cat)
	if err != nil {
		return err
	}

    return nil
}

テスト関数は以下のように記述します。

// ListEntities() の戻り値に、指定した name を持つレコードが存在するため、category を更新し、CreateEntity() は行わない
func TestCreateOrUpdateEntity(t *testing.T) {
	ctrl := gomock.NewController(t)
	defer ctrl.Finish()

	stubList := []app.Entity{
		{1, "dummy1", 1},
		{2, "dummy2", 2},
		{3, "dummy3", 3},
	}
	repoMock := mock.NewMockEntityRepository(ctrl)
	repoMock.EXPECT().ListEntities().Return(stubList, nil).Times(1) // Times(1) は省略可
	repoMock.EXPECT().UpdateEntity(&app.Entity{2, "dummy2", 200}).Return(nil)
	repoMock.EXPECT().CreateEntity(gomock.Any(), gomock.Any()).Times(0) // gomock.Any() は引数の期待値を未指定にする

	srv := app.Service{repoMock}
	err := srv.CreateOrUpdateEntity("dummy2", 200)
	if err != nil {
		t.Errorf("予期せぬエラー: %v", err)
	}
}

After

After() は、モックメソッドが呼ばれる順番を指定します。
例えば、以下のメソッドをテストしたい場合、

func (s *Service) DeleteEntities(ids []int) error {
	for _, id := range ids {
		err := s.EntityRepo.DeleteEntity(id)
		if err != nil {
			return err
		}
	}

	return nil
}

テスト関数は以下のように記述します。

// ID が 1, 2, 3 であるレコードを順に削除する
func TestDeleteEntities(t *testing.T) {
	ctrl := gomock.NewController(t)
	defer ctrl.Finish()

	repoMock := mock.NewMockEntityRepository(ctrl)
	repoMock.EXPECT().DeleteEntity(3).Return(nil).After(
		repoMock.EXPECT().DeleteEntity(2).Return(nil).After(
			repoMock.EXPECT().DeleteEntity(1).Return(nil),
		),
	)
	// 以下と同義です
	// gomock.InOrder(
	// 	repoMock.EXPECT().DeleteEntity(1).Return(nil),
	// 	repoMock.EXPECT().DeleteEntity(2).Return(nil),
	// 	repoMock.EXPECT().DeleteEntity(3).Return(nil),
	// )
	// 以下でも PASS します
	// repoMock.EXPECT().DeleteEntity(gomock.Any()).Return(nil).Times(3)

	srv := app.Service{repoMock}
	err := srv.DeleteEntities([]int{1, 2, 3})
	if err != nil {
		t.Errorf("予期せぬエラー: %v", err)
	}
}

DoAndReturn (Do, Return)

これまで見たきたとおり、Return() では、複数回の呼び出しがある場合でも、戻り値が固定されたままです。
呼び出し毎に、引数に応じて戻り値を変化させたい場合には DoAndReturn() を使います。
以下のメソッドをテストしたい場合、

func (s *Service) GetEntities(ids []int) ([]Entity, error) {
	var entities []Entity
	for _, id := range ids {
		entity, err := s.EntityRepo.GetEntity(id)
		if err != nil {
			return nil, err
		} else if entity == nil {
			continue
		}
		entities = append(entities, *entity)
	}

	return entities, nil
}

テスト関数は以下のように記述します。

// ID が 2 となるエンティティは存在しない
func TestGetEntities(t *testing.T) {
	ctrl := gomock.NewController(t)
	defer ctrl.Finish()

	want := []app.Entity{
		{1, "dummy1", 1},
		{3, "dummy3", 3},
	}

	repoMock := mock.NewMockEntityRepository(ctrl)
	repoMock.EXPECT().GetEntity(gomock.Any()).DoAndReturn(
		// 無名関数の引数と戻り値はモックメソッドに揃える
		func(id int) (*app.Entity, error) {
			switch id {
			case 1:
				return &app.Entity{1, "dummy1", 1}, nil
			case 2:
				return nil, nil
			case 3:
				return &app.Entity{3, "dummy3", 3}, nil
			}
			return nil, nil
		},
	).AnyTimes()

	srv := app.Service{repoMock}
	got, err := srv.GetEntities([]int{1, 2, 3})
	if err != nil {
		t.Errorf("予期せぬエラー: %v", err)
	} else if !reflect.DeepEqual(want, got) {
		t.Errorf("期待: %v, 実際: %v", want, got)
	}
}

DoAndReturn()Do() では、任意の処理を埋め込むことができます。しかし(メソッド内部で、メソッドのスコープを超えて影響を与える実装になることは少ないため)、引数によって戻り値を変化させたい場合に使うのが主だと思います。

SetArg

モックメソッドに渡す引数をポインタ経由で書き換えたい場合に SetArg() を使います。SetArg(n, value) の使い方としては以下です。

  • 第一引数 n には、モックメソッドの引数の番号を指定する(例: n=1 は、モックメソッドの第一引数を意味します)
  • 第二引数 value には、書き換え後の値を指定する(値を示すポインタではなく、値を指定します)


以下の例 UpdateEntity() で考えます。

func (s *Service) UpdateEntity(e *Entity) error {
	err := s.EntityRepo.UpdateEntity(e)
	if err != nil {
		return err
	}

	return nil
}

ここで、(やや強引ですが、) s.EntityRepo.UpdateEntity() の内部処理として以下を仮定します。

  • 指定された category が大きな数値である場合、値 0 で更新する


その場合、テスト関数は以下のように記述します。

// UpdateEntity() 実行後、category が 100 から 1 に書き変わる
func TestUpdateEntity(t *testing.T) {
	ctrl := gomock.NewController(t)
	defer ctrl.Finish()

	entity := &app.Entity{1, "dummy", 100}
	want := &app.Entity{1, "dummy", 0}

	repoMock := mock.NewMockEntityRepository(ctrl)
	repoMock.EXPECT().UpdateEntity(gomock.Any()).SetArg(0, app.Entity{1, "dummy", 0})

	srv := app.Service{repoMock}
	err := srv.UpdateEntity(entity)
	if err != nil {
		t.Errorf("予期せぬエラー: %v", err)
	} else if !reflect.DeepEqual(entity, want) {
		t.Errorf("期待: %v, 実際: %v", want, entity)
	}
}

なお筆者個人の見解としては、 SetArg() を使う機会は多くはないかと思います。引数の書き換えを行うより、EntityRepository.UpdateEntity() の戻り値として更新後のオブジェクトを返した方が安全なためです。

さいごに

本記事では、gomock モックメソッドの挙動指定についてピックアップしました。
初投稿の記事でしたが、みなさまの開発の一助となれば幸いです!