moqを使ったGoのテスト
はじめに
本記事では matryer/moq というモックライブラリを利用したGoのテストのプラクティスについて紹介します。
moqはモック(狭義にはスパイ)・スタブの機能を持っており、その扱い方は非常にシンプルで直感的に扱えます。
その使い勝手に良さについて個人的にかなり気に入っており、実際にプロダクトのテストコードに利用しています。
一方で、moqはシンプルでユーティリティ的な側面が強いため、使い方を誤ると逆に管理しにくいテストコードになってしまいます。
本記事では『単体テストの考え方/使い方』という書籍を参考にしつつ、モック・スタブとしての役割に注意してmoqの使い方を提案してみます。
前提
- いわゆるGoらしさの追求はこだわらない方針とします。具体的には、テーブル駆動は一旦忘れ、stretchr/testify を積極的に使うこととします。応用すればそうしないパターンでもいい感じに書けるかもしれません。
- moq公式の推奨ではなく、あくまで本記事の筆者の考え方・使い方です。もしかしたら想定されていない利用方法かもしれません。
モックとスタブ
まず、簡単にモックとスタブの違いについて解説します。どちらもテストでしか使わない偽りの依存である「テスト・ダブル」という括りになりますが、役割は異なります。それの役割は次の通りです。
- モックはテスト対象から依存に、外部に向かうコミュニケーション(出力)を模倣する。
- スタブは依存からテスト対象に、内部に向かうコミュニケーション(入力)を模倣する。
(『単体テストの考え方/使い方』p.133 図5.2をもとに作成)
また、モックの役割を持つテスト・ダブルについて、より狭義には
- 外部への出力値(依存への入力値)の検証機能を持つものがモック (Mock Object)
- 記録のみを行い、後で参照できるようにしているのがスパイ (Test Spy)
ともされています。詳しくは以下の記事が参考になります。
xUnit Test PatternsのTest Doubleパターン(Mock、Stub、Fake、Dummy等の定義) - 千里霧中
このような区別があるものの、実際に世の中のモックと名乗るライブラリはモック・スタブ両方の面を持つことが多いです。
利用者としてはそれぞれの役割を意識してライブラリを利用することが良いテストを書く上で非常に大切になります。端的にまとめると、次のような使い分けが必要です。
- モックは最終的な出力結果を反映させる依存として利用し、呼び出し方を確認するのに用いる。
- モックを使って検証するのはメール送信の送信先・外部APIへのリクエストなど、制御下にないものが望ましい。もしこれらの例でも制御できるのであればモックを利用せず、実物を利用して検証すべき。[1]
- スタブは必要な値を返却するだけ。スタブへの入力の検証は行わないこと。
- 入力の検証は実装の詳細の確認で、観測可能な振る舞いの確認ではない。入力の検証をしてしまうことでリファクタリングへの耐性が落ちてしまう。
より詳細な使い方、注意点については『単体テストの考え方/使い方』にて詳細に述べられているのでぜひご一読ください。
moq
改めてmoqの紹介です。
moqはGoのinterface定義から、そのモック(スパイ)・スタブを生成してくれるCLIツールです。
例えば、次のようなinterfaceがあった場合
type ExternalAPIClient interface {
FetchUser(id int) (*User, error)
}
次のコマンドを実行すると、
moq -out main_moq.go . ExternalAPIClient
次のようなコードが生成されます。(コメントで補足を加えてます。)
// ExternalAPIClientMockはExternalAPIClientを実装
var _ ExternalAPIClient = &ExternalAPIClientMock{}
type ExternalAPIClientMock struct {
// FetchUser の振る舞いを再現する関数(スタブとして利用)
FetchUserFunc func(id int) (*User, error)
// FetchUser() 呼び出しを記録
calls struct {
FetchUser []struct {
ID int
}
}
// FetchUser() 呼び出しのレースコンディション対策のMutex
lockFetchUser sync.RWMutex
}
// FetchUser() の実装
func (mock *ExternalAPIClientMock) FetchUser(id int) (*User, error) {
// スタブ定義がなければエラー。コマンドで `-stub` オプションをつけるとpanicせずゼロ値が返却されるようになる
if mock.FetchUserFunc == nil {
panic("ExternalAPIClientMock.FetchUserFunc: method is nil but ExternalAPIClient.FetchUser was just called")
}
callInfo := struct {
ID int
}{
ID: id,
}
// 呼び出しを記録
mock.lockFetchUser.Lock()
mock.calls.FetchUser = append(mock.calls.FetchUser, callInfo)
mock.lockFetchUser.Unlock()
// スタブを実行
return mock.FetchUserFunc(id)
}
// FetchUser() の呼び出し記録を返却
func (mock *ExternalAPIClientMock) FetchUserCalls() []struct {
ID int
} {
var calls []struct {
ID int
}
mock.lockFetchUser.RLock()
calls = mock.calls.FetchUser
mock.lockFetchUser.RUnlock()
return calls
}
見ての通り、その中身は非常にシンプルです。使い勝手についての特徴は次の通り。
- スタブ定義が元のinterface定義の型情報を持つので、型安全でエディタでのスタブ実装がやりやすい
- スパイとしての記録も入力された値の型を持っておりアサーションするコードがGoの基本文法のみで書きやすい。呼び出し回数も
len(m.FetchUserCalls())
という感じで直感的に書ける。
moqを利用したテストの例
では、moqを利用したテストコードの例を紹介します。コード全体はこちらに置いています。
まず、プロダクションコードはこちら。
type User struct {
ID int
Name string
}
type ExternalAPIClient interface {
FetchUser(id int) (*User, error)
UpdateUser(user *User) error
}
type Service struct {
client ExternalAPIClient
}
func NewService(client ExternalAPIClient) *Service {
return &Service{client: client}
}
// UpdateUserName は与えられた id のユーザーのユーザー名を、与えられた name に更新する。
func (s Service) UpdateUserName(id int, name string) error {
user, err := s.client.FetchUser(id)
if err != nil {
return err
}
user.Name = name
return s.client.UpdateUser(user)
}
Service.UpdateUserName()
が今回のテスト対象です。行っていることは
-
ExternalAPIClient.FetchUser()
でid
のユーザーを取得 - ユーザー名を
name
に置き換え -
ExternalAPIClient.UpdateUser()
でユーザーを保存
ここでmoqにより生成したオブジェクトExternalAPIClientMock
をどう扱うかというと、
- 1.でスタブとして利用し、想定されるユーザーを返却させる。
- 3.でモックとして利用し、保存リクエストを記録・検証する。
という方針で使っていきます。
テストコードは次のようになります。いわゆるGiven-When-Thenパターン[2]で、「準備」「実行」「確認」に分けています。
func TestWithMoq(t *testing.T) {
// given
clientMock := &ExternalAPIClientMock{
// スタブとして、返却したい値を設定。interfaceの型定義で簡単に定義できる
FetchUserFunc: func(id int) (*User, error) {
return &User{ID: id, Name: "beforeName"}, nil
},
}
service := NewService(clientMock)
// when
err := service.UpdateUserName(1, "afterName")
// then
require.NoError(t, err)
// 呼び出し回数を検証
if assert.Len(t, clientMock.UpdateUserCalls(), 1) {
// Matcherとして、testifyのエコシステムが使える
assert.Equal(t, "afterName", clientMock.UpdateUserCalls()[0].User.Name)
}
}
このように、Given-When-Thenで分割して記述でき、何をどうテストしているかがわかりやすいコードになったと思います。
勿論実際のプロダクトのコードではこれより煩雑になると思われますが、Given-When-Thenパターンをできるだけ守っておくことである程度複雑性を抑えられると筆者は考えます。
gomockとの比較
最後に、同じテストコードをgomockを利用して書いた場合を見てみます。
func TestWithGoMock(t *testing.T) {
// given
ctrl := gomock.NewController(t)
defer ctrl.Finish()
clientMock := NewMockExternalAPIClient(ctrl)
// FetchUserのスタブを定義
clientMock.EXPECT().FetchUser(1).Return(
// Return()の引数はanyで、型安全に書けない
&User{ID: 1, Name: "beforeName"}, nil).
AnyTimes()
// UpdateUserは引数を検証したい。givenフェーズだけどここに検証が入る
clientMock.EXPECT().UpdateUser(gomock.Eq(&User{ID: 1, Name: "afterName"}))
service := NewService(clientMock)
// when
err := service.UpdateUserName(1, "afterName")
// then
require.NoError(t, err)
}
gomockはその生成オブジェクトへの入力値がany
となり、gomock定義のMatcherで柔軟な検証ができる一方で、
- スタブ定義を利用したいとき、型安全でない模倣実装を書かないといけない
- 準備(Given)フェーズでモックで検証したい項目を書かないといけないため、Given-When-Thenパターンが崩れてしまう
という弱点があります。
もちろんひとつのテスト流派を適用した際に生じる問題ではありますが、より広いテストコードパターンを実践するにはmoqのほうが使い勝手がよい、と筆者は考えます。
おわりに
先日、 gomock がPublic archiveとなったことが話題になりました。今後 uber/mock にてメンテナンスされていく見込みですが、これだけ広く使われたライブラリが閉じられてしまうのはやはり衝撃です。
本記事で扱ってきたように、gomockとmoqとでは考え方/使い方が大きく異なるものとなっています。
そのまま移行しよう!となるのは注意が必要ですが、moqを利用してみることを一考してみるとよいかもしれません。
参考
- 単体テストの考え方/使い方 | マイナビブックス
- xUnit Test PatternsのTest Doubleパターン(Mock、Stub、Fake、Dummy等の定義) - 千里霧中
- gomock と比較される moq,こいつはモックを作るライブラリじゃなくてスタブを作るだけの責務放棄したライブラリや!
Discussion