テーブル駆動テストで網羅的に検証する

Go のテストで最もよく使われるパターンがテーブル駆動テスト(Table-Driven Test)だ。複数の入力と期待値をスライスにまとめ、ループで回すことで、テストケースの追加が容易になり、網羅性も高まる。

基本的なテーブル駆動テスト

まずは単純な例から見てみよう。加算関数のテストを複数ケース書く場合、愚直にやると同じような if 文が並ぶことになる。

// 愚直な書き方(非推奨)
func TestAdd(t *testing.T) {
    if Add(1, 2) != 3 {
        t.Errorf("Add(1, 2) failed")
    }
    if Add(0, 0) != 0 {
        t.Errorf("Add(0, 0) failed")
    }
    if Add(-1, 1) != 0 {
        t.Errorf("Add(-1, 1) failed")
    }
}

テーブル駆動テストでは、テストケースを構造体のスライスとして定義し、ループで検証する。

func TestAdd(t *testing.T) {
    tests := []struct {
        name string
        a, b int
        want int
    }{
        {"positive numbers", 1, 2, 3},
        {"zeros", 0, 0, 0},
        {"negative and positive", -1, 1, 0},
        {"both negative", -3, -7, -10},
    }

    for _, tt := range tests {
        got := Add(tt.a, tt.b)
        if got != tt.want {
            t.Errorf("%s: Add(%d, %d) = %d, want %d",
                tt.name, tt.a, tt.b, got, tt.want)
        }
    }
}

テストケースを追加するには、スライスに1行追加するだけで済む。テストのロジック自体を変更する必要がないため、メンテナンスが楽になる。

匿名構造体を使う理由

テーブル駆動テストでは、わざわざ型を定義せず匿名構造体を使うのが慣習だ。テスト関数の中でしか使わない型に名前を付ける必要はない。

名前付き構造体

型定義が増えてコードが冗長になる。テスト以外で再利用することはほぼない

匿名構造体

テスト関数のスコープに閉じており、意図が明確。Go のテストでは標準的な書き方

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

戻り値にエラーを含む関数では、テーブルに wantErr フィールドを追加して正常系と異常系を一緒にテストできる。

func TestDivide(t *testing.T) {
    tests := []struct {
        name    string
        a, b    float64
        want    float64
        wantErr bool
    }{
        {"normal", 10, 2, 5, false},
        {"divide by zero", 10, 0, 0, true},
        {"negative divisor", -6, 3, -2, false},
    }

    for _, tt := range tests {
        got, err := Divide(tt.a, tt.b)
        if (err != nil) != tt.wantErr {
            t.Errorf("%s: error = %v, wantErr %v",
                tt.name, err, tt.wantErr)
            continue
        }
        if !tt.wantErr && got != tt.want {
            t.Errorf("%s: Divide(%g, %g) = %g, want %g",
                tt.name, tt.a, tt.b, got, tt.want)
        }
    }
}

wantErr が true のケースでは戻り値の検証をスキップしている点に注目してほしい。エラーが返ってくることだけを確認すれば十分な場面が多い。

テストケースの命名

各テストケースに name フィールドを持たせるのは、失敗時にどのケースで落ちたかを一目で判別するためだ。名前がないと、出力されるのは行番号だけになり、デバッグ効率が大幅に落ちる。

良い命名の例

"positive numbers", "empty input", "divide by zero" のように、テストの意図が伝わる名前を付ける。

悪い命名の例

"case1", "test2" のような連番は、失敗時に何をテストしていたのか分からない。

名前は英語でもコメントでも構わないが、ログに出力されることを前提に、短く具体的にまとめるのがコツだ。

マップを使ったテーブル

スライスの代わりにマップを使う書き方もある。テストケース名をキーにすることで、name フィールドが不要になる。

func TestAdd(t *testing.T) {
    tests := map[string]struct {
        a, b int
        want int
    }{
        "positive numbers":      {1, 2, 3},
        "zeros":                 {0, 0, 0},
        "negative and positive": {-1, 1, 0},
    }

    for name, tt := range tests {
        got := Add(tt.a, tt.b)
        if got != tt.want {
            t.Errorf("%s: Add(%d, %d) = %d, want %d",
                name, tt.a, tt.b, got, tt.want)
        }
    }
}

ただし、マップのイテレーション順は不定であるため、テストの実行順序が毎回変わる。順序に依存しないテストなら問題ないが、ログの読みやすさを重視するならスライスのほうが扱いやすい。

スライス方式

実行順序が安定し、ログが読みやすい。name フィールドが必要になるが、最も一般的な書き方

マップ方式

name フィールドが不要でコンパクト。ただし実行順序が不定で、ログの読み順が毎回変わる

テーブル駆動テストが有効な場面

すべてのテストをテーブル駆動にすべきかというと、そうでもない。入力と出力が明確で、同じ検証ロジックを複数回繰り返す関数に最も適している。一方、セットアップが複雑だったり、ケースごとにまったく異なる検証が必要な場合は、個別にテスト関数を書いたほうが読みやすくなることもある。

Go の標準ライブラリ自体がテーブル駆動テストを多用しており、strings パッケージや strconv パッケージのテストコードを読むと実践的な書き方が学べる。パターンに慣れてしまえば、テストを書くハードルが大きく下がるはずだ。