【Go】テーブル駆動テストのエラーチェックは関数パターンがおすすめ

記事公開時点ではSREの市川です。

というのも2024年の大晦日を以て退職となるのですが、実は【カヤック】面白法人グループ Advent Calendar 2024の7日目の記事をすっぽかしていたので、Go におけるテストの話を書いて置き土産といたします。

ケーススタディ

以下のようなSUT(テスト対象)があるとします。

package foo

func DoSomething(input string) int {
    // 何かしらの処理
}

この限りでは、SUTがエラーを返さないのでエラーチェックの必要はありません。つまり、以下のようなテストコードを書くことができます。

package foo_test

import (
    "testing"

    "foo" // your SUT package

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

func TestDoSomething(t *testing.T) {
    tests := []struct {
        name  string
        input string
        want  int
    }{
        {
            name:  "when input is foo",
            input: "foo",
            want:  3,
        },
        // 他のテストケースを列挙
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got := foo.DoSomething(tt.input)
            require.Equal(t, tt.want, got)
        })
    }
}

stretchr/testify についても、かなり普及しているモジュールだと思うので詳しい解説は割愛しますが、Go のテストにおけるアサーションを行うためのモジュールです。

require パッケージの諸関数は、要求を満たさなかった場合に当該テストを失敗としてゴルーチンを即時終了させます。 t.Run のコールバックは個別のゴルーチンで実行されるので、上記コードにおいて require が失敗時に中断する検証処理は個々のテストケースに閉じます。

エラーを返す関数のテスト

さて、本題に戻って、エラーを返す関数のテストをどう書くかを考えてみましょう。

先ほどの例を以下のように変更した場合を考えます。

package foo

func DoSomething(input string) (int, error) {
    // 何かしらの処理
}

この場合、テストコードはどう書けばよいでしょうか?

書き方① require.ErrorIs で比較

割とベーシックなのはこのパターンかなと思います。もちろんこれも下の例のようにSUTが返すエラーが変数として定義されていれば過不足なく検証可能です。

 func TestDoSomething(t *testing.T) {
        tests := []struct {
                name  string
                input string
                want  int
+               wantErr error
        }{
                {
                        name:  "when input is foo",
                        input: "foo",
                        want:  3,
                },
+               {
+                       name:    "invalid input",
+                       input:   "bar",
+                       wantErr: foo.ErrInvalidInput,
+               },
                // 他のテストケースを列挙
        }

        for _, tt := range tests {
                t.Run(tt.name, func(t *testing.T) {
-                       got := foo.DoSomething(tt.input)
-                       require.Equal(t, tt.want, got)
+                       got, err := foo.DoSomething(tt.input)
+                       require.ErrorIs(t, err, tt.wantErr)
+                       if err == nil {
+                               require.Equal(t, tt.want, got)
+                       }
                })
        }
 }

なお、wantErr が暗黙で nil になっている箇所もありますが、errors.Is(nil, nil)true になるので問題ありません。

stretchr/testify は内部的に Go の標準パッケージの errors を使っており、errors.Is() の挙動はPlaygroundで確かめることが可能です。

書き方② 関数で比較

これに対して、今回おすすめしたいのは、テストケースにエラーチェック用の関数を追加する方法です。

この方法のメリットは、とにかく柔軟にテストケースを記述できることです。エラーが単純な変数ではなく独自の型として定義されている場合の詳細な比較もできますし、「諸般の事情から文字列チェックをするしかない」みたいなケースにも簡単に対応できます。

 func TestDoSomething(t *testing.T) {
        tests := []struct {
                name  string
                input string
                want  int
+               errorCheck func(*testing.T, error)
        }{
                {
                        name:  "when input is foo",
                        input: "foo",
                        want:  3,
+                       errorCheck: func(t *testing.T, err error) {
+                               require.NoError(t, err)
+                       },
+               },
+               {
+                       name:  "invalid input",
+                       input: "bar",
+                       errorCheck: func(t *testing.T, err error) {
+                               require.ErrorIs(t, err, foo.ErrInvalidInput)
+                       },
+               },
+               {
+                       name:  "empty input",
+                       input: "",
+                       errorCheck: func(t *testing.T, err error) {
+                               if !strings.Contains(err.Error(), "empty input") {
+                                       t.Errorf("unexpected error message: %v", err)
+                               }
+                       },
                },
                // 他のテストケースを列挙
        }

        for _, tt := range tests {
                t.Run(tt.name, func(t *testing.T) {
-                       got := foo.DoSomething(tt.input)
-                       require.Equal(t, tt.want, got)
+                       got, err := foo.DoSomething(tt.input)
+                       tt.errorCheck(t, err)
+                       if err == nil {
+                               require.Equal(t, tt.want, got)
+                       }
                })
        }
 }

Go のテーブル駆動テストのコードは、とにかく縦長になる傾向があり、個々のテストケースと t.Run 内の検証処理を行ったり来たりすると疲れが溜まります。

そのため、できれば個々のテストケースだけを見た時に受ける直感を、完全に頼り切れるように設計したいと常々思っています。

ebi-yade/gotest/cases のご紹介

今どきLLMに入力補助をしてもらったり様々なソリューションがあるので「タイプ数が多くて面倒臭い」という気持ちとは折り合いをつけやすいですが、それもケースバイケースです。

好みによっては ebi-yade/gotest/cases というパッケージが助けになるかもしれません。

以下のように記述することで、エラーチェック関数の記述量を削減することが可能です。

package foo_test

import (
    // 略
    "github.com/ebi-yade/gotest/cases"
)

func TestDoSomething(t *testing.T) {
    tests := []struct {
        name       string
        input      string
        want       int
        errorCheck func(*testing.T, error)
    }{
        {
            name:       "when input is foo",
            input:      "foo",
            want:       3,
            errorCheck: cases.NoError,
        },
        {
            name:       "invalid input",
            input:      "bar",
            errorCheck: cases.ErrorIs(foo.ErrInvalidInput),
        },
    // 

ソースコードとしても t.Helper() を呼んで require をラップしている程度なので、もしインポートすることに抵抗があれば、プロジェクト内のユーティリティパッケージにコピペしていただいても構いません。

まとめ

テストは書くのも読むのも少なからず負担がかかりますが、テストの品質はソフトウェアの品質に大きな影響を与えます。 また、新たにチームに参加したエンジニアの幸福度にも直結する項目でもあると思います。

少しだけ厄介な問題に足を踏み入れることで、自信を持ってチームメンバーに仕事を任せられるようになると良いですね。

カヤックではテストで愛を表現できるエンジニアも募集しています❤️

hubspot.kayac.com