以前からずっとある方法だけど、関数の内部で 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」って可愛い名前を思いついたので、自分の欲しいものを作りました、というのが以下のライブラリです。
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)
ここで m は io.ReadWriter を動的に実装しています。reflect だけでは動的なメソッド実装できないのですが、karamaruさんのポストを読んでいて知った go-dyno というパッケージが unsafe や go:linkname コンパイラディレクティブなども使って動的なインターフェイス実装を実現しています。
具体的には go-dyno では Dynamic という関数が func(meth reflect.Method, args []reflect.Value) []reflect.Value
というシグネチャでコールバックを受け取り、メソッドが呼ばれたときにこの関数で動的に挙動を切り替えるようになっています。もともとはovechkin-dm/mockioというモックライブラリのために作られたようで、mofu と用途が被っていて気まずいなと思いますが、mockio はインターフェイス主体でこちらは関数主体なので、まあいいかな。