🕌

moqを使ったGoのテスト

2023/07/08に公開

はじめに

本記事では 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の紹介です。

https://github.com/matryer/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を利用したテストコードの例を紹介します。コード全体はこちらに置いています。

https://gist.github.com/abekoh/efbc5bd79090f5ea23c099a60b4633a3

まず、プロダクションコードはこちら。

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()が今回のテスト対象です。行っていることは

  1. ExternalAPIClient.FetchUser()idのユーザーを取得
  2. ユーザー名をnameに置き換え
  3. 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を利用してみることを一考してみるとよいかもしれません。

参考

脚注
  1. いわゆる単体テストの一流派である「古典学派」での考え方で、「ロンドン学派」ではモックを利用する対象をもう少し広げた考え方になります。『単体テストの考え方/使い方』では古典学派を推奨しています。 ↩︎

  2. Arrange-Act-Assertパターンとして扱われることもありますが、意味は同等です。筆者はSpockという、Given-When-Thenを構文に組み込んだテストフレームワークに慣れ親しんでいたためこちらを採用しています。 ↩︎

Discussion