Golang mockery/testifyで高階関数をmockする


golangでテストを書く際、vektra/mockeryによるstretchr/testifyで使用できるmockの自動生成が非常に便利なのですが、双方クイックリファレンス以外の細かいドキュメントがあまり存在せず特殊な関数をmockしようとする結構悩みます。

最近調べまくったもので「無名関数で書かれた高階関数のmock」はズバリの解決方法が検索してもtestifyのissueくらいしか出てこなかったので同じく悩める人のためにここに残しておきます。

ポイント かつ 結論

  • 引数を元にmock内の処理を独自に書きたいならCall.Runを活用する
  • mockの戻り値を動的に変更したいならCall.Returnを活用する

高階関数のtestify mockサンプル

シンプルに書こうと思いまいしたがテスト容易にするDIのためのinterface定義を必ず書く都合上結構記述量が多くなりました。
githubにコード公開しておいたのでそっち見たほうが理解が早いかもしれません。

プロダクトコード

まずはテストしたいfuncである ExampleAnonymousFunc()は以下のように高階関数であるWithFunc()の中に実際のロジックが記述されています。

funcs/example.go
package funcs

import (
    "fmt"
    "github.com/pkg/errors"
    "log"
)

type Example interface {
    ExampleAnonymousFunc() (string, error)
}

type example struct {
    called ExampleCalled
}

func NewExample(called ExampleCalled) Example {
    return &example{called: called}
}

func (e *example) ExampleAnonymousFunc() (string, error) {
    log.Print("start ExampleAnonymousFunc()")
    v, err := e.called.WithFunc(func() (i interface{}, err error) {
        // ここにロジックが色々書いてある
        v, err := e.called.Example()
        log.Print(fmt.Sprintf("called anonymous func! result: %s", v))
        // ここにロジックが色々書いてある
        return v, errors.WithStack(err)
    })
    if err != nil {
        return "", errors.WithStack(err)
    }
    log.Print(fmt.Sprintf("end ExampleAnonymousFunc() result: %s", v))
    return v.(string), nil
}

ExampleAnonymousFunc()内で使用しているWithFunc()のmockが今回の悩みどころ。
普通にWithFunc()自体をmockしてしまうと色々書いてあるロジック自体がテストされません。

次にmockされる側の関数WithFunc()Example()は以下の通り。

funcs/example_called.go
package funcs

import (
    "github.com/pkg/errors"
    "log"
)

type ExampleCalled interface {
    WithFunc(func() (interface{}, error)) (interface{}, error)
    Example() (string, error)
}
type exampleCalled struct{}

func NewExampleCalled() ExampleCalled {
    return &exampleCalled{}
}

func (e *exampleCalled) WithFunc(
    fn func() (interface{}, error),
) (interface{}, error) {
    log.Print("called with func!")
    v, err := fn()
    if err != nil {
        return nil, errors.WithStack(err)
    }
    return v, nil
}

func (e *exampleCalled) Example() (string, error) {
    log.Print("called example!")
    return "example!!!", nil
}

こっちのテストはtestifyのクイックリファレンスの範疇内で簡単に出来るので今回は触れません。

main関数を書いて実行してみます。

main.go
package main

import (
    "fmt"
    "go-mockery-mock-sample/anonymous_func/funcs"
    "log"
)

func main() {
    log.Print("start")
    ex := funcs.NewExample(funcs.NewExampleCalled())

    if result, err := ex.ExampleAnonymousFunc(); err != nil {
        log.Print("error!")
    } else {
        log.Print(fmt.Sprintf("result: %s", result))
    }
    log.Print("end")
}
2020/03/07 21:36:01 start
2020/03/07 21:36:01 start ExampleAnonymousFunc()
2020/03/07 21:36:01 called with func!
2020/03/07 21:36:01 called example!
2020/03/07 21:36:01 called anonymous func! result: example!!!
2020/03/07 21:36:01 end ExampleAnonymousFunc() result: example!!!
2020/03/07 21:36:01 result: example!!!
2020/03/07 21:36:01 end

WithFunc()Example()双方がmockされ、無名関数内のコードがテストされれば
called with func!called example!の部分が出力されなくなるはずですが、さて。

テストコード(NGパターン)

まずは駄目な例から

funcs/example_test.go
package funcs

import (
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/mock"
    "go-mockery-mock-sample/anonymous_func/mocks"
    "testing"
)

func TestExample_ExampleAnonymousFunc(t *testing.T) {
    t.Run("example NG", func(t *testing.T) {
        mockExampleCalled := new(mocks.ExampleCalled)

        mockExampleCalled.On("WithFunc", mock.Anything).Return("WithFunc mock!", nil)
        mockExampleCalled.On("Example").Return("Example mock!", nil)

        e := NewExample(mockExampleCalled)
        actual, err := e.ExampleAnonymousFunc()
        assert.NoError(t, err)
        assert.Equal(t, "WithFunc mock!", actual)
        mockExampleCalled.AssertExpectations(t)
    })
}
=== RUN   TestExample_ExampleAnonymousFunc
=== RUN   TestExample_ExampleAnonymousFunc/example_NG
2020/03/07 21:44:36 start ExampleAnonymousFunc()
2020/03/07 21:44:36 end ExampleAnonymousFunc() result: WithFunc mock!
    TestExample_ExampleAnonymousFunc/example_NG: example_test.go:21: PASS:  WithFunc(string)
    TestExample_ExampleAnonymousFunc/example_NG: example_test.go:21: FAIL:  Example()
                at: [example_test.go:15]
    TestExample_ExampleAnonymousFunc/example_NG: example_test.go:21: FAIL: 1 out of 2 expectation(s) were met.
            The code you are testing needs to make 1 more call(s).
            at: [example_test.go:21]
--- FAIL: TestExample_ExampleAnonymousFunc (0.02s)
    --- FAIL: TestExample_ExampleAnonymousFunc/example_NG (0.02s)
FAIL

end ExampleAnonymousFunc() result: WithFunc mock! が出ているのでWithFunc()は無事mockされました。
しかし、Example()が呼び出されずにFailしました。

先程も書きましたが、ExampleAnonymousFunc()内で使用しているWithFunc()のmockを普通に行ってしまうと、無名関数内に記述された色々な処理がmockされてしまいます。Example()も呼ばれません。

テストコード(OKパターン)

前置きが長くなりましたがここが本題です。
WithFunc()をmockしつつ引数で渡した無名関数は実行させ、その戻り値をWithFunc()の戻り値として使用するためにはtestifyのRunFnRunを使用します。

funcs/example_test.go
package funcs

import (
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/mock"
    "go-mockery-mock-sample/anonymous_func/mocks"
    "testing"
)

func TestExample_ExampleAnonymousFunc(t *testing.T) {
    t.Run("example OK", func(t *testing.T) {
        mockExampleCalled := new(mocks.ExampleCalled)

        mockWithFunc := mockExampleCalled.On("WithFunc", mock.Anything)
        mockWithFunc.Run(func(args mock.Arguments) {
            // WithFuncの処理をmockしつつ、高階関数の引数の関数実行は行う
            fn := args[0].(func() (interface{}, error))
            v, err := fn()
            // 高階関数の引数の関数の戻り値を返す
            mockWithFunc.ReturnArguments = mock.Arguments{v, err}
        })
        mockExampleCalled.On("Example").Return("Example mock!", nil)

        e := NewExample(mockExampleCalled)
        actual, err := e.ExampleAnonymousFunc()
        assert.NoError(t, err)
        assert.Equal(t, "Example mock!", actual)
        mockExampleCalled.AssertExpectations(t)
    })
}
=== RUN   TestExample_ExampleAnonymousFunc
=== RUN   TestExample_ExampleAnonymousFunc/example_OK
2020/03/07 21:52:48 start ExampleAnonymousFunc()
2020/03/07 21:52:48 called anonymous func! result: Example mock!
2020/03/07 21:52:48 end ExampleAnonymousFunc() result: Example mock!
    TestExample_ExampleAnonymousFunc/example_OK: example_test.go:41: PASS:  WithFunc(string)
    TestExample_ExampleAnonymousFunc/example_OK: example_test.go:41: PASS:  Example()
--- PASS: TestExample_ExampleAnonymousFunc (0.02s)
    --- PASS: TestExample_ExampleAnonymousFunc/example_OK (0.02s)
PASS

WithFunc()をmockしつつちゃんとExample()も実行されました。

めでたしめでたし

サンプルコード

今回使用したサンプルコード
tomtwinkle/go-mockery-mock-sample

参考サイト

Allow dynamic returns based on arguments | testify/issues/350

@kishibashi3 さんがもう少し汎用的に用例を書いてくれました。
[golang] mockery/testify mockを作るのに困ったとき