モチベーション
GoでDBやWebAPIを叩くテストを書く時にgomockを使うことがよくある。特にレイヤードアーキテクチャを採用した時にDB接続層とそれ以外のパッケージで並列にテストを実行したい時に便利。 しかしtable driven testと単純に合わせて使おうとするとmockの初期化周りでハマりがちなので普段どうテストを書いているか紹介したい。
gomockの使い方
table driven testはメジャーなので割愛するが、gomockについて少しだけ使い方の流れを紹介する。
gomock is 何
任意のinterfaceのmockを生成してくれるもの。mockが実装するinterfaceのメソッドはシグネチャさえ合っていれば任意の引数、返り値を取ることが出来る。mockがどのメソッドを呼ばれるかは事前に EXPECT()
で宣言する必要があり、実際にメソッドが呼ばれた時にその宣言が無ければテストがコケる仕組み。
内部的にmockはrecorderを持っており、EXPECT()
で事前にrecorderに呼び出し宣言をスタックしておき、テスト側からそのメソッドが呼ばれる時にそれがrecorderに存在するかチェックするみたいな感じに思われる。
- 例
DBからUserを引いてくるようなケースを考えてみる。DB接続は抽象化してmock化したいので、以下のようなinterfaceを定義する。
type User struct {
ID int
Name string
}
type UserRepository interface {
Get(id int) (*User, error)
}
type UserInteractor struct {
repository UserRepository
}
gomockを使うテストは以下のような感じで書ける。
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mock := NewMockUserRepository(ctrl)
mock.EXPECT().Get(1).Return(&User{ID: 1, Name: "foo"}, nil)
interactor := UserInteractor{
repository: mock,
}
// interactor内でrepositoryを使う処理を呼んだ時、`mock.EXPECT()`の内容が得られる
// もちろんエラーもシミュレート出来る
// mock.EXPECT().Get(1).Return(nil, sql.ErrNoRows)
gomockについて詳しくは以下の記事を見てもらいたい。
table driven testと組み合わせる
Goでテストを書く時table driven testを使うのは自然な流れだが、gomockを使う時は事前にEXPECT()
で挙動を宣言しておく必要がある。そこで単純にtable driven testとgomockを併用するとテストケース毎に挙動を定義することが難しい問題がある。
ctrl := gomock.NewController(t)
defer ctrl.Finish()
cases := []struct{
input int
expect string
}{...}
for _, c := range cases {
// テストケース毎にmockの挙動を変更出来ない!!
}
こういう場合はテストケースに初期化関数を含めてしまえば良い。
ctrl := gomock.NewController(t)
defer ctrl.Finish()
cases := []struct{
input int
expect string
prepare func(*MockUserRepository)
}{
1,
"foo",
func(m *MockUserRepository) {
m.EXPECT().Get(1).Return(&User{ID: 1, Name: "foo"}, nil)
},
}
for _, c := range cases {
// mockの初期化
mock := NewMockUserRepository(ctrl)
c.prepare(mock)
}
まとめ
gomockとtable driven testを組み合わせる時にテストケースでmockを初期化する手法について紹介した。gomockに限らず様々な関数でゴニョゴニョするのは便利でよく使ってしまうけど、使いすぎると可読性悪くなるので乱用厳禁という感じがする。