Goの構造体を柔軟に比較したい

Goの構造体を比較するテストを書くときにgo-cmpパッケージが便利だった。

フィールドの一部を無視して比較する

エンティティに自動生成のIDやタイムスタンプが含まれている場合、例えばtestifyassert.Equalなどを使って単純に比較するのが難しい。

そこでcmpopts.IgnoreFieldsを使って、それらのフィールドを無視して構造体を比較するテストを書いてみる。

time.Now()によって作成されるCreatedAtフィールドを無視する例

package main

import (
	"testing"
	"time"

	"github.com/google/go-cmp/cmp"
	"github.com/google/go-cmp/cmp/cmpopts"
)

func TestHoge(t *testing.T) {
	type Hoge struct {
		Name      string
		CreatedAt time.Time
	}

	want := Hoge{"Bob", time.Now()}
	got := Hoge{"Tom", time.Now()}

	opts := []cmp.Option{
		cmpopts.IgnoreFields(Hoge{}, "CreatedAt"),
	}

	if diff := cmp.Diff(want, got, opts...); diff != "" {
		t.Errorf("diff: -want, +got:\n%s", diff)
	}
}

ログもこんな感じでとても見やすい

--- FAIL: TestHoge (0.00s)
    ~/dev/go-cmp/main_test.go:26: diff: -want, +got:
          main.Hoge{
        - 	Name: "Bob",
        + 	Name: "Tom",
          	... // 1 ignored field
          }
FAIL
FAIL	go-cmp	0.363s
FAIL

フィールドの一部を修正したうえで比較する

こちらはあまり登場機会が多くないかもしれないが、例えば次のようなときに使える。

エンティティがある条件を満たす時だけ、別のエンティティをID参照する例

package main

import (
	"testing"

	"github.com/google/go-cmp/cmp"
	"github.com/google/go-cmp/cmp/cmpopts"
	"github.com/google/uuid"
)

type Foo struct {
	ID FooID
}

type FooID uuid.UUID

func NewFoo() *Foo {
	uuid, _ := uuid.NewUUID()
	return &Foo{
		ID: FooID(uuid),
	}
}

type Hoge struct {
	Name  string
	FooID *FooID
}

func NewHoge(name string) *Hoge {
	return &Hoge{
		Name: name,
	}
}

func CreateHoge(name string) *Hoge {
	hoge := NewHoge(name)

	// ある条件の時はFooが作られて、HogeはFooをID参照する想定
	foo := NewFoo()
	hoge.FooID = &foo.ID

	return hoge
}


func TestCreateHoge(t *testing.T) {
	uuid1, _ := uuid.NewUUID()
	id := FooID(uuid1)

	want := &Hoge{Name: "Bob", FooID: &id}
	got := CreateHoge("Bob")

	opts := []cmp.Option{
		cmpopts.IgnoreFields(Hoge{}, "FooID"),
	}

	if diff := cmp.Diff(want, got, opts...); diff != "" {
		t.Errorf("diffs: (-want, +got):%s", diff)
	}
}

先ほどと同じようにcmpopts.IgnoreFieldsを使ってHoge.FooIDを無視することもできるが、テストの信頼性を高めるためにHoge.FooIDが存在することを確かめるテストを書きたい。 (またはその逆)

func TestCreateHoge(t *testing.T) {
	uuid1, _ := uuid.NewUUID()
	dummyID := FooID(uuid1)

	opts := []cmp.Option{
		cmpopts.AcyclicTransformer("FooID", func(id *FooID) *FooID {
			if id == nil {
				return nil
			} else {
				return &dummyID
			}
		}),
	}

	tests := map[string]struct {
		want *Hoge
	}{
		"FooIDが存在してほしいケース": {
			want: &Hoge{Name: "Bob", FooID: &dummyID},
		},
		"FooIDが存在してほしくないケース": {
			want: &Hoge{Name: "Bob"},
		},
	}

	for name, tt := range tests {
		tt := tt
		t.Run(name, func(t *testing.T) {
			t.Parallel()

			got := CreateHoge("Bob")

			if diff := cmp.Diff(tt.want, got, opts...); diff != "" {
				t.Errorf("diffs: (-want, +got):\n%s", diff)
			}
		})
	}
}
--- FAIL: TestCreateHoge (0.00s)
    --- FAIL: TestCreateHoge/FooIDが存在してほしくないケース (0.00s)
        ~/dev/go-cmp/main_test.go:79: diffs: (-want, +got):
              &main.Hoge{
              	Name:  "Bob",
            - 	FooID: Inverse(FooID, (*main.FooID)(nil)),
            + 	FooID: Inverse(FooID, &main.FooID{
            + 		0xd5, 0xa3, 0x8e, 0x3a, 0x62, 0x30, 0x11, 0xed, 0xa2, 0xb8, 0x6e, 0xde, 0x53,
            + 		0x05, 0x13, 0xe7,
            + 	}),
              }
FAIL
FAIL	go-cmp	0.335s
FAIL

色々と簡略化したがcmpopts.AcyclicTransformerを使ってこのように書くことができる

FooIDをダミーに入れ替えることで構造体自体の比較はパスする、そのうえでFooIDが存在してほしいのにnilのパターン、または逆のパターンの時はテストが落ちるようになる。

あとがき

他のオプションも試してみたい。