knqyf263's blog

自分のためのメモとして残しておくためのブログ。

stretchr/testify/mockでTable Driven Testしやすいようにmockeryを拡張した

アドベントカレンダー用に書こうと思っていたのですが乗り遅れました。あとあんまり大衆向けの話でもないのでひっそりと公開しておきます。

良いやり方を調べても見つからない時は自分の思いつく最良の方法を公開すると誰か凄い人がやってきてより良いやり方を教えてくれたりするので、今回もそれを期待しています。

課題

stretchr/testifyにはテストするために使える便利なパッケージが複数含まれていますが、その中にmockがあります。
https://github.com/stretchr/testify

これを使うと指定した引数に対する戻り値を簡単に定義できます。例えば会社名入れたら会社情報を返してくれるstructのmockに対する引数と戻り値は以下のように書けます。

mockCompany.On("Info", "FooCompany").Return("101-0001", "東京都千代田区", "03-3000-0000", "2000")

上の場合だとinterfaceは以下のような感じです

type Company interface {
    Info(string) (string, string, string, int)
}

思いつきで作ったので、このInterfaceなんなんみたいなのは置いておいて下さい。

ちなみにmockはこれ以外にもCallで関数呼び出したりとか、色々な機能があるので普段テストでそれらを使う機会の方が多い人はここで記事を閉じてもらったほうが良さそうです。自分もCallTimesたまに使うのですが、多くのケースでは上のシンプルな使い方で十分な場合が多いです。なので、今回は上のmock.On().Return()の形に絞った話になります。

テスト用のstructを用意する

これをTable Driven Testで使おうとすると、テストケース内で上の引数・戻り値を定義したくなります。そしてその時に専用のstructがあると便利です。MockCompanyは先に作っておく必要がありますが、それはググればたくさん使い方出るので省略します。MockCompanyは作成済みということで進めます。

// mockが受け取る引数
type InfoArgs struct {
    CompanyName string
}

// mockが返す戻り値
type InfoReturns struct {
    Zip      string
    Address  string
    Phone    string
    Employee int
}

// 上の2つをまとめたstruct
type InfoExpectation struct {
    Args    InfoArgs
    Returns InfoReturns
}

こんな感じでstructを定義しておけば、テストケースを定義する時に綺麗に書けます。補完も効くしサクサク書けて気持ち良いです。意味的にも分かりやすいです(個人的には)。

testCases = []struct {
    name string
    info InfoExpectation
}{
    {
        name: "happy path",
        info: InfoExpectation{
            Args: InfoArgs{
                CompanyName: "FooCompany",
            },
            Returns: InfoReturns{
                Zip:      "101-0001",
                Address:  "東京都千代田区",
                Phone:    "03-3000-0000",
                Employee: 2000,
            },
        },
    },
}

あとはこれをfor文の中でOnとReturnに渡せばよいです。

for _, tc := range testCases {
    t.Run(tc.name, func(t *testing.T) {
        mockCompany := new(MockCompany)
        mockCompany.On("Info", tc.info.Args.CompanyName).Return(tc.info.Returns.Zip,
            tc.info.Returns.Address, tc.info.Returns.Phone, tc.info.Returns.Employee)
        ...

これで複数のテストケースでも対応できて便利なのですが、問題は上のstruct達をいちいち作るのがだるすぎるということです。導入が若干長くなりましたがこれが自分の課題でした。

Sliceを使ってみる

Onはinterfaceを渡せるので、以下のような方法も考えたりしました。

type InfoExpectation struct {
    Args    []interface{}
    Returns []interface{}
}

func TestCompany_Info() {
    testCases = []struct {
        name string
        info InfoExpectation
    }{
        {
            name: "happy path",
            info: InfoExpectation{
                Args:    []interface{}{"FooCompany"},
                Returns: []interface{}{"101-0001", "東京都千代田区", "03-3000-0000", 2000},
            },
        },
    }
    for _, tc := range testCases {
        t.Run(tc.name, func(t *testing.T) {
            mockCompany := new(MockCompany)
            mockCompany.On("Info", tc.info.Args...).Return(tc.info.Returns...)
        })
    }
}

[]interface{}で全部受け取れるようにしてOnとReturnに渡す方法です。試した時のコード消しちゃったので上ので動くかわかりませんがイメージが伝わればよいです。これを試してみて思ったのは、何番目が何の値とか覚えてられないです。例えば住所と電話番号逆にしてもすぐには気づけないですし、型も分からないので郵便番号ってstringだったっけ...?みたいになって辛いです。あとintやstringなどのプリミティブ型だけでなく別のstructを渡したい時も不便です。

mapを使ってみる

せめて名前ぐらいは知りたいということでmapにしてみたりもしました。

func TestCompany_Info() {
    testCases = []struct {
        name string
        info InfoExpectation
    }{
        {
            name: "happy path",
            info: InfoExpectation{
                Args: map[string]interface{}{
                    "CompanyName": "FooCompany",
                },
                Returns: map[string]interface{}{
                    "Zip":      "101-0001",
                    "Address":  "東京都千代田区",
                    "Phone":    "03-3000-0000",
                    "Employee": 2000,
                },
            },
        },
    }
    ...
}

ですが、これも結局keyがただの文字列なのでtypoとかすると終了します。補完も効かないので自分で名前を調べて打つ必要があって人類がやるべきことではないなと感じました。

などと考えると一番最初のように専用のstructがある方が補完も出来るし名前付いてるから分かりやすいし一番良いなーとなりました。もし他に良い方法をご存じの方は可及的速やかに教えていただきたいです。

stretchr/testify は有名なツールだし他のOSSで既に同じことやってるだろうと思って調べたのですが、意外とみんなTable Driven Testしてませんでした。特にmockとか使うようなテストは割と手続き的に書いてました。自分が見つけられなかっただけで良い方法で解決してるOSSがあればどなたか教えて下さい。

Goの流儀に則るならやはりコード生成だろう、ということでstructを自動生成することにしました。

ツール

上のようなstructを作ってくれるツールを探したのですが見つけられませんでした。なので作りました。既存のvektra/mockeryというinterfaceを見つけてMockを作ってくれるツールがあったので、それをforkして作りました。
https://github.com/knqyf263/mockery

upstreamにパッチ送らないのかという話がありますが、残念ながらメンテナンスが止まっているようです。
https://github.com/vektra/mockery/issues/237

ソースコードを読んだのですが難しいことはしていなかったですし、自分で使う範囲なら十分メンテナンスしていけそうだったのでforkすることにしました。特に他の人に使ってもらおうと思って作っておらず、自分の使う範囲で動くように拡張しています。なのでオプション次第では動かないかもしれません。ただupstrean自体が既にバグあったりしてfork内で直したりもしました。

2時間ぐらいで作ったので全く動作保証はできないです。追加したかった機能とは別のupstreamのバグの調査に時間かかったのでもう満足してしまいました。

では実際に使ってみます。オプションの意味はヘルプを見てもらえればと思いますが、以下のように打つとサブディレクトリも含めinterfaceを探してきて同じパッケージ内にMockを作ります。

$ mockery -all -inpkg -case=snake

本家mockeryは以下のようなMockを生成します。

// MockCompany is an autogenerated mock type for the Company type
type MockCompany struct {
    mock.Mock
}

// Info provides a mock function with given fields: _a0
func (_m *MockCompany) Info(_a0 string) (string, string, string, int) {
    ret := _m.Called(_a0)
    ...
}

自分が拡張した方のmockeryを使うと、上のMockに加えてテストで使うときのためのstruct達が生成されます。また、interfaceの引数や戻り値に名前つけておかないと_a0とかになっちゃうので、付けておくのがおすすめです。じゃないと実質名前付きじゃなくなっちゃってこの拡張があんまり意味なくなると思います。

interfaceの引数と戻り値に名前つけると以下のようになります。

type Company interface {
    Info(name string) (zip, address, phone, string, employee int)
}

以下のようなstructになります。

type InfoArgs struct {
    Name         string
    NameAnything bool
}

type InfoReturns struct {
    Zip      string
    Address  string
    Phone    string
    Employee int
}

type InfoExpectation struct {
    Args    InfoArgs
    Returns InfoReturns
}

Name だけじゃなくて NameAnything があるのは、Mockしたいけど引数は何でも良い場合とかがあるので、その場合に NameAnything をtrueにしておくとMockには mock.Anything を渡してくれます。 mock.Anything がただのstringなので、 Employee: mock.Anything みたいに渡すことができず苦肉の策でこうしています。これに関しては何か他に良いやり方ありそう。

そして同時にApplyする側のメソッドも生成します。

func (_m *MockCompany) ApplyInfoExpectation(e InfoExpectation) {
    var args []interface{}
    if e.Args.NameAnything {
        args = append(args, mock.Anything)
    } else {
        args = append(args, e.Args.Name)
    }
    _m.On("Info", args...).Return(e.Returns.Zip, e.Returns.Address, e.Returns.Phone, e.Returns.Employee)
}

テストから呼び出す時は、これを単に呼び出せばよいです。OnとかReturnとかテストに書かなくても良いのですっきりします。

for _, tc := range testCases {
    t.Run(tc.name, func(t *testing.T) {
        mockCompany := new(MockCompany)
        mockCompany.ApplyInfoExpectation(tc.info)
        ...

簡単に引数・戻り値を定義できるしApplyも簡単だし最高ですね(個人の感想)。ちなみに自分は全然違う命名をしてたのですが、英語の得意な同僚が上のような命名をしているのを見てそっと変えました。

定義の部分で文字数が少し増えてしまうのが気にはなっているものの、補完されるし書く分にはあまり困らないです。どちらかと言うと読む時に文字が詰まって見えることがあるのでそれは何とかしたい気持ちがあります。ただきちんと説明されているという意味では初めて見る時には今のほうが良いかもしれないし悩みどころです。

余談

vektra/mockeryはメンテナンスが止まっているため、stretchr/testifyからgomockに移行するという人も結構いるみたいです。ただ個人的にはtestify/mockの方が使いやすくて好きなので現時点ではこっちで良いかなと思っています。数カ月後にどうなるかはわかりませんそもそも大体必要な機能は揃っているのでこれからどんどん破壊的変更が入るかと言うとそうではないような気がしますし、Kubernetesなども使っているのでとりあえずは大丈夫かなと楽観視しています。 https://github.com/kubernetes/kubernetes/blob/7f23a743e8c23ac6489340bbb34fa6f1d392db9d/pkg/kubelet/eviction/mock_threshold_notifier_test.go#L20

自分はあまりMock自体が好きじゃないので本当に必要になるまでは使わないのですが、最近は大人の事情で必要になってしまったので使っています。

まとめ

Mockに与える引数と戻り値を簡単に定義できるstructと適用するためのメソッドを自動生成できるように既存ツールを拡張しました。このツールを使ってほしいと言うよりは、みんなどうしてるんだろう、という議論の種になれば良いなという気持ちです。実は同じ問題を抱えてた人が多かったりしたら真面目にmockeryの拡張部分のテスト書いたりして整備しようと思います。

あと今回の件もそうなのですが、入門記事みたいなのは結構出てくるけどプロダクトで実際にどうやって使うのみたいなので困ることが結構多い気がします。GitHubを漁って実際の使われ方を探すものの意外と出てこなかったりするので、そういうのに使ってる時間が結構長くて辛いです。