My External Storage

Dec 20, 2021 - 4 minute read - Comments - go

gomockで順序を無視してスライスの引数を検証する

この記事はGoアドベントカレンダー2021 その1 20日目の記事となる。
この記事ではgithub.com/golang/mockを使ったモックのメソッドで順不同なスライス引数を検証する方法を紹介する。

TL;DR

  • gomockはGoでモックを自動生成するデファクトスタンダードなツール
  • モックメソッドの引数のスライスを順不同で検証したいならばInAnyOrderマッチャーを使えばよい
  • 余談:順不同でスライスを検証したくなるとき
  • 普段使っているライブラリのリリースと更新内容は目を通しておきましょう

https://go.dev/play/p/hxwbM2S6vrR

func TestInAnyOrder(t *testing.T) {
    if match := gomock.InAnyOrder([]int{1, 2, 3}).Matches([]int{1, 3, 2}); !match {
        t.Error("want match, but not match")
    }
}

gomockはGoでモックを自動生成するデファクトスタンダードなツール

gomockはインターフェイスに対してモックコードを自動生成してくれるツールだ。

Goでテストコードを書くならば高確率でお世話になると思う。

モックメソッドの引数のスライスを順不同で検証したいならばInAnyOrderマッチャーを使えばよい

gomockはモックのメソッドで受け付ける引数を検証するが、スライスが引数の場合は当然スライスの中身の順序も含めて検証してくれる。

ctrl := gomock.NewController(t)
// mockgenコマンドで自動生成したモック生成関数
mobj := mock.NewMockObject(ctrl)
// []int{1, 3, 2} を受け取った場合failする
mobj.EXPECT().ReceiveIDs([]int{1, 2, 3})

しかし、後述するようなシーンでスライスの中身の順序を無視してテストをしたくなる。
そんなときはgomock#InAnyOrderマッチャーを使ってモックの期待引数を設定すれば良い。

// []int{1, 3, 2} を受け取ってもOKになる
mobj.EXPECT().ReceiveIDs(gomock.InAnyOrder([]int{1, 2, 3}))

サンプルコードは次にある。 https://github.com/golang/mock/blob/0cdccf5f55d777b12c1ac5a93f607cdd1dbf5296/gomock/matchers_test.go#L148-L295

InAnyOrderが必要になるとき

「スライスはデータ構造として順序も含めて等価性を検証すべきだ!」という意見はもっともだと思う。
しかし、mapを経由したdistinct処理を実装した場合などで順不同なスライスが発生しうる。

InAnyOrderが必要になるサンプルコード

InAnyOrderを使いたくなるサンプルコードを示す。実装したい機能は次のとおり

  • 渡された書籍リストからDBに登録された著者のデータを返す関数
    • 単純化のため1冊の書籍は1名の著者のみ
  • 書籍リストには同じ著者の書いた書籍が複数含まれうる

これをGoで実装すると次のようになる。

type AuthorID int

type Book {
    AutherID AuthorID
}

type Author {
    ID AuthorID
}

// モックを作成するインターフェイス
type AuthorFinder interface {
    FindAuthors(ids []AuthorID) []*Author
}

type Sample struct {
    ag AuthorFinder
}

func (s *Sample)GetAuthors(books []*Book) []*Author {
    ids := mapDistinct(books)
    return s.ag.FindfAuthors(ids)
}

// 書籍のスライスから重複を除外した著者IDスライスを生成するmap-distinct関数
func mapDistinct(books []*Book) []AuthorID {
    d := make(map[AuthorID]struct{}, len(books))
    for _, b := range books {
        d[b.AuthorID] = struct{}{}
    }
    ids := make([]AuthorID, len(d))
    // mapは順序性を持たないデータ構造なので順不同になる
    for id := range d {
        ids = append(ids, id)
    }
    return ids
}

GoでMap-Distinctするストリーム処理を書こうとすると、map構造を挟んで重複を削除することになる。 mapの中身はrangeループを使って取り出せるが、mapはデータ構造的に順序を持たないので、ここで生成されるIDが順不同になる。 (SQLのIN句へ渡されることになる)プロダクトコードではこのidsスライスはソートする必要がない。 そのため、ここで FindAuthors(ids []AuthorID) []*Author メソッドのids引数を順不同としてモックするテストコードを書きたくなる。

終わりに

実は最初は「gomockではこういうモック定義ができませんが、私の自作ライブラリを使えばできます」と書くつもりだった。 しかし、ブログを書く前にgodocを確認したらInAnyOrderマッチャーが増えているのに気がついた1
普段使っているパッケージのリリースと更新内容は目を通しておいたおくべきたと反省した。 gomockも結構前からgomock.Controller#Finishメソッドを呼ぶ必要がなくなっていたり、ずっと使っているパッケージも日々カイゼンが行われている。

ちなみに紹介しようとした自作ライブラリはこちら。
gomock用の自作マッチャーなのだが、github.com/google/go-cmpパッケージを使っているので、cmp.Optionインタフェースを満たすオプションを書けばモック定義でもgo-cmp基準の検証ができる。

参考


  1. 違うネタを用意する時間がありませんでした… ↩︎

関連記事