Go言語 テスト用のモックを自動生成する (GoMock)

## はじめに

Go言語を用いた開発においては、コードの品質担保や予期せぬ修正の影響による不具合検知などのために、標準機能の go test で実行できる単体テストをあわせて書くことが多いと思います。

単体テスト実装に際しては、並列実行で短時間にテストを終わらせるためだったり、GitHub Actions や CircleCI のような限定的な環境で実行できるよう、ファイルの入出力や外部のAPIやDBサーバへの接続を行う処理などを、モックで代用して記述することが多いと思います。

インターフェイス定義などからモック処理を自動で生成してくれるツール
[GoMock](https://github.com/golang/mock) の導入と 生成したモックを用いたテストの書き方の紹介です

https://github.com/golang/mock

### 前提

以下の環境、知識を前提とします
- go 1.16以降 Go Modules を利用
- Go言語の基礎知識
- go test の基礎知識

## テスト対象のサンプルコード

※以下サンプルコードではプロジェクトのリポジトリURLを example.com/gotest (`go mod init example.com/gotest`を実行した)と仮定しています。適宜読み替えてください。

```go:entity/foo.go
package entity

// Foo entity
type Foo struct {
	ID    int64
	Value string
}
```

```go:repository/foo.go
package repository

import (
	"example.com/gotest/entity"
)

type FooRepository interface {
	FindByID(id int64) (*entity.Foo, error)
	Insert(foo *entity.Foo) error
	Update(foo *entity.Foo) error
}
```

実装は省略しています

```go:usecase/foo.go
package usecase

import (
	"example.com/gotest/entity"
	"example.com/gotest/repository"
)

type fooUseCase struct {
	fooRepo repository.FooRepository
}

func (p *fooUseCase) Save(id int64, value string) (*entity.Foo, error) {
	foo, err := p.fooRepo.FindByID(id)
	if err != nil {
		// 何らかのエラー
		return nil, err
	}
	if foo != nil {
		// 既存
		foo.Value = value
		if err := p.fooRepo.Update(foo); err != nil {
			return nil, err
		}
	} else {
		// 新規作成
		foo = &entity.Foo{ID: id, Value: value}
		if err := p.fooRepo.Insert(foo); err != nil {
			return nil, err
		}
	}
	// 再取得
	return p.fooRepo.FindByID(id)
}
```

## モック生成

### mockgen CLIのインストール （初回のみ）

モックを生成するCLIをインストールします

```
go install github.com/golang/mock/mockgen@v1.6.0
```

### モックコードの生成

以下の例では repository.FooRepository インターフェイスの実装のモックを生成させます

```
mockgen \
  -source repository/foo.go \
  -destination repository/mock/foo.go \
  -package mock
```

`-source`でモック化対象のソースファイルを指定
`-destination`で生成したモックを保存するソースファイル名、
`-package`で生成するモックのパッケージ名を指定

## テストの実装

```go:usecase/foo_test.go
package usecase

import (
	"reflect"
	"testing"

	"github.com/golang/mock/gomock"

	"example.com/gotest/entity"
	"example.com/gotest/repository/mock"
)

// TestFooUseCase_Save_NotExistsSuccess 新規保存のテストケース
func TestFooUseCase_Save_NotExistsSuccess(t *testing.T) {
	// 期待値
	testID := int64(123)
	testValue := "hoge"
	expectedSaved := &entity.Foo{
		ID:    testID,
		Value: testValue,
	}

	// モックセット
	ctrl := gomock.NewController(t)
	defer ctrl.Finish()

	fooRepoMock := mock.NewMockFooRepository(ctrl)
	fooRepoMock.EXPECT().FindByID(gomock.Eq(testID)).Return(nil, nil).Times(1)
	fooRepoMock.EXPECT().Insert(gomock.Eq(expectedSaved)).Return(nil)
	fooRepoMock.EXPECT().FindByID(gomock.Eq(testID)).Return(expectedSaved, nil).Times(1)

	// モックを注入して作成
	u := &fooUseCase{fooRepo: fooRepoMock}
	// 実行
	actualSaved, err := u.Save(testID, testValue)
	// 結果確認
	if err != nil {
		t.Errorf("予期せぬエラーが発生 %v", err)
	}
	if !reflect.DeepEqual(actualSaved, expectedSaved) {
		t.Errorf("保存結果が期待値と異なる\n期待:%+v\n実際:%+v", expectedSaved, actualSaved)
	}
}
```

#### 補足

##### import
gomockパッケージ "github.com/golang/mock/gomock" と
上記で生成したモックパッケージ "example.com/gotest/repository/mock" をimport

##### モック関数生成と期待値のセット
各テスト関数では gomock.NewController を呼び出してモックコントローラを初期化し、
戻り値(ctrl)を各モックの New関数 `NewMockモック化オブジェクト名(ctrl)`に渡してやる必要があります。

呼び出されることが期待されるすべてのモックの関数の引数の期待値(マッチ条件)と戻り値を設定する必要があります。
`モックオブジェクト.EXPECT().呼び出される関数(１つ目の引数の期待値, 2つ目の引数の期待値...).Return(モック関数が返す戻り値)`

`.Times(回数)`は呼び出される回数を限定、

同じモック関数が複数呼び出されるようなケースでは、
モック関数の呼び出し順序を厳密に指定する[gomock.InOrder関数](https://pkg.go.dev/github.com/golang/mock/gomock#InOrder)で囲んだり、
`.Return(戻り値)`の代わりに[.DoAndReturn(戻り値を返す関数)](https://pkg.go.dev/github.com/golang/mock/gomock#Call.DoAndReturn)で引数に応じた戻り値を返すことなどができます

#### テスト実行

```sh:
$ go test -v --cover ./...

=== RUN   TestFooUseCase_Save_NotExistsSuccess
--- PASS: TestFooUseCase_Save_NotExistsSuccess (0.00s)
PASS
coverage: 50.0% of statements
ok      example.com/gotest/usecase     0.264s  coverage: 50.0% of statements
```

無事に実行できました