1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Goのモックライブラリを比較検討する

Last updated at Posted at 2024-08-10

概要

Goのモックライブラリはいくつか存在しており、検討を始めるにあたって Comparison of golang mocking librariesの内容が大変参考になった。

このうち候補として考えた gomock, mockery, moq をそれぞれ使ってテストコードを書いてみた所感を元に、比較検討した結果をまとめる。

はじめに:なぜモックライブラリが必要なのか

Goのテストコードで依存するインターフェースをモックする時、モックライブラリを使わずとも自前でモックを実装することは可能である。
例えば、以下のような GetUserService.Get() のテストコードを考えてみる。

type UserRepository interface {
	GetUser(userId string) (User, error)
}

type User struct {
}

type GetUserService struct {
	UserRepository UserRepository
}

// テスト対象のメソッド
func (s GetUserService) Get(userId string) (User, error) {
	user, err := s.UserRepository.GetUser(userId)
	if err != nil {
		return User{}, err
	}
	return user, nil
}

テストケースは2パターンある。

  1. UserRepository.GetUser()がエラーを返す時
  2. UserRepository.GetUser()がエラーを返さない時

これを実現するには、UserRepositoryインターフェースを満たすモックを実装して、GetUserService のフィールドに設定すれば良い。

// インターフェースのモック
type MockUserRepository struct {
	hasError bool
}

// モック対象のメソッド。hasErrorフィールドの値を見て戻り値を出し分けている
func (m MockUserRepository) GetUser(userId string) (User, error) {
	if m.hasError {
		return User{}, errors.New("error")
	}
	return User{name: "test"}, nil
}

func Test_GetUserServiceのGetが正常にUserを返すこと(t *testing.T) {
	// エラーを返さないモックを作成
	sut := GetUserService{UserRepository: MockUserRepository{}}
	user, err := sut.Get("1")
	if err != nil {
		t.Errorf("error=%v", err)
	}
	if user != (User{name: "test"}) {
		t.Errorf("user=%v", user)
	}
}

func Test_GetUserServiceのGetがエラーを返すこと(t *testing.T) {
	// エラーを返すモックを作成
	sut := GetUserService{UserRepository: MockUserRepository{hasError: true}}
	user, err := sut.Get("1")
	if err == nil {
		t.Errorf("error=%v", err)
	}
	if user != (User{}) {
		t.Errorf("user=%v", user)
	}
}

自前モックの課題

1つ目は想定する戻り値が得られるように自分で実装しなければならない点。
上記例では、UserRepositoryのGetUser()の結果を出し分けるのに、MockUserRepositoryのGetUser()を実装しなければならない。
モックライブラリを使うと、モックの実装を自ら用意せずともメソッドコールされた時の戻り値を自由に設定することが可能なものがある。

2つ目はインターフェースにメソッドが追加された時、自分でモックに反映する必要がある点。
例えば UserRepositoryにRegisterUser()が追加された場合、MockUserRepositoryにもRegisterUser()を自分で追加しなければならない。
モックライブラリを使うと、モック生成時にインターフェースの定義をモックに自動反映してくれてこの辺が楽になる。

さらに、モックライブラリを使うと上記のような辛さを和らげられるだけでなく、メソッド引数のアサートやメソッド呼び出し回数のアサートなどの機能を簡単に使うことができる。

このようなことがモックライブラリを導入する動機である。

使い方の確認

gomock, mockery, moqの使い方をそれぞれ簡単にまとめる。

gomockの使い方

まずはモック生成コマンドであるmockgenをインストールする。

go install go.uber.org/mock/mockgen@latest

モック対象のインターフェースが定義されているファイルのコメントに mockgenコマンドを記載する。
go:generateを付与しておくと、go generateコマンドを実行した時に記載の mockgenコマンドを実行してくれる。

//go:generate mockgen -source=$GOFILE -package=$GOPACKAGE -destination=./mock_$GOFILE

package service

type GetUserService struct {
	UserRepository UserRepository
}

func (s GetUserService) Get(userId string) (User, error) {
	user, err := s.UserRepository.GetUser(userId)
	if err != nil {
		return User{}, err
	}
	return user, nil
}

type UserRepository interface {
	GetUser(userId string) (User, error)
}

type User struct {
	name string
}

生成されるモックは以下のようになる(長いため一部のみ抜粋)

package service

import (
	reflect "reflect"
	gomock "go.uber.org/mock/gomock"
)

// MockUserRepository is a mock of UserRepository interface.
type MockUserRepository struct {
	ctrl     *gomock.Controller
	recorder *MockUserRepositoryMockRecorder
}

// MockUserRepositoryMockRecorder is the mock recorder for MockUserRepository.
type MockUserRepositoryMockRecorder struct {
	mock *MockUserRepository
}

// NewMockUserRepository creates a new mock instance.
func NewMockUserRepository(ctrl *gomock.Controller) *MockUserRepository {
	mock := &MockUserRepository{ctrl: ctrl}
	mock.recorder = &MockUserRepositoryMockRecorder{mock}
	return mock
}

生成したモックを使って次のようなテストコードを書くことができる。ポイントは

  • 最初にgomock.NewController(t)でコントローラを生成する
  • 自動生成されたコンストラクタ(NewMockUserRepository)を使ってモックを生成する
  • mock.EXPECT().GetUser("1")で呼び出される想定のメソッドと引数を設定する
  • Return()で戻り値を設定する
  • Times()で想定される呼び出し回数を設定する
func Test_Gomock_GetUserServiceのGetが正常にUserを返すこと(t *testing.T) {
	// given
	// コントローラの生成
	ctrl := gomock.NewController(t)
	// モックの生成
	mock := NewMockUserRepository(ctrl)
	// モックの振る舞いを定義
	mock.EXPECT().GetUser("1").Return(User{name: "test"}, nil).Times(1)
	// テスト対象の生成時にモックをセット
	sut := GetUserService{UserRepository: mock}
	// when
	user, err := sut.Get("1")
	// then
	if err != nil {
		t.Errorf("error=%v", err)
	}
	if user != (User{name: "test"}) {
		t.Errorf("user=%v", user)
	}
}

また、モックメソッドが複数回呼ばれる時、それぞれの振る舞いを変えるには次のように書ける。

// GetUserが引数 "1" で呼ばれた時は `User{name: "test"}, nil` を返す
mock.EXPECT().GetUser("1").Return(User{name: "test"}, nil).Times(1)
// GetUserが引数 "2" で呼ばれた時は `User{name: "test2"}, nil` を返す
mock.EXPECT().GetUser("2").Return(User{name: "test2"}, nil).Times(1)

Reflect mode

gomockの実行モードにはSource modeとReflect modeがある(参考)。
上述の例は -source オプションをつけているためSource modeであり、ファイル内に定義された全てのインターフェースのモックが生成される。

ファイル内でモックしたいインターフェースを個別指定するには、Reflect modeを使う。
Reflect mode でモックを生成するには、次のようにパッケージパス(.)とモック対象のインターフェース名をカンマ区切りで渡す(UserRepository,UserRepository2)。

//go:generate mockgen -package=$GOPACKAGE -destination=./mock_$GOFILE . UserRepository,UserRepository2

type UserRepository interface {
	GetUser(userId string) (User, error)
}

type UserRepository2 interface {
	GetUser2(userId string) (User, error)
}

type UserRepository3 interface {
	GetUser3(userId string) (User, error)
}

上記コマンドが実行されると、UserRepository・UserRepository2のモックは作成されるが、UserRepository3のモックは作成されない。

mockeryの使い方

インストール方法はいくつかあるので環境要件に従って行う(ドキュメント)。
ここでは go installを使ってmockeryをインストールする。

go install github.com/vektra/mockery/v2@v2.43.2

モック生成の設定ファイルである.mockery.yamlをルートディレクトリに作成し、モック対象のパッケージパスやインターフェース名を記載する。

.mockery.yaml
with-expecter: true
packages:
  # モック対象のパッケージパス
  your-go-project/service:
    interfaces:
      # モック対象のインターフェース名
      UserRepository:

プロジェクトルートで次のコマンドを実行するとモックが生成される。

mockery --all --keeptree
生成されたモック(一部抜粋)
// Code generated by mockery v2.43.2. DO NOT EDIT.

package service

import (
	mock "github.com/stretchr/testify/mock"
)

// MockUserRepository is an autogenerated mock type for the UserRepository type
type MockUserRepository struct {
	mock.Mock
}

type MockUserRepository_Expecter struct {
	mock *mock.Mock
}

func (_m *MockUserRepository) EXPECT() *MockUserRepository_Expecter {
	return &MockUserRepository_Expecter{mock: &_m.Mock}
}

生成したモックを使って次のようなテストコードを書くことができる。gomockと同じように

  • mock.EXPECT().GetUser("1") で呼び出される想定のメソッドと引数を設定する
  • Return()で戻り値を指定する
  • Times()で想定される呼び出し回数を設定する
func Test_Mockery_GetUserServiceのGetが正常にUserを返すこと(t *testing.T) {
	// given
	// モックの生成
	mock := NewMockUserRepository(t)
	// モックの振る舞いを定義
	mock.EXPECT().GetUser("1").Return(User{name: "test"}, nil).Times(1)
	// テスト対象の生成時にモックをセット
	sut := GetUserService{UserRepository: mock}
	// when
	user, err := sut.Get("1")
	// then
	if err != nil {
		t.Errorf("error=%v", err)
	}
	if user != (User{name: "test"}) {
		t.Errorf("user=%v", user)
	}
}

注意点として、Times(1)で1度呼ばれることをアサートしているが、呼ばれなくてもテストは失敗しない。2回以上呼ばれる場合にテストが失敗する。

また、モックメソッドが複数回呼ばれる時、それぞれの振る舞いを変えるには次のように書ける。

// GetUserが引数 "1" で呼ばれた時は `User{name: "test"}, nil` を返す
mock.EXPECT().GetUser("1").Return(User{name: "test"}, nil).Times(1)
// GetUserが引数 "2" で呼ばれた時は `User{name: "test2"}, nil` を返す
mock.EXPECT().GetUser("2").Return(User{name: "test2"}, nil).Times(1)

moqの使い方

次のようにmoqをインストールする(ドキュメント

$ go install github.com/matryer/moq@latest

モック対象のインターフェースが定義されているファイルのコメントに moqコマンドを記載する。

//go:generate moq -out ./moq_$GOFILE . UserRepository

package service

type GetUserService struct {
	UserRepository UserRepository
}

func (s GetUserService) Get(userId string) (User, error) {
	user, err := s.UserRepository.GetUser(userId)
	if err != nil {
		return User{}, err
	}
	return user, nil
}

type UserRepository interface {
	GetUser(userId string) (User, error)
}

type User struct {
	name string
}

プロジェクトルートで go generate ./... を実行すると以下のようにモックが生成される(一部抜粋)

var _ UserRepository = &UserRepositoryMock{}

type UserRepositoryMock struct {
	// GetUserFunc mocks the GetUser method.
	GetUserFunc func(userId string) (User, error)

	// calls tracks calls to the methods.
	calls struct {
		// GetUser holds details about calls to the GetUser method.
		GetUser []struct {
			// UserId is the userId argument value.
			UserId string
		}
	}
	lockGetUser sync.RWMutex
}

// GetUser calls GetUserFunc.
func (mock *UserRepositoryMock) GetUser(userId string) (User, error) {
	if mock.GetUserFunc == nil {
		panic("UserRepositoryMock.GetUserFunc: method is nil but UserRepository.GetUser was just called")
	}
	callInfo := struct {
		UserId string
	}{
		UserId: userId,
	}
	mock.lockGetUser.Lock()
	mock.calls.GetUser = append(mock.calls.GetUser, callInfo)
	mock.lockGetUser.Unlock()
	return mock.GetUserFunc(userId)
}

func (mock *UserRepositoryMock) GetUserCalls() []struct {
	UserId string
} {
	var calls []struct {
		UserId string
	}
	mock.lockGetUser.RLock()
	calls = mock.calls.GetUser
	mock.lockGetUser.RUnlock()
	return calls
}

生成したモックを使って次のようなテストコードを書くことができる。ポイントは

  • モックの振る舞いを関数として実装すること。モックのGetUser()が呼ばれた時、内部的にはGetUserFuncが呼び出されるようになっており、指定した戻り値を返すようにGetUserFuncを自ら実装する。
  • メソッド呼び出し時の情報は自動生成されたメソッド(GetUserCalls)から返されるので、その値を使って呼び出し回数や渡された引数のアサートを自ら実装する。
func Test_Moq_GetUserServiceのGetが正常にUserを返すこと(t *testing.T) {
	// given
	// モックの生成、モックの振る舞いを定義
	mock := &UserRepositoryMock{
		GetUserFunc: func(userId string) (User, error) {
			return User{name: "test"}, nil
		},
	}
	// テスト対象の生成時にモックをセット
	sut := GetUserService{UserRepository: mock}
	// when
	user, err := sut.Get("1")
	// then
	if err != nil {
		t.Errorf("error=%v", err)
	}
	if user != (User{name: "test"}) {
		t.Errorf("user=%v", user)
	}
	if len(mock.GetUserCalls()) != 1 {
		t.Errorf("got call count = %v", mock.GetUserCalls())
	}
	if mock.GetUserCalls()[0].UserId != "1" {
		t.Errorf("got arg = %v", mock.GetUserCalls()[0].UserId)
	}
}

比較結果

使った感触を比較した結果をまとめる。

uber-go/mock gomock vektra/mockery matryer/moq
GitHub stars 1.9k 5.8k 1.9k
最新リリース日 2023/12/21 2024/5/29 2024/2/11
メンテナンスされているか
インストールが容易か
モックが自動生成されるか
特定のインターフェースのみモック生成可能か
メソッド引数のアサート
自前でアサートロジックを作成する必要あり
呼び出し回数のアサート
指定した呼び出し回数未満の場合でもテストが通ってしまう
同一メソッドを引数違いで複数呼び出す際に異なる振る舞いを設定できるか ×
機能として提供されていない

今回の比較項目だとgomockに全て丸がついていて優秀だと捉えられる。
一方で、moqは他と比べて機能が少ないように見えるが、これは薄いライブラリになっているためであり内部実装を把握しやすいことが逆に利点と受け取ることができる。
実際に導入する際は、モックライブラリに求める観点を明確にすると自分たちに合ったものを選定できると思う。

1
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?