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 を入れ子に持つ Not
2 のような 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 行目で入れ子の Matcher
が GotFormatter
だった場合 にその処理を委譲しています。
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 行目で入れ子の Matcher
が GotFormatter
だった場合にその処理を委譲しています。
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