【小ネタ】gomockでカスタムMatcherを使う

egonelbre/gophersによるGitHubからの画像

要約

unittestやってますか? unittestで便利なツール、gomockでカスタムMatcherを使って拡張する方法をご紹介します。 mockの引数チェックでgomock.Eq()でチェックすると単純な比較となってしまい、動的なデータ、例えばタイムスタンプなどは比較対象外にしたいということが出来なくて困ったりします。 このようなユースケースでカスタムMatcherが役に立ちます。

課題

次のようなコードがあるとします。

  • interfaces/interfaces.go
package interfacesimport "time"//go:generate go install github.com/golang/mock/mockgen//go:generate mockgen -destination=${GOPACKAGE}mocks/${GOFILE} -package=${GOPACKAGE}mocks -source=$GOFILEtype IRepository interface {    Update(data *Data) error}type Data struct {    ID        int    UpdatedAt time.Time}
  • repository/repository.go
package repositoryimport "github.com/cloudandbuild/blog-article-resrouces/content/blog/article-5/interfaces"type ARespository struct {    interfaces.IRepository}func NewRepository() *ARespository {    return &ARespository{}}func (r *ARespository) Update(data *interfaces.Data) error {    // not implemented yet    return nil}
  • service/service.go
package serviceimport (    "time"    "github.com/cloudandbuild/blog-article-resrouces/content/blog/article-5/interfaces")type AService struct {    ARepository interfaces.IRepository}func (r *AService) DoSomething() error {    d := &interfaces.Data{        ID:        1,        UpdatedAt: time.Now(),    }    return r.ARepository.Update(d)}

処理の流れはServiceRepositoryUpdateを呼び出すDoSomething関数があります。 DoSomething関数がテスト対象です。このとき、Updateに渡すData構造体のUpdatedAtが現在時刻を指します。これをgomockでテストするとき、単純にgomock.Eq()で照合するとUpdatedAtが合わなくて失敗してしまいます。

カスタムMatcherを作る

そこで、gomockのカスタムMatcherを作って、テストごとにMatcherを定義するようにしたいと思います。

  • testutil/testutil.go

gomockは、Matcher Interfaceを提供していますので、これを実装します。 テストごとにMatcherを定義できるよう比較関数を渡せるようにしています。

package testutilimport (    "fmt"    "github.com/golang/mock/gomock")func NewMatcher(x interface{}, matchFn func(a, b interface{}) bool) gomock.Matcher {    return &Matcher{        x:       x,        matchFn: matchFn,    }}type Matcher struct {    gomock.Matcher    x       interface{}    matchFn func(a, b interface{}) bool}func (m *Matcher) Matches(x interface{}) bool {    return m.matchFn(m.x, x)}func (m *Matcher) String() string {    return fmt.Sprintf("is equal to %v", m.x)}
  • service/service_test.go

gomockのカスタムMatcherを使ったテストコードがこちらになります。 go-cmpのIgnoreFiledsでUpdatedAtを無視するようにしました。 go-cmpだけでなく、自由にMatcherを実装することができるので自由度が広がると思います。

package serviceimport (    "testing"    "github.com/cloudandbuild/blog-article-resrouces/content/blog/article-5/interfaces"    "github.com/cloudandbuild/blog-article-resrouces/content/blog/article-5/interfaces/interfacesmocks"    "github.com/cloudandbuild/blog-article-resrouces/content/blog/article-5/testutil"    "github.com/golang/mock/gomock"    "github.com/google/go-cmp/cmp"    "github.com/google/go-cmp/cmp/cmpopts")func TestAService_DoSomething(t *testing.T) {    type fields struct {        ARepository func(ctrl *gomock.Controller) interfaces.IRepository    }    tests := []struct {        name    string        fields  fields        wantErr bool    }{        {            name: "success",            fields: fields{                ARepository: func(ctrl *gomock.Controller) interfaces.IRepository {                    ret := interfacesmocks.NewMockIRepository(ctrl)                    ret.EXPECT().Update(                        testutil.NewMatcher(                            &interfaces.Data{                                ID: 1,                            },                            func(a, b interface{}) bool {                                if diff := cmp.Diff(a, b, cmpopts.IgnoreFields(interfaces.Data{}, "UpdatedAt")); diff != "" {                                    t.Errorf("MockSomeRepository.Update mismatch (-want +got): %s", diff)                                    return false                                }                                return true                            },                        ),                    ).Return(nil).Times(1)                    return ret                },            },            wantErr: false,        },    }    for _, tt := range tests {        t.Run(tt.name, func(t *testing.T) {            mockCtrl := gomock.NewController(t)            defer mockCtrl.Finish()            r := &AService{                ARepository: tt.fields.ARepository(mockCtrl),            }            if err := r.DoSomething(); (err != nil) != tt.wantErr {                t.Errorf("AService.DoSomething() error = %v, wantErr %v", err, tt.wantErr)            }        })    }}

テスト実行

go mod initgo mod tidygo generate ./...go test ./...?       github.com/cloudandbuild/blog-article-resrouces/content/blog/article-5/interfaces       [no test files]?       github.com/cloudandbuild/blog-article-resrouces/content/blog/article-5/interfaces/interfacesmocks       [no test files]?       github.com/cloudandbuild/blog-article-resrouces/content/blog/article-5/repository       [no test files]ok      github.com/cloudandbuild/blog-article-resrouces/content/blog/article-5/service  0.003s?       github.com/cloudandbuild/blog-article-resrouces/content/blog/article-5/testutil [no test files]

まとめ

今回はGoのgomockのカスタムMatcherをご紹介しました。 Goで開発し始めてからは、gomockをはじめとしてテストのツールが便利だったり書きやすいのでテストがはかどっています。単体テストあるのとないのでは、全然生産性違うのでぜひテスト筋力を鍛えて、テストカバー率を上げていきたいですね💪

全てのソースコードはこちら