go-cmpを使う理由とTipsの紹介
はじめに
これは Go 4 Advent Calendar 2020 8日目の記事です。
Goのテストにおいて、構造体を含めて型の値を比較したいという場合は往々にしてあります。ロジックの結果はなんらかの値として作用することが多いですから、型の値を比較したい、というのは自然です。私は型の値が等価であるかどうか判定するために、go-cmp
というライブラリを使うことが多いです。
しかしGoにおける等価性の仕様は決まっていますし、標準ライブラリの reflect
パッケージにも DeepEqual
という deeply equal,
かどうか判定するメソッドがあります。そこで本記事ではなぜわざわざ go-cmp
を使っているのかという理由と、go-cmp
を使ったときにどのようにして使うか?という go-cmp
の使う上でよく使う以下のTipsを提供したいと思います。
- Tips
go-cmpを使う理由
私は大きく以下の3つの理由から go-cmp
を使うケースが多いです。
- 値にDiffがあった場合に見やすい
- 比較対象を柔軟に制御しやすい
- テストの書き方は標準的な書き方によせたい
基本的には go-cmp
を用いた比較は reflect.DeepEqual
の上記互換と考えています。reflect.DeepEqual
の比較方法にふれる前に、以下のような構造体の値を ==
の等価演算子を用いて比較するテストを考えてみます。
- main_test.go
package main import "testing" type User struct { UserID string UserName string } func TestTom(t *testing.T) { tom := User{ UserID: "0001", UserName: "Tom", } tom2 := User{ UserID: "0001", UserName: "Tom", } if tom != tom2 { t.Errorf("User tom is mismatch, tom=%v, tom2=%v", tom, tom2) } }
=== RUN TestTom --- PASS: TestTom (0.00s) PASS
https://play.golang.org/p/WIvR4SGFJ87
上記のテストはPASSします。==
の等価演算子を用いた比較については Comparison operators に記載があります。つまり構造体の値は、構造体のすべてのフィールドが比較可能(つまり map
型や slice
型, func
型の値を含んでいないということです)の場合に比較できます。構造体のブランクフィールドでないフィールドが等しい場合に等価とみなされます。
slice
型を ==
で比較するとどうなるでしょうか?構造体の中に slice
型を含んでいる値を ==
で比較した場合はコンパイルエラーになります。
https://play.golang.org/p/Wn2MMNAxoTT
上記のGo Playgroundの例にあるような slice
型を含む場合や map
型の値、ポインタが参照する値などを比較したい場面は多くあります。その場合に標準ライブラリの reflect.DeepEqual
を用いると deeply equal,
に判定することができます。slice
型の場合に限れば、スライスの要素の値が deeply equal,
であれば等しいとされます。[]string
であれば、string
の slice
の値が要素の位置まで含めて同じ場合に等価と判定でき、多くの場面でこの deeply equal,
の等価判定が役に立ちます。
つまり reflect.DeepEqual
を使った以下のテストはPASSします。
func TestTom(t *testing.T) { tom := User{ UserID: "0001", UserName: "Tom", Languages: []string{"Java", "Go"}, } tom2 := User{ UserID: "0001", UserName: "Tom", Languages: []string{"Java", "Go"}, } if !reflect.DeepEqual(tom, tom2) { t.Errorf("User tom is mismatch, tom=%v, tom2=%v", tom, tom2) } }
=== RUN TestSlice --- PASS: TestSlice (0.00s) PASS
https://play.golang.org/p/HnX_HO-s9Y3
さて、go-cmp
を使った場合ですが、reflect.DeepEqual
を使った場合と同様にテストはPASSします。
func TestTom(t *testing.T) { // ... if diff := cmp.Diff(tom, tom2); diff != "" { t.Errorf("User value is mismatch (-tom +tom2):\n%s", diff) } }
=== RUN TestTom --- PASS: TestTom (0.00s) PASS
https://play.golang.org/p/whGERlULOXt
- 値にDiffがあった場合に見やすい
go-cmp
を使う理由の1つとして値にDiffがあった場合に reflect.DeepEqual
よりもDiffがわかりやすい(個人差あり)ということがあります。仮にスライスの値に差があった場合を考えてみます。
reflect.DeepEqual
の場合
package main import ( "reflect" "testing" ) type User struct { UserID string UserName string Languages []string } func TestTom(t *testing.T) { tom := User{ UserID: "0001", UserName: "Tom", Languages: []string{"Java", "Go"}, } tom2 := User{ UserID: "0001", UserName: "Tom", Languages: []string{"Ruby", "Go"}, } if !reflect.DeepEqual(tom, tom2) { t.Errorf("User tom is mismatch, tom=%v, tom2=%v", tom, tom2) } }
Diffの表示は以下のようになります。つまり開発者が t.Errorf
で表示している文字列です。
=== RUN TestTom prog.go:28: User tom is mismatch, tom={0001 Tom [Java Go]}, tom2={0001 Tom [Ruby Go]} --- FAIL: TestTom (0.00s) FAIL
https://play.golang.org/p/yEg8hLy_bhY
go-cmp
を使った場合は以下のようになります。
package main import ( "testing" "github.com/google/go-cmp/cmp" ) type User struct { UserID string UserName string Languages []string } func TestTom(t *testing.T) { // ... if diff := cmp.Diff(tom, tom2); diff != "" { t.Errorf("User value is mismatch (-tom +tom2):\n%s", diff) } }
以下のように構造体の値でDiffがあったもののみが -
と +
で表示されます、Diffがあった値が直感的に分かるようになっています。構造体のフィールドが複雑になるほど、Diffが見やすいことが役に立ちます。
=== RUN TestTom prog.go:29: User value is mismatch (-tom +tom2): main.User{ UserID: "0001", UserName: "Tom", Languages: []string{ - "Java", + "Ruby", "Go", }, } --- FAIL: TestTom (0.00s) FAIL
https://play.golang.org/p/58FuMU6IUMm
また nil
スライスと空スライスの違いがあった場合に reflect.DeepEqual
では異なったものとしてみなされますが、出力する文字列だけではわかりにくい時もあります。
go-cmp
だと以下のように出力されるため nil
スライスと空スライスで値に差がある場合もわかりやすいです。
- Languages: []string{}, + Languages: nil,
- 比較対象を柔軟に制御しやすい
構造体の中で、あるフィールドは比較対象から除きたい場合などがあります。このような場合に go-cmp
のオプションを用いることで比較対象から簡単に除くことができます。この方法については以下の go-cmp
のTips の項目の中で紹介します。
- テストの書き方は標準的な書き方によせたい
GoのFAQ(Where is my favorite helper function for testing?)にもあるようにGoはテストのフレームワークを提供していません。とはいえ、一方でやはりチームによっては stretchr/testify などのフレームワークを使ったほうが生産性が上がる、などの意見もあります。どのようなライブラリを使うかはプロジェクトメンバの構成や状況によって変わるので一概に何が良い、と決めることはできません。
それでも私個人としてはテストはなるべく標準的な書き方によせていきたい、と考えています。go-cmp
は reflect.DeeqEqual
の代用程度として使うことができるため、全体的な書き方は標準的な書き方のままで大丈夫、という点も go-cmp
を使う理由の一つです。
go-cmpのTips
前置きが長くなりましたが、go-cmp
を使ったときのTipsを紹介します。なお go-cmp
のバージョンは2020/12/08現在最新の v0.5.4
を使っています。
unexportedなフィールドが存在する場合に比較する
go-cmp
のドキュメントにもあるように go-cmp
は reflect.DeepEqual
とは異なり、デフォルトではunexportedなフィールドは比較対象になりません。というかオプションを何も指定しないとドキュメントに記載されているように panic
になります。
package main import ( "testing" "github.com/google/go-cmp/cmp" ) func TestX(t *testing.T) { type X struct { numUnExport int NumExport int } num1 := X{100, -1} num2 := X{999, -1} if diff := cmp.Diff(num1, num2); diff != "" { t.Errorf("X value is mismatch (-num1 +num2):%s\n", diff) } }
=== RUN TestX --- FAIL: TestX (0.00s) panic: cannot handle unexported field at {main.X}.numUnExport: "main".X consider using a custom Comparer; if you control the implementation of type, you can also consider using an Exporter, AllowUnexported, or cmpopts.IgnoreUnexported [recovered] panic: cannot handle unexported field at {main.X}.numUnExport: "main".X consider using a custom Comparer; if you control the implementation of type, you can also consider using an Exporter, AllowUnexported, or cmpopts.IgnoreUnexported goroutine 18 [running]: testing.tRunner.func1.1(0x566380, 0xc00010a2f0) /usr/local/go-faketime/src/testing/testing.go:1072 +0x30d testing.tRunner.func1(0xc000106300) /usr/local/go-faketime/src/testing/testing.go:1075 +0x41a panic(0x566380, 0xc00010a2f0) /usr/local/go-faketime/src/runtime/panic.go:969 +0x1b9 github.com/google/go-cmp/cmp.validator.apply(0xc000144280, 0x565c80, 0xc0001001d0, 0xa2, 0x565c80, 0xc0001001e0, 0xa2) /tmp/gopath381553566/pkg/mod/github.com/google/go-cmp@v0.5.4/cmp/options.go:244 +0x68f github.com/google/go-cmp/cmp.(*state).tryOptions(0xc000144280, 0x5cb000, 0x565c80, 0x565c80, 0xc0001001d0, 0xa2, 0x565c80, 0xc0001001e0, 0xa2, 0xca20) /tmp/gopath381553566/pkg/mod/github.com/google/go-cmp@v0.5.4/cmp/compare.go:303 +0x132 github.com/google/go-cmp/cmp.(*state).compareAny(0xc000144280, 0x5c9aa0, 0xc00017c000) /tmp/gopath381553566/pkg/mod/github.com/google/go-cmp@v0.5.4/cmp/compare.go:258 +0x2ef github.com/google/go-cmp/cmp.(*state).compareStruct(0xc000144280, 0x5cb000, 0x5788a0, 0x5788a0, 0xc0001001d0, 0x99, 0x5788a0, 0xc0001001e0, 0x99) /tmp/gopath381553566/pkg/mod/github.com/google/go-cmp@v0.5.4/cmp/compare.go:427 +0x685 github.com/google/go-cmp/cmp.(*state).compareAny(0xc000144280, 0x5c9920, 0xc000108680) /tmp/gopath381553566/pkg/mod/github.com/google/go-cmp@v0.5.4/cmp/compare.go:286 +0xe35 github.com/google/go-cmp/cmp.Diff(0x5788a0, 0xc0001001d0, 0x5788a0, 0xc0001001e0, 0x0, 0x0, 0x0, 0x4f44e4, 0x65ea30) /tmp/gopath381553566/pkg/mod/github.com/google/go-cmp@v0.5.4/cmp/compare.go:119 +0xab main.TestX(0xc000106300) /tmp/sandbox897774203/prog.go:19 +0xc5 testing.tRunner(0xc000106300, 0x5a0348) /usr/local/go-faketime/src/testing/testing.go:1123 +0xef created by testing.(*T).Run /usr/local/go-faketime/src/testing/testing.go:1168 +0x2b3
https://play.golang.org/p/15Yu_aD4ahO
unexportedなフィールドをテストの比較対象にするときは cmp.AllowUnexported
を用います。差分があった場合はDiffが表示されます。
package main import ( "testing" "github.com/google/go-cmp/cmp" ) func TestX(t *testing.T) { type X struct { numUnExport int NumExport int } num1 := X{100, -1} num2 := X{999, -1} opt := cmp.AllowUnexported(X{}) if diff := cmp.Diff(num1, num2, opt); diff != "" { t.Errorf("X value is mismatch (-num1 +num2):%s\n", diff) } }
=== RUN TestX prog.go:22: X value is mismatch (-num1 +num2): main.X{ - numUnExport: 100, + numUnExport: 999, NumExport: -1, } --- FAIL: TestX (0.00s) FAIL
https://play.golang.org/p/L9P8p7ASLyo
unexportedなフィールドが存在する場合に無視する
unexportedなフィールドを比較対象から除くときは cmpopts.IgnoreUnexported
を使います。
package main import ( "testing" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" ) func TestX(t *testing.T) { type X struct { numUnExport int NumExport int } num1 := X{100, -1} num2 := X{999, -1} opt := cmpopts.IgnoreUnexported(X{}) if diff := cmp.Diff(num1, num2, opt); diff != "" { t.Errorf("X value is mismatch (-num1 +num2):%s\n", diff) } }
比較対象から除外されて、テストがPASSするようになったことが分かります。
=== RUN TestX --- PASS: TestX (0.00s) PASS
https://play.golang.org/p/Rfh4aVjHGNt
構造体のあるフィールドを比較対象から除く
時刻など構造体のうち一部のフィールドは比較対象から除きたい場合があります。一部のフィールドを比較対象から除外したい場合は cmpopts.IgnoreFields
を使います。除外したいフィールドを文字列で列挙することで比較対象から除くことができます。
以下の場合は X
構造体の CreateAt
と UpdateAt
の時刻に関するフィールドを比較対象から除外しています。
func TestX(t *testing.T) { type X struct { NumExport int CreateAt time.Time UpdateAt time.Time } num1 := X{-1, time.Date(2019, 1, 1, 0, 0, 0, 0, time.UTC), time.Now()} num2 := X{-1, time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), time.Now()} opt := cmpopts.IgnoreFields(X{}, "CreateAt", "UpdateAt") if diff := cmp.Diff(num1, num2, opt); diff != "" { t.Errorf("X value is mismatch (-num1 +num2):%s\n", diff) } }
=== RUN TestX --- PASS: TestX (0.00s) PASS
https://play.golang.org/p/9Geaky978t_J
構造体の中に構造体のスライスが含まれていて、そのスライスに含まれる構造体の一部のフィールドを比較対象から除外することも cmpopts.IgnoreFields
を指定することで除外可能です。
func TestX(t *testing.T) { type Item struct { HashKey string CreatedAt time.Time } type X struct { Items []Item } x1 := X{[]Item{ {HashKey: "aaa", CreatedAt: time.Date(2019, 1, 1, 0, 0, 0, 0, time.UTC)}, {HashKey: "bbb", CreatedAt: time.Date(2019, 1, 1, 0, 0, 0, 0, time.UTC)}, }} x2 := X{[]Item{ {HashKey: "aaa", CreatedAt: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)}, {HashKey: "bbb", CreatedAt: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)}, }} opt := cmpopts.IgnoreFields(Item{}, "CreatedAt") if diff := cmp.Diff(x1, x2, opt); diff != "" { t.Errorf("X value is mismatch (-x1 +x2):%s\n", diff) } }
=== RUN TestX --- PASS: TestX (0.00s) PASS
https://play.golang.org/p/KCtP21m6ioc
複数のオプションを指定する
複数の条件を cmp.Diff
のオプションに渡したい場合があります。例えば
- unexportedなフィールドを比較対象から除外
- 構造体の時刻に関するフィールドを比較対象から除外
といった上記の両方をオプションとして指定したい場合です。このような場合は cmp.Diff
のオプションに cmp.Option
のスライスを渡すことで複数の条件をオプションとして指定できます。
func TestX(t *testing.T) { type X struct { NumExport int numUnExport int CreateAt time.Time UpdateAt time.Time } num1 := X{100, -1, time.Date(2019, 1, 1, 0, 0, 0, 0, time.UTC), time.Now()} num2 := X{999, -111, time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), time.Now()} opts := []cmp.Option{ cmpopts.IgnoreUnexported(X{}), cmpopts.IgnoreFields(X{}, "CreateAt", "UpdateAt"), } if diff := cmp.Diff(num1, num2, opts...); diff != "" { t.Errorf("X value is mismatch (-num1 +num2):%s\n", diff) } }
時刻に関する項目の差分は比較対象から無視され、またunxeportedなフィールドも無視されていることがわかります。
=== RUN TestX prog.go:29: X value is mismatch (-num1 +num2): main.X{ - NumExport: 100, + NumExport: 999, ... // 3 ignored fields } --- FAIL: TestX (0.00s) FAIL
https://play.golang.org/p/uGFUSLEFwZF
スライスの要素の順序を無視する
構造体の中にスライスを含む場合に、スライスに含まれる値が一致していることを確認し、順序までは一致していなくてもOKとする場合です。通常はスライスの順序まで一致していることが求められます。
func TestX(t *testing.T) { type X struct { Numbers []int } num1 := X{[]int{1, 2, 3, 4, 5}} num2 := X{[]int{5, 4, 3, 2, 1}} if diff := cmp.Diff(num1, num2); diff != "" { t.Errorf("X value is mismatch (-num1 +num2):%s\n", diff) } }
スライスの順序が一致していない場合はDiffとして差分があるものとして検知されます。
=== RUN TestX prog.go:19: X value is mismatch (-num1 +num2): main.X{ Numbers: []int{ - 1, 2, 3, 4, 5, + 5, 4, 3, 2, 1, }, } --- FAIL: TestX (0.00s) FAIL
多くの場合は順序も含めて一致することを確認するとして問題ないですが、一部の状況ではスライスの中身が一致していればOKとみなしたい場合があるでしょう。その場合はスライスの中身をソートするようなオプションを指定するとうまくいきます。
func TestX(t *testing.T) { type X struct { Numbers []int } num1 := X{[]int{1, 2, 3, 4, 5}} num2 := X{[]int{5, 4, 3, 2, 1}} opt := cmpopts.SortSlices(func(i, j int) bool { return i < j }) if diff := cmp.Diff(num1, num2, opt); diff != "" { t.Errorf("X value is mismatch (-num1 +num2):%s\n", diff) } }
=== RUN TestX --- PASS: TestX (0.00s) PASS
https://play.golang.org/p/hLrpfqiSlii
ソートの条件は任意に実装できるため、例えば独自の構造体のスライスの順序をソートすることも可能です。例えば
type X struct { HashKey string SortKey string }
という構造体のスライスについての順序をソートするには、以下のような実装例が考えられます。
func TestX(t *testing.T) { x1 := []X{ {HashKey: "AAA", SortKey: "AAA"}, {HashKey: "BBB", SortKey: "BBB"}, {HashKey: "BBB", SortKey: "CCC"}, } x2 := []X{ {HashKey: "BBB", SortKey: "CCC"}, {HashKey: "AAA", SortKey: "AAA"}, {HashKey: "BBB", SortKey: "BBB"}, } opt := cmpopts.SortSlices(func(i, j X) bool { // ハッシュキーとソートキーの昇順でソート return i.HashKey < j.HashKey || (i.HashKey == j.HashKey && i.SortKey < j.SortKey) }) if diff := cmp.Diff(x1, x2, opt); diff != "" { t.Errorf("X value is mismatch (-x1 +x2):%s\n", diff) } }
=== RUN Test --- PASS: Test (0.00s) PASS
https://play.golang.org/p/8uUD5XKLYXs
空スライスとnilスライスの違いを無視する
テスト上は空スライスとnilスライスの違いを明確にしてテストすることで十分な場合がありますが、cmpopts.EquateEmpty()
をオプションに用いることで無視することもできます。
func TestX(t *testing.T) { type X struct { Numbers []int } num1 := X{[]int{}} num2 := X{nil} opt := cmpopts.EquateEmpty() if diff := cmp.Diff(num1, num2, opt); diff != "" { t.Errorf("X value is mismatch (-num1 +num2):%s\n", diff) } }
=== RUN TestX --- PASS: TestX (0.00s) PASS
https://play.golang.org/p/jdchcmmR5DF
その他
その他 float64
型の差分を加味して、一定の差分以内であれば等価とみなすような cmpopts.EquateApprox
や math.NaN()
と math.NaN()
の比較を等価とみなせるような cmpopts.EquateNaNs()
、time
に関する一定の差分を等価とみなすような cmpopts.EquateApproxTime
など様々なオプションがあります。
本記事に記載したものに比べるとユースケースは少ないかもしれませんが、様々なオプションが指定できるようになっています。詳しくは Documentation や util_test.go
の単体テストケースに記載されている内容を見るとよいでしょう。
まとめ
go-cmp
の使う理由やよく使うオプションのTipsを紹介しました。go-cmp
は reflect.DeppEqual
の代替として使うことができ、テストの書き方自体は標準の書き方を使うことができます。テスト結果のDiffが見やすくなるだけでも生産性は上がると思いますので、まだ使ったことがない方は導入を検討してみてはいかがでしょうか。