oinume journal

Scratchpad of what I learned

moq - gomockを使わないMock生成

Goでよく使われるMockの生成ツールとしてgomockがある1。個人的にはgomockが生成したコードでモックを書くのが好きではないので、代替としてmoqを使うやり方を取り上げてみようと思う。

なお、本記事ではgithub.com/golang/mockではなく、go.uber.org/mockを使うように 2024-06-11 に改訂している。[^1]

TL;DR

  • moqは、mockしたいinterfaceで定義されているメソッドと同じシグネチャのメソッドを生成し、そのメソッドを実装することでmockが作れる
    • 純粋にメソッドのモック実装のコードを書けばいいだけなので、gomockのようにライブラリとしての使い方を覚える必要はない
    • 生成されるコードもtype safeであり、gomockのようにanyは登場しない
  • moqには、mock化したメソッドが呼び出された回数を取得するなど、最低限のことはできるようになっている。
    • それ以上のことをやりたければ自分で実装する

題材

GitHubのAPIを使い指定したリポジトリのブランチを出力するServiceを考えてみる。このServiceには以下の引数owner, repoで指定されたリポジトリのブランチをwに出力するというメソッドを持っている。

PrintBranches(ctx context.Context, w io.Writer, owner, repo string) error

service.go

このServiceの実装コードは以下のようになっていて、githubClientを使ってGitHub APIを呼び出し引数のリポジトリのブランチの一覧を取得している。

github/client.go

次にServiceから参照されているgithub.Client interfaceについて説明する。これは以下のように純粋なinterfaceとして定義している。そして、ユニットテストではGitHub APIへのアクセスをモックにしたいので、このinterfaceが定義しているListBranchesを実装するモックをgo:generateで生成するようにしている。

package github

//go:generate moq -out=client.moq.go . Client
//go:generate mockgen -destination=client.gomock.go -package=github . Client

import "context"

type Client interface {
    ListBranches(ctx context.Context, owner, repo string) ([]string, error)
}

gomockを使ったテストコード

それではgomockを使ってService.PrintBranchesのテストコードを書いてみよう。以下のようにgithub/client.go にgo generateでmockgenを呼び出すコードを追加し、go generate ./mock/github を実行すると、./mock/github/client.mock.go が生成される。

//go:generate mockgen -destination=client.gomock.go -package=github . Client

そして、以下が実際にgomockが生成するMockを使って書いたservice.goのテストコードである(service_test.go)。

ctrl := gomock.NewController(t)
defer ctrl.Finish()
githubClient := github.NewMockClient(ctrl)
githubClient.
    EXPECT().
    ListBranches(context.Background(), "oinume", "playground-go").
    Return([]string{"main", "develop", "feature/a"}, nil)
s := Service{githubClient: githubClient}
out := new(bytes.Buffer)
if err := s.PrintBranches(context.Background(), out, "oinume", "playground-go"); err != nil {
    t.Fatal(err)
}
fmt.Printf("--- out ---\n%v\n", out.String())

注目して欲しいのは、以下のEXPECT()以降の部分。

mockGitHubClient.
    EXPECT().
    ListBranches(context.Background(), "oinume", "playground-go").
    Return([]string{"main", "develop", "feature/a"}, nil)

このEXPECT()が返すのは*MockClientMockRecorder型で、これに定義されているメソッドListBranchesのシグネチャはListBranches(arg0, arg1, arg2 interface{}) *gomock.Callになっている。これは本来のListBranchesのシグネチャとは異なるものになり、引数の型がinterface{}になっているため、オリジナルの引数の型であるstring以外の型も渡せてしまう。テストコードの実行には問題がないかもしれないが、本来であればClient interfaceに定義されている引数の型と同じにしておいた方が変な誤解を生まなくてすむと思う。

また、MockClientMockRecorder.ListBranchesが返す*gomock.Callに対してモックが返す値をReturn([]string{"main", "develop", "feature/a"}, nil)のようにセットする必要があるが、戻り値の数を間違えてもコンパイル時にはエラーにならず、実行時にエラーになることもイケてないと思う。

また、Mockのために以下のようなboilerplateのコードを毎回書くのも個人的には好きではない。俺はメソッドをmockしたいだけなのになぜこんなことをしなければならないのか

ctrl := gomock.NewController(t)
defer ctrl.Finish()
...

moqを使ったテストコード

では次に、moqが生成したコードとそれを使った場合のコードを見てみる。

まずmoqが生成したコードは以下のようになっている。

type ClientMock struct {
    // ListBranchesFunc mocks the ListBranches method.
    ListBranchesFunc func(ctx context.Context, owner string, repo string) ([]string, error)

    // calls tracks calls to the methods.
    calls struct {
        // ListBranches holds details about calls to the ListBranches method.
        ListBranches []struct {
            // Ctx is the ctx argument value.
            Ctx context.Context
            // Owner is the owner argument value.
            Owner string
            // Repo is the repo argument value.
            Repo string
        }
    }
    lockListBranches sync.RWMutex
}

// ListBranches calls ListBranchesFunc.
func (mock *ClientMock) ListBranches(ctx context.Context, owner string, repo string) ([]string, error) {
    if mock.ListBranchesFunc == nil {
        panic("ClientMock.ListBranchesFunc: method is nil but Client.ListBranches was just called")
    }
    callInfo := struct {
        Ctx   context.Context
        Owner string
        Repo  string
    }{
        Ctx:   ctx,
        Owner: owner,
        Repo:  repo,
    }
    mock.lockListBranches.Lock()
    mock.calls.ListBranches = append(mock.calls.ListBranches, callInfo)
    mock.lockListBranches.Unlock()
    return mock.ListBranchesFunc(ctx, owner, repo)
}

そして、上記のmoqが生成したClientMockを使うコードはこんな感じになる。

githubClient := &github.ClientMock{
    ListBranchesFunc: func(ctx context.Context, owner string, repo string) ([]string, error) {
        return []string{"main", "develop", "feature/a"}, nil
    },
}
s := Service{githubClient: githubClient}
out := new(bytes.Buffer)
if err := s.PrintBranches(context.Background(), out, "oinume", "playground-go"); err != nil {
    t.Fatal(err)
}

fmt.Printf("--- out ---\n%v\n", out.String())

moqの場合はモックしたいメソッドをstructの初期化時にセットする。これはただのメソッドなので、中身の実装は好きなように書けばよくて、引数の型や戻り値の型も元のClient interfaceと全く同じである。つまり、戻り値の数を間違えてもコンパイル時に検出してくれる。このmoqを使ったコードの方がboilerplateなコードもなく純粋にモックにフォーカスできるのではないかと思う。

まとめ

gomockではなくmoqを使うとモックを使ったテストコードがよりわかりやすく、かつ使い方を間違えた時の実行時のエラーもなくなることが伝わったのではないかと思う。個人的にはモック化する対象のメソッドが少なければ、moqを使わずにmoqが生成するような該当メソッドを置き換えるfuncを手で書くのもありだと思う。というわけで、gomock割といろんなところで使われているけどより良いツールがあるよ、という紹介でした。おしまい。


  1. 2023年1月時点で github.com/golang/mock がアーカイブされたため、go.uber.org/mock としてforkされたものが継続的に開発されているため