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

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

要約

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

課題

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

  • interfaces/interfaces.go
package interfaces
import "time"
//go:generate go install github.com/golang/mock/mockgen
//go:generate mockgen -destination=${GOPACKAGE}mocks/${GOFILE} -package=${GOPACKAGE}mocks -source=$GOFILE
type IRepository interface {
    Update(data *Data) error
}
type Data struct {
    ID        int
    UpdatedAt time.Time
}
  • repository/repository.go
package repository
import "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 service
import (
    "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 testutil
import (
    "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 service
import (
    "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 init
go mod tidy
go 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をはじめとしてテストのツールが便利だったり書きやすいのでテストがはかどっています。単体テストあるのとないのでは、全然生産性違うのでぜひテスト筋力を鍛えて、テストカバー率を上げていきたいですね💪

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