testify の assert を使ってテストを書く

注: 本記事は元々 Qiita にて公開していたものの再掲です。

概要

  • testify
  • golang でテストをより記述しやすくするためのパッケージ
  • その中で assert パッケージは、結果値の妥当性をチェックする関数を提供する

本記事で使用したバージョン

  • go: 1.16
  • testify 1.7.0

テストの書き方と結果出力

import

import (
    // ... 他の必要なパッケージ

    "github.com/stretchr/testify/assert"
)

引数の指定

(例) Equal 関数

assert.Equal(t, expected, got, description)
  • 1番目の引数は、testing.T
  • そのあと、テスト関数ごとに指定の引数を追加する
    • 2つの値を比較するとき、大抵は 期待される値 実際の値 の順だが、たまに逆の関数もある
    • 指定の順番を間違えると、テスト失敗時の出力で逆に表示されてしまう
  • 引数の末尾にテストの説明を任意で追加することができる。(なくてもよい)
type Member (
    Name string
    Grade int
)

func TestEqual(t *testing.T) {
    expected := &Member{Name: "Niko", Grade: 3}

    assert.Equal(t,
        expected,
        &Member{Name: "Niko", Grade: 2},
        "%s is 3rd grade", expected.Name)
}

結果表示

  • テストに失敗すると、以下のような内容が出力される
=== RUN   TestEqual
    assert_test.go:27:
                Error Trace:
                Error:          Not equal:
                                expected: &assertion_test.Member{Name:"Niko", Grade:2}
                                actual  : &assertion_test.Member{Name:"Niko", Grade:3}

                                Diff:
                                --- Expected
                                +++ Actual
                                @@ -2,3 +2,3 @@
                                  Name: (string) (len=4) "Niko",
                                - Grade: (int) 2
                                + Grade: (int) 3
                                 })
                Test:           TestEqual
                Messages:       Niko is 3rd grade
--- FAIL: TestEqual (0.00s)

関数

以下の関数で、カッコ内でプレフィクス Not または No がついているものは、元のテストの逆の結果になることのテスト関数。

等価性チェック

Equal(NotEqual)

Equal(t TestingT, expected, actual interface{}, msgAndArgs ...interface{}) bool
  • 実際値が期待値と一致していることをテストする
  • 値はスカラー値だけでなく、構造体のインスタンス等でも比較可能
    • 唯一、function 同士の比較はできない
type Member struct {
    Name  string
    Grade int
}

func TestEqual(t *testing.T) {
    num := 111
    str := "Honoka"
    arr := []string{"Kotori", "Umi", "Hanayo"}
    niko := &Member{
        Name:  "Niko",
        Grade: 3,
    }

    assert.Equal(t, 111, num)
    assert.NotEqual(t, 222, num)
    assert.Equal(t, "Honoka", str)
    assert.Equal(t, []string{"Kotori", "Umi", "Hanayo"}, arr)
    assert.Equal(t, &Member{Name: "Niko", Grade: 3}, niko, "%s is 3rd grade", niko.Name)
}

EqualValues(NotEqualValues)

EqualValues(t TestingT, expected, actual interface{}, msgAndArgs ...interface{}) bool
  • 2つの値が同一かどうかをテストする
  • Equal との違いは、型が同一でなくても、型変換により同一とみなせる場合はテストが成功する点
func TestEqualValues(t *testing.T) {
    assert.EqualValues(t, uint(123), int(123))
}

Same(NotSame)

Same(t TestingT, expected, actual interface{}, msgAndArgs ...interface{}) bool
  • 2つのポインタ値が同一オブジェクトを指していることをテストする
  • Same であるためには以下の両方の条件を満たす必要がある
    • ポインタが同一オブジェクトを指している(ポインタ値が同一)
    • 同一の型である
func TestSame(t *testing.T) {
    eri := &Member{
        Name:  "Eri",
        Grade: 3,
    }
    ptrEri := eri

    type Student *Member
    sEri := Student(eri)

    assert.Same(t, eri, ptrEri)
    assert.NotSame(t, &Member{Name: "Eri", Grade: 3}, ptrEri)
    // assert.Same(t, eri, sEri, "not same types") // fail
}
  • 一番下のテストは、ポインタは同一(0xc0000b61f8)だが、型が異なるため失敗する
    assert_test.go:42:
                Error Trace:
                Error:          Not same:
                                expected: 0xc0000b61f8 &assertion_test.Member{Name:"Eri", Grade:3}
                                actual  : 0xc0000b61f8 &assertion_test.Member{Name:"Eri", Grade:3}
                Test:           TestSame
                Messages:       not same types: *assertion_test.Member, assertion_test.Student
--- FAIL: TestSame (0.00s)

Exactly

  • 2つのオブジェクトが同一かどうかをテストする
  • TODO: すみません Equal との違いが不明です

特定の条件を満たす値かどうか

Nil(NotNil)

Nil(t TestingT, object interface{}, msgAndArgs ...interface{}) bool
  • オブジェクトが nil であるかをテストする

Empty(NotEmpty)

Empty(t TestingT, object interface{}, msgAndArgs ...interface{}) bool
  • オブジェクトの内容が空であることをテストする
  • 次項の「EmptyZero の違い」も参照

Zero(NotZero)

Zero(t TestingT, i interface{}, msgAndArgs ...interface{}) bool
  • 値がその型のzero valueであることをテストする
EmptyZero の違い
  • Zero は、値そのものが zero value であることをチェックする
    • array, slice, map, channel, ポインタの場合
      • 値が nil かどうか
  • Empty は、その値が指し示すものが「空」とみなせるかどうかをチェックする
    • array, slice, map, channel の場合
      • 長さが 0 であるかどうか
    • ポインタの場合
      • それが指し占めす値が空であれば、空とみなされる
func TestEmptyZero(t *testing.T) {
    i := 0
    ip := &i

    assert.Empty(t, i)
    assert.Empty(t, ip)    // ip が指し示す i は zero value

    assert.Zero(t, i)
    assert.NotZero(t, ip)  // ip 自体は値を持つ

    var se := []string{}   // []string{}
    var sn []string        // []string(nil)

    assert.Empty(t, se)
    assert.Empty(t, sn)

    assert.NotZero(t, se)  //  空の slice を持つ
    assert.Zero(t, sn)     //  slice 自体がない
}

True/False

True(t TestingT, value bool, msgAndArgs ...interface{}) bool
  • オブジェクトの値が true または false と判定されるかどうかをテストする

Len

Len(t TestingT, object interface{}, length int, msgAndArgs ...interface{}) bool
  • オブジェクトの長さが期待通りであることをテストする
  • built-in 関数の len() を実行可能なオブジェクトに対してのみ適用できる
    • 適用できない場合はテストに失敗する
func TestLen(t *testing.T) {
    assert.Len(t, "Rin", 3)
}

Regexp(NotRegexp)

Regexp(t TestingT, rx interface{}, str interface{}, msgAndArgs ...interface{}) bool
  • 実際に得られた文字列が、指定の正規表現に合致することをテストする
  • 期待される正規表現(rx)は、*regexp.Regexp 型、もしくは string で表現された正規表現のどちらでもよい
func TestRegexp(t *testing.T) {
    str := "nikko nikko nii"
    assert.Regexp(t, regexp.MustCompile("^(nikko )+nii$"), str)
    assert.Regexp(t, "^(nikko )+nii$", str)
    assert.NotRegexp(t, "^(nikko ){3}nii$", str)
}

リスト

Contains/NotContains

Contains(t TestingT, s, contains interface{}, msgAndArgs ...interface{}) bool
  • オブジェクトが特定の値を含むことをテストする
  • 適用可能なのは、string、リスト(array, slice 等 len() の適用が可能なもの)、map に限られる
    • それ以外のオブジェクトに適用した場合はテストに失敗する
  • string の場合、部分文字列として含まれるかどうかをテストする
  • リストの場合、特定の要素がリスト内に存在するかどうかをテストする
  • map の場合、特定の key が map 内に含まれるかどうかをテストする
    • map の value は比較の対象外
func TestContains(t *testing.T) {
    assert.Contains(t, "Maki Nishikino", "Maki")
    assert.Contains(t, []string{"Maki", "Hanayo", "Rin"}, "Maki")
    assert.Contains(t, map[string]int{"Maki": 1, "Honoka": 2, "Niko": 3}, "Maki")
    // map の value は対象外
    assert.NotContains(t, map[string]int{"Maki": 1, "Honoka": 2, "Niko": 3}, 3)
}

Subset(NotSubset)

Subset(t TestingT, list, subset interface{}, msgAndArgs ...interface{}) (ok bool)
  • リスト型のオブジェクトに対して、期待されたサブセットが含まれるかどうかをテストする
func TestSubset(t *testing.T) {
    members := []string{"Kotori", "Kayo", "Eri"}
    assert.Subset(t, members, []string{"Kotori", "Eri"})
    assert.NotSubset(t, members, []string{"Kotori", "Yukiho"})
}

ElementsMatch

ElementsMatch(t TestingT, listA, listB interface{}, msgAndArgs ...interface{}) (ok bool)
  • リスト型のオブジェクトに対して、すべての要素が含まれていることをテストする
  • 要素の順番は問わない。ただし個数は一致する必要がある
func TestElementsMatch(t *testing.T) {
    members := []string{"Kotori", "Kayo", "Eri", "Kayo"}
    assert.ElementsMatch(t, members, []string{"Eri", "Kayo", "Kayo", "Kotori"})
    //assert.ElementsMatch(t, members, []string{"Eri", "Kotori", "Kayo", "Kotori"})  //fail
}

誤差の判定

WithinDuration

WithinDuration(
    t TestingT,
    expected, actual time.Time,
    delta time.Duration,
    msgAndArgs ...interface{}) bool
  • time.Time で表される時刻と、期待する時刻との誤差が一定以下であることをテストする
func TestWithinDuration(t *testing.T) {
    now := time.Now()
    expected := now.Add(2 * time.Second)

    assert.WithinDuration(t, expected, now, time.Duration(5*time.Second))
    //assert.WithinDuration(t, expected, now, time.Duration(1*time.Second)) // fail
}

InDelta

InDelta(t TestingT, expected, actual interface{}, delta float64, msgAndArgs ...interface{}) bool
  • 両者の誤差が一定値(delta)以下であることをテストする。
func TestInDelta(t *testing.T) {
    val := 10.0 / 2.998

    assert.InDelta(t, 10.0, val*2.99, 0.1)
}

InDeltaSlice

InDeltaSlice(t TestingT, expected, actual interface{}, delta float64, msgAndArgs ...interface{}) bool
  • InDelta のスライス版
  • 2つのスライスに含まれる同一位置の数値同士を比較し、その誤差が一定値(delta)以下であることをテストする
  • テストされる側(actual)のすべての要素に対してテストが通った場合に、InDeltaSlice のテストも通る
func TestInDeltaSlice(t *testing.T) {
    expect := []float32{1.0, 2.0, 3.0}
    actual := []float32{1.03, 1.98, 3.01}

    assert.InDeltaSlice(t, expect, actual, 0.03)
}

InDeltaMapValues

InDeltaMapValues(t TestingT, expected, actual interface{}, delta float64, msgAndArgs ...interface{}) bool
  • 比較対象は map 型
  • すべての key に対応する値を期待値、実際値で比較し、その誤差が範囲内に収まっていることをテストする
  • key が完全に一致しない場合は FAIL となる
func TestInDeltaMapValues(t *testing.T) {
    expected := map[string]float64{"width": 2.5, "height": 7.5}
    actual := map[string]float64{"width": 2.47, "height": 7.53}

    assert.InDeltaMapValues(t, expected, actual, 0.1)
}

InEpsilon

InEpsilon(t TestingT, expected, actual interface{}, epsilon float64, msgAndArgs ...interface{}) bool
  • 期待値対する実測値の誤差が一定割合以内に収まっていることをテストする
  • InDelta と似ているが、InEpsilon では誤差の「割合」をチェックする
func TestInEpsilon(t *testing.T) {
    assert.InEpsilon(t, 10000, 10090, 0.01)
    assert.InEpsilon(t, 100, 100.9, 0.01)
    // assert.InEpsilon(t, 1, 0.11, 0.01)  <-- fail
}

InEpsilonSlice

InEpsilonSlice(t TestingT, expected, actual interface{}, epsilon float64, msgAndArgs ...interface{}) bool
  • InEpsilon のスライス版

ファイル

FileExists(NoFileExists)

FileExists(t TestingT, path string, msgAndArgs ...interface{}) bool
  • 指定したファイルが存在することをテストする
  • 指定したパスがディレクトリである場合はテスト失敗となる
func TestFileExists(t *testing.T) {
    // exists.txt はすでに存在し、missing.txt はないものとする
    assert.FileExists(t, "./exists.txt")
    assert.NoFileExists(t, "./missing.txt")

    // existsdir は存在するディレクトリ
    assert.NoFileExists(t, "./existsdir")
}

DirExists(NoDirExists)

func DirExists(t TestingT, path string, msgAndArgs ...interface{}) bool {
  • 指定したディレクトリが存在することをテストする
  • 指定したパスがファイルであった場合はテストに失敗する
func TestDirExists(t *testing.T) {
    // existsdir は存在するディレクトリ、missingdir はないものとする
    assert.DirExists(t, "./existsdir")
    assert.NoDirExists(t, "./missingdir")

    // exists.txt はファイル
    assert.NoDirExists(t, "./exists.txt")
}

JSON, YAML

JSONEq

JSONEq(t TestingT, expected string, actual string, msgAndArgs ...interface{}) bool
  • 実際値のJSON文字列が、期待されるJSON文字列と内容的に同一であることをテストする
    • 文字列内での順番が異なってもかまわない
func TestJSONEq(t *testing.T) {
    expected := `{"name": "Nozomi", "age": 17, "blood": "O"}`
    actual := `{"age": 17, "blood": "O", "name": "Nozomi"}`

    assert.JSONEq(t, expected, actual)
}

YAMLEq

YAMLEq(t TestingT, expected string, actual string, msgAndArgs ...interface{}) bool
  • 実際値のYAML文字列が、期待されるYAML文字列と内容的に同一であることをテストする
    • 文字列内での順番が異なってもかまわない
func TestYAMLEq(t *testing.T) {
    expected := `---
name: Eri
age: 17
blood: B`
    actual := `---
blood: B
age: 17
name: Eri`

    assert.YAMLEq(t, expected, actual)
}

状態変化

Eventually

Eventually(
    t TestingT,
    condition func() bool,
    waitFor time.Duration,
    tick time.Duration,
    msgAndArgs ...interface{}) bool
  • 一定時間内(waitFor)に条件が満たされた状態になることをテストする
  • 条件が満たされているかをチェックするための関数(condition)を引数で渡す
    • 条件が満たされたら true、そうでなければ false を返す
    • この関数は tick で指定した時間おきに呼び出される
func TestEventually(t *testing.T) {
    var content []byte
    go func() {
        res, err := http.Get("https://example.com")
        if err != nil {
            return
        }
        defer res.Body.Close()
        content, _ = io.ReadAll(res.Body)
    }()

    // コンテンツが読み込まれたかをチェックする関数
    checkCompleted := func() bool {
        if len(content) > 0 {
            return true
        }
        return false
    }

    assert.Eventually(t, checkCompleted, time.Second*5, time.Second*1)
}

Never

Never(
    t TestingT,
    condition func() bool,
    waitFor time.Duration,
    tick time.Duration,
    msgAndArgs ...interface{}) bool
  • Eventually の逆。一定時間内に条件が満たされないことをテストする。

エラーおよび Panic

Error(NoError)

Error(t TestingT, err error, msgAndArgs ...interface{}) bool
  • error が存在する(nil でない)ことをテストする
func TestError(t *testing.T) {
    divideFunc := func(a, b int) (int, error) {
        if b == 0 {
            return 0, fmt.Errorf("division by zero")
        }
        return a / b, nil
    }

    _, err := divideFunc(5, 0)
    assert.Error(t, err)

    _, err = divideFunc(5, 2)
    assert.NoError(t, err)
}

EqualError

EqualError(t TestingT, theError error, errString string, msgAndArgs ...interface{}) bool
  • 得られたエラーが、期待される文字列表現と合致するかどうかをテストする
  • テスト対象(theError)のエラーの文字列表現が errString と完全一致していればよい
  • 他の assert 関数と引数の順番が異なるので注意
    • 実際に得られたエラー 期待されるエラー文字列 の順
var firstGrades map[string]string = map[string]string{
    "Hanayo": "Koizumi",
    "Rin":    "Hoshizora",
    "Maki":   "Nishikino",
}

type ErrorNotFirstGrade struct{}

func (e *ErrorNotFirstGrade) Error() string {
    return "not a first grade member"
}

func getLastName(firstName string) (string, error) {
    ln, ok := firstGrades[firstName]
    if !ok {
        return "", new(ErrorNotFirstGrade)
    }

    return ln, nil
}

func TestEqualError(t *testing.T) {
    _, err := getLastName("Honoka")
    assert.EqualError(t, err, "not a first grade member")
}

ErrorContains

ErrorContains(t TestingT, theError error, contains string, msgAndArgs ...interface{}) bool
  • 得られたエラーが、期待される文字列を含んでいるかどうかをテストする
  • テスト対象(theError)のエラーの文字列表現が errString を含んでいればよい
  • EqualError と同様、他の assert 関数と引数の順番が異なるので注意
    • 実際に得られたエラー 期待される部分エラー文字列 の順
func TestErrorContains(t *testing.T) {
    err := errors.New("Honoka and Hanayo were captured by Umi in front of a rice restaurant")
    assert.ErrorContains(t, err, "captured")
}

ErrorIs(NotErrorIs)

ErrorIs(t TestingT, err, target error, msgAndArgs ...interface{}) bool
  • 対象エラーのエラーチェーンの中に、特定のエラーが含まれていることをテストする
  • エラーチェーンの探索には errors.Is()(https://golang.org/pkg/errors/#Is) が使用される
func TestErrorIs(t *testing.T) {
    errA := errors.New("Umi cannot write lyrics")
    errB := fmt.Errorf("Umi has escaped: %w", errA)
    errC := fmt.Errorf("Umi not found: %w", errB)

    assert.ErrorIs(t, errC, errA)
}

ErrorAs

ErrorAs(t TestingT, err error, target interface{}, msgAndArgs ...interface{}) bool
  • 対象エラーのエラーチェーンの中に、target に代入可能なエラーが含まれていることをテストする
  • テストが通った場合、target にそのエラーが格納される
  • エラーチェーンの探索および target への代入は、errors.As()(https://golang.org/pkg/errors/#As) の挙動と同じ
type KotoriSlumpError struct{}

func (e *KotoriSlumpError) Error() string {
    return "Kotori cannot design dresses"
}

func TestErrorAs(t *testing.T) {
    errA := &KotoriSlumpError{}
    errB := fmt.Errorf("Kotori has escaped: %w", errA)
    errC := fmt.Errorf("Kotori not found: %w", errB)

    var err *KotoriSlumpError

    assert.ErrorAs(t, errC, &err)
    fmt.Println(err) // Kotori cannot design dresses
}

Panics(NotPanics)

Panics(t TestingT, f PanicTestFunc, msgAndArgs ...interface{}) bool
  • 指定された関数が panic を起こすことをテストする
  • 指定できる関数の型は func() (assert.PanicTestFunc 型)
  • recover() 実行時に得られる panic value を確認する PanicsWithValue PanicsWithError も提供されている
func TestPanics(t *testing.T) {
    setQuantity := func(qty int) func() {
        return func() {
            if qty <= 0 {
                panic(fmt.Errorf("qty cannot be negative"))
            }
        }
    }
    assert.PanicsWithError(t, "qty cannot be negative", setQuantity(-2))
    assert.NotPanics(t, setQuantity(5))
}

複雑な条件を使う

Condition

Condition(t TestingT, comp Comparison, msgAndArgs ...interface{}) bool
  • テストの成否を複雑な条件で判定させたいときに使う
  • 成否判定を行う func() bool 型の関数を引数で渡す
type Member struct {
    Name  string
    Grade int
}

func TestComparison(t *testing.T) {
    is3rdGrade := func(m Member) func() bool {
        return func() bool { return m.Grade == 3 }
    }

    assert.Condition(t, is3rdGrade(Member{"Niko", 3}))

    //assert.Condition(t, is3rdGrade(Member{"Honoka", 2}))  // fail
}

個人的見解

以上のように、assert には多くのテスト関数が用意されている。しかし実際には Equal() のみ使えば十分で、それ以外の関数は Equal() と go 標準の文法の組み合わせだけで十分表現可能なケースがほとんどと思われる。 むしろ Equal() 以外の様々なテスト関数を駆使「しすぎる」ことによって、テストコードの意図が汲み取りづらくなってしまう恐れがある。また testify/assert の学習コストを上げることにもなってしまう。特に複数メンバーの開発チーム内で使用する場合はこの点に注意すべきで、testify/assert の便利さと、テストコードの共通理解の得られやすさのバランスをうまくとるべきだと思う。 (使うとしても True() Nil() など、誰が見てもほぼ誤解の余地がないようなものにとどめるのが良さそう)