gomockでスライスのMatcherを作る

gomockでスライスを順不同で一致させるためにMatcherを自作しました。

※追記
v1.6.0 から InAnyOrder というMatcherが用意されているので、不要になりました。

gomock package - github.com/golang/mock/gomock - pkg.go.dev

gomock

GolangでUTを書く際に使います。

GitHub - golang/mock: GoMock is a mocking framework for the Go programming language.
GoMock is a mocking framework for the Go programming language. - GitHub - golang/mock: GoMock is a mocking framework for the Go programming language.

interfaceの定義からモックを自動生成できるので、比較的簡単にUTが書けます。

例えば以下のようなメソッドがあった時、

func (u *userUsecase) GetByIDs() (Users, error) {
	idMap := map[int64]struct{}{
		1: {},
		2: {},
	}

	ids := make([]int64, 0, len(idMap))
	for id := range idMap {
		ids = append(ids, id)
	}

	us, err := u.userRepository.GetByIDs(ids)
	if err != nil {
		return nil, err
	}
	return us, nil
}

userRepositoryがinterfaceで定義されていれば、gomockでモック化してテストできます。

しかし、idMapはmapなのでidsの順番が保証されません。

よって、以下のようなテストを書くと、ランダムでテストが失敗するようになります。

func Test_GetByIDs(t *testing.T) {
	...
	ctrl := gomock.NewController(t)
	repository := mock.NewMockIRepository(ctrl)
	repository.EXPECT().
		GetByIDs([]int64{1, 2}).
		Return([]*user.User{ ... }, nil).
		Times(1)
	...
}

普通にスライスを渡すと、順番も一致していなければエラーになります。

スライス用のMatcherを定義する

gomockでは任意のMatcherを定義することができます。

mock/matchers.go at master · golang/mock
GoMock is a mocking framework for the Go programming language. - mock/matchers.go at master · golang/mock
// A Matcher is a representation of a class of values.
// It is used to represent the valid or expected arguments to a mocked method.
type Matcher interface {
	// Matches returns whether x is a match.
	Matches(x interface{}) bool

	// String describes what the matcher matches.
	String() string
}

Matcherをモック関数の引数に渡すと、内部でMatchesメソッドを呼び、trueが返ってきたら一致、とみなすようになっています。

この2つのメソッドを実装します。

type sliceMatcher struct {
	slice interface{}
}

func (m sliceMatcher) Matches(x interface{}) bool {
	xValue := reflect.ValueOf(x)
	mValue := reflect.ValueOf(m.slice)
	// 引数がスライスか確認
	if xValue.Kind() != reflect.Slice {
		return false
	}
	// 期待値がスライスか確認
	if mValue.Kind() != reflect.Slice {
		return false
	}
	// スライスの長さが一致しているか確認
	if xValue.Len() != mValue.Len() {
		return false
	}
	mUsedIndex := make(map[int]struct{}, mValue.Len())
	for i := 0; i < xValue.Len(); i++ {
		x := xValue.Index(i)
		match := false
		for j := 0; j < mValue.Len(); j++ {
			if _, ok := mUsedIndex[j]; ok {
				continue
			}
			m := mValue.Index(j)
			// 一致しているものがあるか確認
			if reflect.DeepEqual(x.Interface(), m.Interface()) {
				match = true
				mUsedIndex[j] = struct{}{}
				break
			}
		}
		if !match {
			return false
		}
	}
	return true
}

func (m sliceMatcher) String() string {
	return fmt.Sprintf("is equal to %v", m.slice)
}

var _ gomock.Matcher = sliceMatcher{}

フィールドをinterface{}で定義しているので、任意の型のスライスをマッチさせることができます。

reflect パッケージを使い、順不同で一致しているか確認しています。2重ループなのでパフォーマンスは悪いですが、そんなに大きいスライスを扱うことはないと思うので良しとしています。

多分もう少し綺麗に書けると思いますが、テストで使うだけなのでだいぶ妥協してます。

以下のように使います。

func Test_GetByIDs(t *testing.T) {
	...
	ctrl := gomock.NewController(t)
	repository := mock.NewMockIRepository(ctrl)
	repository.EXPECT().
		GetByIDs(sliceMatcher{[]int64{1, 2}}).
		Return([]*user.User{ ... }, nil).
		Times(1)
	...
}

GetByIDsに来る値が[]int64{1, 2}または[]int64{2, 1}の場合に一致します。

mapを使った処理でも簡単にテストが書けるようになりました。