技術メモ

技術メモ

ラフなメモ

go-cmpを使う理由とTipsの紹介

はじめに

これは Go 4 Advent Calendar 2020 8日目の記事です。

Goのテストにおいて、構造体を含めて型の値を比較したいという場合は往々にしてあります。ロジックの結果はなんらかの値として作用することが多いですから、型の値を比較したい、というのは自然です。私は型の値が等価であるかどうか判定するために、go-cmp というライブラリを使うことが多いです。

github.com

しかしGoにおける等価性の仕様は決まっていますし、標準ライブラリの reflect パッケージにも DeepEqual という deeply equal, かどうか判定するメソッドがあります。そこで本記事ではなぜわざわざ go-cmp を使っているのかという理由と、go-cmp を使ったときにどのようにして使うか?という go-cmp の使う上でよく使う以下の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 であれば、stringslice の値が要素の位置まで含めて同じ場合に等価と判定でき、多くの場面でこの 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-cmpreflect.DeeqEqual の代用程度として使うことができるため、全体的な書き方は標準的な書き方のままで大丈夫、という点も go-cmp を使う理由の一つです。

go-cmpのTips

前置きが長くなりましたが、go-cmp を使ったときのTipsを紹介します。なお go-cmp のバージョンは2020/12/08現在最新の v0.5.4 を使っています。

unexportedなフィールドが存在する場合に比較する

go-cmp のドキュメントにもあるように go-cmpreflect.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 構造体の CreateAtUpdateAt の時刻に関するフィールドを比較対象から除外しています。

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.EquateApproxmath.NaN()math.NaN() の比較を等価とみなせるような cmpopts.EquateNaNs()time に関する一定の差分を等価とみなすような cmpopts.EquateApproxTime など様々なオプションがあります。

本記事に記載したものに比べるとユースケースは少ないかもしれませんが、様々なオプションが指定できるようになっています。詳しくは Documentationutil_test.go単体テストケースに記載されている内容を見るとよいでしょう。

まとめ

go-cmp の使う理由やよく使うオプションのTipsを紹介しました。go-cmpreflect.DeppEqual の代替として使うことができ、テストの書き方自体は標準の書き方を使うことができます。テスト結果のDiffが見やすくなるだけでも生産性は上がると思いますので、まだ使ったことがない方は導入を検討してみてはいかがでしょうか。