Plan 9とGo言語のブログ

主にPlan 9やGo言語の日々気づいたことを書きます。

Goで気に入っているテストの書き方と関数をモックするjest.fnのようなライブラリを作った話

以前からずっとある方法だけど、関数の内部で xxxInternal という形で実装を分けて、単体テストでは xxxInternal を使ってテストする書き方を気に入っています。どういうことかといえば、例えばKinesisにPutする以下のようなメソッドがあったとき、

type Writer struct{ ... }

func (w *Writer) BulkPost(ctx context.Context, metrics []*Metric) error {
    k := kinesisFromConfig(w) // Kinesisを初期化する
    records := make([]types.PutRecordsRequestEntry, len(metrics))
    for i, m := range metrics {
        records[i] = metricToRequestEntry(m)
    }
    input := &kinesis.PutRecordsInput{
        Records:    records,
        StreamName: aws.String("dummy"),
    }
    _, err := k.PutRecords(ctx, input)
    return err
}

このままでは k.PutRecords で本当のKinesisに投げてしまって困るので、本物のKinesisを参照する部分とそれ以外で、Writer.BulkPost()bulkPostInternal のように分けてしまいます。このとき、bulkPostInternalインターフェイスKinesisクライアントを受け取るようにしておきます。

type Writer struct{ ... }

func (w *Writer) BulkPost(ctx context.Context, metrics []*Metric) error {
    // 公開する実装は本物のKinesisクライアントを渡す
    k := kinesisFromConfig(w)
    return bulkPostInternal(ctx, metrics, k)
}

// recordsPutter はbulkPostInternalで必要なKinesisのメソッドだけ定義したインターフェイス。
type recordsPutter interface {
    // 本物のKinesisをそのまま渡せるようにKinesis SDKの型にあわせておく
    PutRecords(ctx context.Context, input *kinesis.PutRecordsInput, opts ...func(*kinesis.Options)) (*kinesis.PutRecordsOutput, error)
}

func bulkPostInternal(ctx context.Context, metrics []*Metric, putter recordsPutter) error {
    records := make([]types.PutRecordsRequestEntry, len(metrics))
    for i, m := range metrics {
        records[i] = metricToRequestEntry(m)
    }
    input := &kinesis.PutRecordsInput{
        Records:    records,
        StreamName: aws.String("dummy"),
    }
    _, err := putter.PutRecords(ctx, input)
    return err
}

これで自由にモックできるようになったので、テストでは関数にメソッドを生やしてモックします。

type putterFunc func(metrics []*Metric) (*kinesis.PutRecordsOutput, error)

// PutRecords はrecordsPutterインターフェイスのPutRecordsを実装します。
func (f putterFunc) PutRecords(ctx context.Context, input *kinesis.PutRecordsInput, opts ...func(*kinesis.Options)) (*kinesis.PutRecordsOutput, error) {
    return f(input)
}

func TestBulkPost(t *testing.T) {
    metrics := make([]*Metric, 10)
    bulkPostInternal(context.Background(), metrics, putterFunc(func(metrics []*Metric) (*kinesis.PutRecordsOutput, error) {
        return nil, errors.New("error")
    }))
}

このように分割すると、

  • モックの差し替えも関数を書くだけなので簡単
  • テストしたい対象の近くにモックの実装があるので、モックが返している値がすぐ見える
  • パッケージの外からは必要なインターフェイスしか見えない

特に最後の項目は、モックのためのインターフェイスをエクスポートしていないので、モックのための関数や型が見えていないところがいいですね。

type Writer struct{ ... }

func (w *Writer) BulkPost(ctx context.Context, metrics []*Metric) error

ライブラリ作成のモチベーション

やりたいのは上記の内容なのだけども、テストのたびに func(metrics []*Metric) (*kinesis.PutRecordsOutput, error) を手書きするのも少し面倒になるので、jest.fn みたいなライブラリが欲しいなと思いました。上記で putterFunc を実装した関数でいうと、以下のように書きたい。

fn := mock.Fn().ReturnOnce(nil, errors.New("error"))
bulkPostInternal(context.Background(), metrics, putterFunc(fn))

なのでGoのモックライブラリをいくつか調べたけれど、少なくとも有名なライブラリはインターフェイスをモックするものしか見つけられませんでした。調べるのも飽きてきた頃にたまたま「MOck FUnctionでmofu」って可愛い名前を思いついたので、自分の欲しいものを作りました、というのが以下のライブラリです。

github.com

Goの型パラメータによる制約などがあるので上で書いた欲しいものとは多少違いますが、TestBulkPost の例は

m := mofu.MockFor[putterFunc]().ReturnOnce(nil, errors.New("error"))
fn, r := m.Make()
bulkPostInternal(context.Background(), metrics, fn)
// 呼び出し回数のテスト、gtは最近気に入っている github.com/m-mizutani/gt です
gt.Equal(t, r.Count(), 1)

のように書けます。他にも、モックを書くときに必要そうなものは組み込んでいて、呼び出した回数によって結果を変えたい場合は ReturnOnce を繋げて書きます。

fn, r := m.
    ReturnOnce(&kinesis.PutRecordsOutput{}, nil). // 1回目の呼び出し
    ReturnOnce(nil, errors.New("error")). // 2回目
    Panic("panic"). // 3回目以降はずっとpanic
    Make()

引数によって返す値を変える場合は When で条件を書きます。

m.When(mofu.Any, &kinesis.PutRecordsInput{}).ReturnOnce(...).Make()

上記では MockFor で型を指定しましたが、具体的な関数があってそれをモックしたい場合のために MockOf もあります。

now, _ := mofu.MockOf(time.Now).Return(time.Date(...)).Make()

メソッドが複数の場合どうするか

ここまで恣意的にメソッドが1つだけの例を挙げていましたが、実際はメソッドが複数必要な場合もあります。mofu では、インターフェイスの実装に必要なだけ関数を作って合成する方法を採用しました。

read := mofu.MockOf(io.Reader.Read)
write := mofu.MockOf(io.Writer.Write)
m := mofu.Implement[io.ReadWriter](read, write)

ここで mio.ReadWriter を動的に実装しています。reflect だけでは動的なメソッド実装できないのですが、karamaruさんのポストを読んでいて知った go-dyno というパッケージが unsafego:linkname コンパイラディレクティブなども使って動的なインターフェイス実装を実現しています。

github.com

具体的には go-dyno では Dynamic という関数が func(meth reflect.Method, args []reflect.Value) []reflect.Value というシグネチャでコールバックを受け取り、メソッドが呼ばれたときにこの関数で動的に挙動を切り替えるようになっています。もともとはovechkin-dm/mockioというモックライブラリのために作られたようで、mofu と用途が被っていて気まずいなと思いますが、mockioインターフェイス主体でこちらは関数主体なので、まあいいかな。