[golang] mockery/testify mockを作るのに困ったとき


mockery : https://github.com/vektra/mockery
testify: https://github.com/stretchr/testify
上記のモック作成ツールを使ったテストを書いているとき、mockを定義しようとして「困ったな」と思う事が時々あります。そんなときに使えそうな方法について書いてみました。

それぞれの解答に対して評価(〇、△、X)をつけていますが、あくまでこれは主観的なものです。異見があるかたはコメントください。

問題1(副作用)

さて、golangでこんなモジュールがあったとします。

type Sample struct {
}
func (s *Sample) Hoge(in interface{}, out interface{}) error {
   // do something
}

in に入力を受け取り、outに出力内容を書きこんで返してくるメソッドです。

たとえばinをjson.Marshalして作成したbodyを使ってhttpリクエストし、戻ってきたレスポンスをjson.Unmarshalでoutに書き込んで返してくるイメージ。

さて、これをモックするとします。

mockSample := new(mocks.Sample)
in := &Input{something}
mockSample.On("Hoge", in, mock.Anything).Return(nil)

ここで困ってしまいます。Hogeはoutの中身を書き換えて返す関数ですが、これをどうモックで表現したらいいんでしょう?

解答1(X)

mockSample := new(mocks.Sample)
in := &Input{something}
mockSample.On("Hoge", in, mock.Anything).Return(nil)

何もしない。もちろんテストがそもそも通過しません。

解答2(〇)

mockSample.On("Hoge", in, out).Return(nil).Once().Run(func(args mocks.Argument) {
   o := args[2].(*Output)
   *o = Output{something}
})

こうすることでこのmockは引数に値を出力することができそうです。このRun()に渡している関数はRunFnと定義されているようですね。

何をしているかといいますと、Runの中ではmockがリクエストを受けたとき行う処理を書くことができます。そこで、引数として受け取ったoutのアドレスの参照先に戻したい値の「実体」をコピーしています。

戻り値で戻すならReturnに書くだけなので簡単ですが、引数による返却は少し難しいです・・・ですがargumentsを引数に受け取って何かする関数であるRunFnは、基本的にこのような事を目的とするもののように思われます。

解答3(X)

mockSample.On("Hoge", in, mock.MatchedBy(func(out interface{}) bool) {
   o := out.(*Output)
   *o = Output{something}
   return true
})).Return(nil)

MatchedByを使ってこんなこともできそうではあるものの、MatchedByはあくまでmatcherなので、matcher以外の用途(副作用)に使うべきではないかと思います。

問題2(関数引数)

package sample

type Sample interface {
    Hoge(func() string) string
    Fuga() string
}
type sample struct{}

func (s *sample) Hoge(f func() string) string {
    return f()
}
func (s *sample) Fuga() string {
    return "Fuga"
}

type Sample2 struct {
    s Sample
}

func (s *Sample2) Piyo() string {
    return s.s.Hoge(func() string {
        return s.s.Fuga()
    })
}

ちょっと長いのですが、Hoge(func() string) stringこんなものをモックする方法についていろいろ試してみました。

解答1(X)

    m := new(mocks.Sample)
    m.On("Fuga").Return("Mock2")
    m.On("Hoge", mock.Anything).Return("Mock")

もちろんこれだけだとHogeの引数である関数をテストしていませんのでダメです。

解答2(△)

    m := new(mocks.Sample)
    m.On("Fuga").Return("Mock2")
    m.On("Hoge", mock.Anything).Return("Mock").Run(func(args mock.Arguments){
        f := args[0].(func() string)
        assert.Equal(t, "Mock2", f())
    })

モック定義の中でアサートしている事が違和感があります。

解答2(△)

    m := new(mocks.Sample)
    m.On("Fuga").Return("Mock2")
    m.On("Hoge", mock.Anything).Return(func(f func() string) string {
        return f()
    })

ReturnValueProviderFunctionです。
シンプルではあるものの、この関数、Hogeそのものなんです。

「単体テスト」的に、テストの中でmockの実装に依存するのは抵抗があります。ただまあ、限りなく〇に近い△です。

解答3(X)

    m := new(mocks.Sample)
    m.On("Fuga").Return("Mock2")
    m.On("Hoge", mock.MatchedBy(func(f func() string)bool{
        fmt.Println(f())
        return true
    })).Return("Mock")

これはdeadlockになります。というのは、Matcherのなかでmock自身が呼ばれるとダメのようです。
RunやReturnValueProviderFunctionだとdeadlockにならないんですが・・・

MatchedByは関数引数には無力です。

解答4(〇)

    m := new(mocks.Sample)
    m.On("Fuga").Return("Mock2")
    m.On("Hoge", mock.Anything).Return("Mock")

    // test
    sut := &Sample2{m}
    assert.Equal(t, "Mock", sut.Piyo())

    f := m.Calls[0].Arguments[0].(func()string)
    assert.Equal(t, "Mock", f())
  • Anythingで荒くassertする
  • そのあと、改めてargsのチェックをする

結論

いろいろ方法はあるかと思いますが、困ったときは「単体テストとは何か」に立ち返ってみるのが良いと思います。

そのうえで、RunやMatchedByやReturnValueProviderFunction、そしてテストそのものの分離を検討してみてください。

    m.AssertExpectations()

こんな記事を読むような方には言わずもがなと思いますが、コール数のチェックをお忘れなく!

参照

https://qiita.com/tomtwinkle/items/55f79c969d48206c9945
上記で @tomtwinkle さんがもっと具体的なコードを書いてくださってますので、こういう記事に興味があるかたはそちらも併せてどうぞ!