GoにおけるTable-driven Test

1. 概要

1つの関数が複数の処理ブランチを持つ場合、異なるinput/outputのセットでテストしたいでしょう。ケースごとに個別のテスト関数を書くと、テストコードが大量に重複し、メンテナンスが困難になります。

Table-driven testはGo communityで最も一般的なテスト手法です。テストケースを「表」形式のデータとして記述し、単一のループですべてを実行します。

この記事を読み終えれば、subtestを持つtable-driven test、エラーチェックの正しい方法、テストの並列実行、複雑なstruct比較の方法がわかります。また、このテクニックを使うべきでない場合も理解できるでしょう。

2. テスト対象の関数

割引コードを適用した後の金額を計算する関数を使用します。この関数には説明に十分な複数のブランチがあります:

// pricing/discount.go
package pricing

import (
	"errors"
	"strings"
)

var (
	ErrNegativeTotal = errors.New("negative total amount")
	ErrInvalidCode   = errors.New("invalid discount code")
)

// ApplyDiscountは割引コード適用後の金額を返します。
func ApplyDiscount(total int, code string) (int, error) {
	if total < 0 {
		return 0, ErrNegativeTotal
	}

	switch strings.ToUpper(strings.TrimSpace(code)) {
	case "":
		return total, nil
	case "SALE10":
		return total * 90 / 100, nil
	case "GIAM50K":
		if total < 50000 {
			return 0, nil
		}
		return total - 50000, nil
	default:
		return 0, ErrInvalidCode
	}
}

この関数には5つの動作をテストする必要があります。それは負の合計、コード無し、SALE10コード、GIAM50Kコード(2つのブランチ)、そして存在しないコードです。さらに、コードはTrimSpaceとToUpperで正規化されます。

3. Table-driven testの基本構造

3.1. 初期バージョン

Table-driven testは3つの部分から構成されます:テーブルの宣言、テーブルのループ、結果の比較。

// pricing/discount_test.go
package pricing

import "testing"

func TestApplyDiscount(t *testing.T) {
	tests := []struct {
		name  string
		total int
		code  string
		want  int
	}{
		{name: "no code", total: 100000, code: "", want: 100000},
		{name: "sale10", total: 100000, code: "SALE10", want: 90000},
		{name: "sale10 with lowercase and space", total: 100000, code: "  sale10 ", want: 90000},
		{name: "giam50k normal", total: 200000, code: "GIAM50K", want: 150000},
		{name: "giam50k no negative", total: 30000, code: "GIAM50K", want: 0},
	}

	for _, tc := range tests {
		got, err := ApplyDiscount(tc.total, tc.code)
		if err != nil {
			t.Fatalf("%s: unexpected error: %v", tc.name, err)
		}
		if got != tc.want {
			t.Errorf("%s: ApplyDiscount(%d, %q) = %d, want %d",
				tc.name, tc.total, tc.code, got, tc.want)
		}
	}
}

説明:

  • testsはanonymous structのsliceです。各要素はテストケースで、input(total、code)と期待される出力(want)から成ります。
  • フィールドnameは、どのケースが失敗したかを認識するのに役立ちます。
  • forループは関数を呼び出し、結果を比較します。F(input) = got, want XパターンのエラーメッセージはGoで慣習的です。

新しいケースを追加するには、テーブルに1行追加するだけです。これがこのテクニックの最大の利点です。

3.2. 初期バージョンの問題

上記のコードは動作しますが、2つの欠点があります:

  1. t.Fatalfは全体のテストを停止します。1つのケースの失敗により、後続のケースが実行されません。
  2. デバッグ時に特定のケースを個別に実行することができません。

Subtestはこの両方の問題を解決します。

4. t.Runを使ったSubtest

t.Runは各ケースのsubtestを作成します。各subtestは独自の*testing.Tを持つため、t.Fatalは現在のケースのみを停止します。

func TestApplyDiscount(t *testing.T) {
	tests := []struct {
		name  string
		total int
		code  string
		want  int
	}{
		{name: "no code", total: 100000, code: "", want: 100000},
		{name: "sale10", total: 100000, code: "SALE10", want: 90000},
		{name: "sale10 with lowercase and space", total: 100000, code: "  sale10 ", want: 90000},
		{name: "giam50k normal", total: 200000, code: "GIAM50K", want: 150000},
		{name: "giam50k no negative", total: 30000, code: "GIAM50K", want: 0},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			got, err := ApplyDiscount(tc.total, tc.code)
			if err != nil {
				t.Fatalf("unexpected error: %v", err)
			}
			if got != tc.want {
				t.Errorf("ApplyDiscount(%d, %q) = %d, want %d",
					tc.total, tc.code, got, tc.want)
			}
		})
	}
}

エラーメッセージにtc.nameを出力する必要はなくなりました。Goがsubtestの名前を出力に含めるからです。-vで実行してください:

go test ./pricing -v
=== RUN   TestApplyDiscount
=== RUN   TestApplyDiscount/no_code
=== RUN   TestApplyDiscount/sale10
=== RUN   TestApplyDiscount/sale10_with_lowercase_and_space
=== RUN   TestApplyDiscount/giam50k_normal
=== RUN   TestApplyDiscount/giam50k_no_negative
--- PASS: TestApplyDiscount (0.00s)
    --- PASS: TestApplyDiscount/no_code (0.00s)
    --- PASS: TestApplyDiscount/sale10 (0.00s)
    --- PASS: TestApplyDiscount/sale10_with_lowercase_and_space (0.00s)
    --- PASS: TestApplyDiscount/giam50k_normal (0.00s)
    --- PASS: TestApplyDiscount/giam50k_no_negative (0.00s)
PASS

テスト名の空白は_に置き換わっていることに気づくでしょう。特定のケースを個別に実行するには、-runにregexを渡します:

go test ./pricing -run 'TestApplyDiscount/giam50k' -v

このコマンドは名前がgiam50kで始まる2つのケースのみを実行します。ケースが失敗すると、出力は以下のようになります:

--- FAIL: TestApplyDiscount (0.00s)
    --- FAIL: TestApplyDiscount/sale10 (0.00s)
        discount_test.go:24: ApplyDiscount(100000, "SALE10") = 90000, want 85000

どのケースが失敗し、どの行で、どのinputで失敗したかがすぐにわかります。

注意: ケース名は短く、重複せず、ターミナルで再度入力しやすいものにしてください。2つのケースが同じ名前の場合、Goが自動的にサフィックス#01を追加し、特定のケースの個別実行が難しくなります。

5. テーブルテストでのエラーチェック

エラーを返すケースも同じテーブルに含める必要があります。error型のwantErrフィールドを追加し、errors.Isで比較します:

package pricing

import (
	"errors"
	"testing"
)

func TestApplyDiscount_Errors(t *testing.T) {
	tests := []struct {
		name    string
		total   int
		code    string
		want    int
		wantErr error
	}{
		{name: "sale10", total: 100000, code: "SALE10", want: 90000},
		{name: "negative total", total: -1, code: "SALE10", wantErr: ErrNegativeTotal},
		{name: "invalid code", total: 100000, code: "TET2026", wantErr: ErrInvalidCode},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			got, err := ApplyDiscount(tc.total, tc.code)

			if !errors.Is(err, tc.wantErr) {
				t.Fatalf("err = %v, wantErr %v", err, tc.wantErr)
			}
			if tc.wantErr != nil {
				return // already checked error, no need to check return value
			}
			if got != tc.want {
				t.Errorf("got %d, want %d", got, tc.want)
			}
		})
	}
}

説明:

  • 成功するケースの場合、wantErrはゼロ値nilを持ちます。その場合、errors.Is(nil, nil)はtrueを返します。
  • 関数が予期しないエラーを返す場合、errors.Is(err, nil)はfalseを返し、テストはすぐに失敗します。
  • errors.Isはfmt.Errorf("...: %w", err)でwrapされたエラーでもまだ機能します。err == tc.wantErrの比較はそうではありません。

多くのコードベースはシンプルさのためにwantErr boolを使用します。この方法はエラーがあるかどうかだけを示し、どのエラーかは示しません。パッケージが定義されている場合は、sentinel errorとの比較を優先すべきです。

6. t.Parallelを使った並列実行

テストケースが遅い場合(I/O呼び出し、重い処理),subtestを並列に実行することができます:

func TestApplyDiscount_Parallel(t *testing.T) {
	tests := []struct {
		name  string
		total int
		code  string
		want  int
	}{
		{name: "no code", total: 100000, code: "", want: 100000},
		{name: "sale10", total: 100000, code: "SALE10", want: 90000},
		{name: "giam50k", total: 200000, code: "GIAM50K", want: 150000},
	}

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

			got, err := ApplyDiscount(tc.total, tc.code)
			if err != nil {
				t.Fatalf("unexpected error: %v", err)
			}
			if got != tc.want {
				t.Errorf("got %d, want %d", got, tc.want)
			}
		})
	}
}

t.Parallel()はtest runnerに、このsubtestが他の並列subtestと一緒に実行できることを通知します。親関数はsubtestを一時的に停止し、ループを完了した後、すべてのsubtestを同時に実行します。

Go 1.22以降では、各forループは新しいtc変数を作成するため、closureは常に正しい値をキャプチャします。Go 1.21以前では、変数をコピーすることが必須です:

for _, tc := range tests {
	tc := tc // required for Go < 1.22
	t.Run(tc.name, func(t *testing.T) {
		t.Parallel()
		// ...
	})
}

古いバージョンでtc := tcがない場合、すべてのsubtestが最後のケースを使用します。テストはまだ「pass」ですが、実際には1つのケースのみがチェックされます。これは発見しにくいバグです。go.modファイルのgo行を確認してください。

注意: t.Parallel()は、ケースが変更可能な状態を共有しない場合(グローバル変数、共有一時ファイル、共有データベーステーブルなど)のみ使用します。ApplyDiscountのような純粋な関数では、メリットはほぼありません。習慣で追加しないでください。

7. go-cmpを使った複雑なstruct比較

関数がネストされたstructを返す場合、sliceやmapを含むstructに対して!=演算子は使用できません。reflect.DeepEqualはtrue/falseのみを返し、どこが異なるかは示しません。ライブラリgo-cmpがこれを解決します。モジュールに追加してください:

go get github.com/google/go-cmp/cmp
// go.mod (excerpt)
require github.com/google/go-cmp v0.6.0

請求書を作成する関数があると仮定します:

// pricing/invoice.go
package pricing

type Invoice struct {
	Customer string
	Items    []string
	Total    int
}

func BuildInvoice(customer string, items []string, total int, code string) (Invoice, error) {
	final, err := ApplyDiscount(total, code)
	if err != nil {
		return Invoice{}, err
	}
	return Invoice{Customer: customer, Items: items, Total: final}, nil
}

cmp.Diffを使ったテスト:

package pricing

import (
	"testing"

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

func TestBuildInvoice(t *testing.T) {
	tests := []struct {
		name     string
		customer string
		items    []string
		total    int
		code     string
		want     Invoice
	}{
		{
			name:     "with sale10 code",
			customer: "An",
			items:    []string{"shirt", "hat"},
			total:    200000,
			code:     "SALE10",
			want:     Invoice{Customer: "An", Items: []string{"shirt", "hat"}, Total: 180000},
		},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			got, err := BuildInvoice(tc.customer, tc.items, tc.total, tc.code)
			if err != nil {
				t.Fatalf("unexpected error: %v", err)
			}
			if diff := cmp.Diff(tc.want, got); diff != "" {
				t.Errorf("BuildInvoice() mismatch (-want +got):\n%s", diff)
			}
		})
	}
}

cmp.Diffは2つの値が等しい場合、空の文字列を返します。異なる場合、読みやすいdiffが表示されます:

BuildInvoice() mismatch (-want +got):
  pricing.Invoice{
  	Customer: "An",
  	Items:    {"shirt", "hat"},
- 	Total:    180000,
+ 	Total:    200000,
  }

-は期待値、+は実際の値です。メッセージに(-want +got)規約を明確に記述し、読者が推測する必要がないようにしてください。

8. よくある落とし穴と使うべきでない場合

Table-driven testを書く際によくある落とし穴:

  • テーブルが肥大化し、フラグフィールドが多すぎる。 テストstructがsetupMock bool、skipValidation bool、expectCall intなどのフィールドを持ち、ループ本体がifでいっぱいの場合、テスト内にロジックを書いています。その場合は、独立したテスト関数に分割してください。
  • t.Runの外でt.Fatalを使用する。 1つのケースが失敗すると、後続のケースの結果が失われます。
  • 後果を理解せずにsliceの代わりにmapを使用する。 map[string]struct{...}はケース名の重複を防ぎ、ランダムな実行順序によってケース間の隠れた依存関係を露出させるのに役立ちます。ただし、出力は固定順ではありません。どちらのアプローチも受け入れられます。チーム全体で統一する限り。
  • エラーメッセージにinputがない。 t.Errorf("wrong")だけを書くと、デバッグ時にテストコードを開いて読む必要があります。常にinput、got、wantを出力してください。
  • エッジケースを忘れる。 テーブルは欠落しているケースを見つけやすくします。0値、負の数、空の文字列、および境界値(例:GIAM50Kを使用したtotal: 50000)を再度確認してください。

以下の場合は、table-driven testを使うべきではありません:

  • 各ケースが大きく異なるsetupを必要とする場合(例:mockが異なるデータを返す、呼び出し順序が異なる)。それらをテーブルに無理に詰め込むとテストが読みにくくなるだけです。
  • ケースが1つまたは2つのみの場合。単純なテスト関数の方が理解しやすくなります。
  • ランダムなinputを探索する必要がある場合。その場合は、fuzz test(func FuzzXxx(f *testing.F))を検討してください。Go 1.18以降で利用可能です。f.Addを通じてテストテーブル自体をseed corpusとして使用できます。

9. まとめ

Table-driven testは複数のテストケースをデータテーブルに集約し、単一のループで実行するのに役立ちます。t.Runと組み合わせると、各ケースが独立したsubtestになります。-runで個別のケースを実行でき、1つのケースの失敗が他のケースを停止することはありません。

重要なポイント:

  • 各ケースはname、input、期待される出力を持つべきです。
  • wantErr errorフィールドでerrors.Isを使ってエラーをチェックしてください。
  • t.Parallel()は本当に必要な場合だけ使用し、Go 1.22以前のループ変数セマンティクスに注意してください。
  • 複雑なstruct比較にはcmp.Diffを使用してください。
  • テーブルが制御ロジックを含み始めたら、それはテストを分割すべき合図です。
golangtestingtable-driven testunit test