Carpe Diem

備忘録

Goのテストで一部のフィールドを対象外にしたい

背景

Goのテストを書いていると大半のフィールドは検査したいけれど

  • 自動生成しているUUIDのようにランダムになる部分
  • UpdatedAt, CreatedAtのように時刻のずれが影響する部分

を対象外としたいケースが出てきます。

単純に考えると以下のような方法が浮かびますが、それぞれ欠点があります。

  • 1つ1つのフィールドを書き出すとフィールドが追加されたときに漏れることがある
  • gomock.Any()にするとそれ以外のフィールドのテストができなくなる

そういった部分をどう除外するかを説明します。

環境

  • go v1.22.6
  • go-cmp v0.6.0
  • gomock v1.6.0

サンプルコード

次のようなよくあるコードを用意します。

import "github.com/google/uuid"

type User struct {
    ID      string
    Name    string
    Age     int
}

func NewUser(name string, age int) User {
    u := User{
        ID:   uuid.New().String(),
        Name: name,
        Age:  age,
    }
    return u
}

type UserRepository interface {
    Save(User) error
}

type UserService struct {
    repo UserRepository
}

func NewUserService(repo UserRepository) UserService {
    return UserService{repo: repo}
}

func (s UserService) Save(name string, age int) error {
    // UUIDを生成してIDにセット
    u := NewUser(name, age)

    // ユーザー情報を保存
    err := s.repo.Save(u)
    if err != nil {
        return err
    }

    return nil
}

NewUser内でUUIDを生成しているので、テストがしづらいコードになっています。
おそらく人によってはこのUUID生成部分を外から注入できるようにするといった対応をしているでしょうが、今回はそういった対応がされていないとします。

対応方法

通常のテストと、gomockでのモックのテストの2通りで説明します。

通常のテストの対応

go-cmpIgnoreFieldsを使うと、対象のフィールドをアサーションの対象外としてくれます。

func TestNewUser(t *testing.T) {
    type in struct {
        name    string
        age     int
    }
    tests := []struct {
        name string
        in   in
        out  mycmp.User
    }{
        {
            name: "success",
            in: in{
                name: "alice",
                age:  20,
            },
            out: mycmp.User{
                Name: "alice",
                Age:  20,
            },
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            out := mycmp.NewUser(tt.in.name, tt.in.age, tt.in.address)
            if diff := cmp.Diff(tt.out, out, cmpopts.IgnoreFields(mycmp.User{}, "ID")); diff != "" {
                t.Errorf("Mismatch (-expected +actual):\n%s", diff)
            }
        })
    }
}

こちらのコードのcmpopts.IgnoreFields(mycmp.User{}, "ID"))としている部分です。

mockコードの対応

gomockの引数のチェックの場合はもう一工夫が必要です。
gomockではカスタムMatcherを使うことで対象外にすることが可能です。

カスタムMatcherは次の実装をすることでinterfaceを満たします。

type Matcher interface {
    // Matches returns whether x is a match.
    Matches(x interface{}) bool

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

このように実装します。

type userMatcher struct {
    expected mycmp.User
}

func (m userMatcher) Matches(x interface{}) bool {
    actual, ok := x.(mycmp.User)
    if !ok {
        return false
    }

    // 一部のフィールドを無視して他のフィールドを比較
    return cmp.Equal(m.expected, actual, cmp.FilterPath(func(p cmp.Path) bool {
        if p.String() == "ID" {
            return true
        }
        return false
    }, cmp.Ignore()))
}

func (m userMatcher) String() string {
    return "matches User with ID ignored"
}

モックを使ったテストコードは次のようになります。

func TestUserService_Save(t *testing.T) {
    type in struct {
        name    string
        age     int
    }
    tests := []struct {
        name     string
        injector func(*mock_cmp.MockUserRepository)
        in       in
        err      error
    }{
        {
            name: "success",
            injector: func(m *mock_cmp.MockUserRepository) {
                m.EXPECT().Save(userMatcher{
                    expected: mycmp.User{
                        Name: "test",
                        Age:  20,
                    }}).Return(nil)
            },
            in: in{
                name: "test",
                age:  20,
            },
            err: nil,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            ctrl := gomock.NewController(t)
            m := mock_cmp.NewMockUserRepository(ctrl)
            tt.injector(m)

            s := mycmp.NewUserService(m)
            err := s.Save(tt.in.name, tt.in.age)
            assert.Equal(t, tt.err, err)
        })
    }
}

今回はcmp.FilterPathを使っていますが、先ほどのcmp.IgnoreFieldsはこのFilterPathのシンタックスシュガーですので同じです。
細かい設定がしたい場合はこちらのcmp.FilterPathの方が書きやすいでしょう。

その他

ネストした場合

次のようにネストしているフィールドを持っている場合、

type User struct {
        ID      string
        Name    string
        Age     int
        Address Address
}

type Address struct {
        ZipCode string
        Pref    string
        City    string
        Street  string
}

go-cmpは次のように指定すると対象にできます。

cmp.Diff(tt.out, out, cmpopts.IgnoreFields(mycmp.User{}, "ID", "Address.Street"))

サンプルコード

本日のサンプルコードはこちら

github.com

まとめ

Goのテストで特定のフィールドだけ対象外にする方法を紹介しました。