Go でテストを記述する際、モックの生成にはしばしば gomock が使われますが、引数の検証に使う Matcher は標準では限られたもののみが用意されています。

たとえば、以下のテストの 12 行目では gomock.Eq(...) を使って api.Client に対して Get("/v1/info") という呼び出しが 1 回されることを期待しています。

//go:generate mockgen -source=client.go -destination=client_mock.go -package=api

package api

import (
	"io"
	"net/http"
)

type Client interface {
	Get(url string) (*http.Response, error)
	Post(url, contentType string, body io.Reader) (*http.Response, error)
}
import (
	"testing"

	"go.uber.org/mock/gomock"
)

func TestServiceGet(t *testing.T) {
	ctrl := gomock.NewController(t)
	defer ctrl.Finish()

	mock := api.NewMockClient(ctrl)
	mock.EXPECT().Get(gomock.Eq("/v1/info")).Return(nil, nil)
	service := domain.NewService(mock)
	service.DoFancyStuff()
}

このような単純な比較であれば標準の Matcher でも事足りますが、以下のテストの 15 行目のように引数の検証が比較演算でできないケースもあり、gomock.Any() を使って引数の検証自体を省略したままにしてしまうこともあります。

import (
	"testing"

	"go.uber.org/mock/gomock"
)

func TestServicePost(t *testing.T) {
	ctrl := gomock.NewController(t)
	defer ctrl.Finish()

	mock := api.NewMockClient(ctrl)
	mock.EXPECT().Post(
		gomock.Eq("/v1/comments"),
		gomock.Eq("application/x-www-form-urlencoded"),
		gomock.Any(),	).Return(nil, nil)

	service := domain.NewService(mock)
	service.DoFancyStuff()
}

今回は上記のラッパーの Post() が期待した body の値で呼び出されているか検証する Matcher を記述して対処してみます。メインロジックでは、21 行目で url.Values をエンコードした文字列をラップする io.Reader を渡し、これが正しく渡されているかをテストします1

import (
	"net/url"
	"strings"
)

type service struct {
	client api.Client
}

func NewService(c api.Client) *service {
	return &service{c}
}

func (s *service) DoFancyStuff() {
	values := url.Values{}
	values.Add("name", "test me")

	s.client.Post(
		"/v1/comments",
		"application/x-www-form-urlencoded",
		strings.NewReader(values.Encode()),	)
}

環境

Go

1.21.4

go.uber.org/mock

0.3.0

設計

gomock には別の Matcher を入れ子に持つ Not2 のような Matcher がすでにあるため、汎化するために io.Reader を検証する Reader の Matcher と、url.Values を検証する Query の Matcher の 2 つに分けて、以下のように呼び出せるようにしておきます。

mock.EXPECT().Post(
	"/v1/comments",
	"application/x-www-form-urlencoded",
	Reader(
		Query(
			gomock.Eq(
				url.Values{
					"name": []string{"test me"},
				},
			),
		),
	),
)

io.Reader の Matcher

渡された io.Reader からすべて読み取り、期待する []byte と比較する Matcher に委譲する Matcher を返します。

func Reader(m gomock.Matcher) gomock.Matcher {
	// TODO
}

また、マッチしなかった場合の出力を整形するために GotFormatter も実装しておきます3

url.Values の Matcher

渡された []byte を URL デコードし、期待する url.Values と比較する Matcher に委譲する Matcher を返します。

func Query(m gomock.Matcher) gomock.Matcher {
	// TODO
}

また、マッチしなかった場合の出力を整形するために GotFormatter も実装しておきます3

実装

以下の 2 つのインターフェイスを満たす実装を 1 つずつ作っていきます。
それぞれの Matcher は生成時に GotFormatterAdapter(GotFormatter, Matcher) を通すことでコンパイル時に両方のインターフェイスを満たすことを保証させます4

type Matcher interface {
	Matches(x any) bool
	String() string
}
type GotFormatter interface {
	Got(got any) string
}

io.Reader の Matcher と GotFormatter

テスト時に読み取った値を readerMatcher にキャッシュしておき、マッチしなかった場合にその値を Got() でも使えるようにしておきます。 Got() では 34 行目で入れ子の MatcherGotFormatter だった場合にその処理を委譲しています。

import (
	"fmt"
	"io"

	"go.uber.org/mock/gomock"
)

type readerMatcher struct {
	m    gomock.Matcher
	data []byte
}

func Reader(m gomock.Matcher) gomock.Matcher {
	r := &readerMatcher{m, nil}
	return gomock.GotFormatterAdapter(r, r)
}

func (r *readerMatcher) Matches(x any) bool {
	var err error
	r.data, err = io.ReadAll(x.(io.Reader))
	if err != nil {
		return false
	}
	return r.m.Matches(r.data)
}

func (r *readerMatcher) String() string {
	return fmt.Sprintf("data(%s)", r.m.String())
}

func (r *readerMatcher) Got(got any) string {
	f, ok := r.m.(gomock.GotFormatter)
	if ok {
		return fmt.Sprintf("data(%s)", f.Got(r.data))	}
	return fmt.Sprintf("%#v", r.data)
}

url.Values の Matcher と GotFormatter

io.Reader への Read() とは異なりバイト列の URL デコードは冪等性があるのでキャッシュしません。 Got() では 39 行目で入れ子の MatcherGotFormatter だった場合にその処理を委譲しています。

import (
	"fmt"
	"net/url"

	"go.uber.org/mock/gomock"
)

type queryMatcher struct {
	m gomock.Matcher
}

type queryGotFormatter struct {
	m gomock.Matcher
}

func Query(m gomock.Matcher) gomock.Matcher {
	return gomock.GotFormatterAdapter(
		&queryGotFormatter{m},
		&queryMatcher{m},
	)
}

func (q *queryMatcher) Matches(x any) bool {
	values, err := url.ParseQuery(string(x.([]byte)))
	if err != nil {
		return false
	}
	return q.m.Matches(values)
}

func (q *queryMatcher) String() string {
	return fmt.Sprintf("url.Values(%s)", q.m.String())
}

func (q *queryGotFormatter) Got(got any) string {
	values, _ := url.ParseQuery(string(got.([]byte)))
	f, ok := q.m.(gomock.GotFormatter)
	if ok {
		return fmt.Sprintf("url.Values(%s)", f.Got(values))	}
	return fmt.Sprintf("url.Values(%v)", values)
}

動作確認

実際に冒頭の TestServicePost() を使ってテストしてみると、期待した引数("test me")で呼び出されているためテストが通過していることが確認できます。

ok      github.com/chitoku-k/custom-matcher-test    0.010s

つづいて、実装側で Post() の第 3 引数を "テスト" に変更してテストを再実行してみると、テストが正しく失敗し、GotFormatter によって出力が整形できていることが確認できます。

--- FAIL: TestService (0.00s)
    matcher_test.go:103: Unexpected call to *api.MockClient.Post([/v1/comments application/x-www-form-urlencoded 0xc00000e3e0]) at /usr/src/custom-matcher-test/api/client_mock.go:40 because:
        expected call at /usr/src/custom-matcher-test/matcher_test.go:87 doesn't match the argument at index 2.
        Got: data(url.Values(map[name:[テスト]]))
        Want: data(url.Values(is equal to map[name:[test me]]))
    panic.go:617: missing call(s) to *api.MockClient.Post(is equal to /v1/comments, is equal to application/x-www-form-urlencoded, data(url.Values(is equal to map[name:[test me]]))) /usr/src/custom-matcher-test/matcher_test.go:87
    panic.go:617: aborting test due to missing call(s)
FAIL
FAIL    github.com/chitoku-k/custom-matcher-test    0.009s
FAIL

脚注