t.Run でサブテストを整理する

テーブル駆動テストと組み合わせて使われることが多いのが t.Run によるサブテストだ。各テストケースに名前を付けて独立したサブテストとして実行することで、失敗箇所の特定が容易になり、特定のケースだけを再実行することもできるようになる。

サブテストの基本

t.Run は第1引数にサブテスト名、第2引数にテスト関数を受け取る。テーブル駆動テストのループ内で使うのが典型的なパターンだ。

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

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

go test -v で実行すると、各サブテストが独立して表示される。

=== RUN   TestAdd
=== RUN   TestAdd/positive
=== RUN   TestAdd/zero
=== RUN   TestAdd/negative
--- PASS: TestAdd (0.00s)
    --- PASS: TestAdd/positive (0.00s)
    --- PASS: TestAdd/zero (0.00s)
    --- PASS: TestAdd/negative (0.00s)

TestAdd/positive のように親テスト名とサブテスト名がスラッシュで連結される。この階層構造が -run フラグでの絞り込みに活きてくる。

t.Run を使わない場合との違い

t.Run を使わずにループ内で直接 t.Errorf を呼ぶ書き方と比較すると、いくつかの明確な違いがある。

t.Run なし

失敗しても全ケースが実行される。ただし、どのケースが失敗したかはエラーメッセージに依存する

t.Run あり

各ケースが独立したサブテストになり、-run フラグで個別に実行できる。t.Fatalf も安全に使える

特に重要なのは t.Fatalf の挙動だ。t.Run を使わずにループ内で t.Fatalf を呼ぶと、テスト関数全体が中断され、後続のケースが実行されない。t.Run の中で t.Fatalf を呼べば、そのサブテストだけが中断され、他のケースは通常通り実行される。

func TestParse(t *testing.T) {
    tests := []struct {
        name  string
        input string
        want  int
    }{
        {"valid", "42", 42},
        {"invalid", "abc", 0},
        {"empty", "", 0},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got, err := Parse(tt.input)
            if err != nil {
                t.Fatalf("Parse(%q) returned error: %v",
                    tt.input, err)
            }
            if got != tt.want {
                t.Errorf("Parse(%q) = %d, want %d",
                    tt.input, got, tt.want)
            }
        })
    }
}

"invalid" のケースで t.Fatalf が呼ばれても、"empty" のサブテストは問題なく実行される。

-run フラグでサブテストを絞り込む

サブテストの大きな利点は、-run フラグで特定のケースだけを実行できることだ。スラッシュ区切りで親テストとサブテストを指定する。

# TestAdd の positive サブテストだけ実行
go test -v -run TestAdd/positive

# "negative" を含むすべてのサブテストを実行
go test -v -run /negative

デバッグ中に失敗するケースだけを繰り返し実行したい場面で非常に便利だ。テーブル駆動テストのケースが数十個ある場合でも、問題のあるケースにピンポイントでアクセスできる。

クロージャ変数のキャプチャに注意

t.Run と range ループを組み合わせるとき、Go 1.21 以前ではループ変数のキャプチャに注意が必要だった。

// Go 1.21 以前で問題になるパターン
for _, tt := range tests {
    t.Run(tt.name, func(t *testing.T) {
        // tt はループの最後の値を参照してしまう可能性がある
        check(t, tt.a, tt.b, tt.want)
    })
}

Go 1.22 以降ではループ変数のセマンティクスが変更され、各イテレーションで新しい変数が作られるようになったため、この問題は解消されている。ただし、Go 1.21 以前をサポートする必要がある場合はローカル変数にコピーする対策が必要になる。

// Go 1.21 以前での安全な書き方
for _, tt := range tests {
    tt := tt // ローカルにコピー
    t.Run(tt.name, func(t *testing.T) {
        check(t, tt.a, tt.b, tt.want)
    })
}
Go 1.22 以降

ループ変数は各イテレーションでスコープが切られるため、キャプチャの問題は発生しない。

Go 1.21 以前

tt := tt のイディオムでローカル変数にシャドウイングする必要がある。

サブテストのネスト

t.Run はネストすることもできる。テストの論理的なグループ分けに使えるが、深くネストしすぎると可読性が落ちるため、2階層程度に留めるのが実践的だ。

func TestMath(t *testing.T) {
    t.Run("Add", func(t *testing.T) {
        t.Run("positive", func(t *testing.T) {
            if Add(1, 2) != 3 {
                t.Error("failed")
            }
        })
        t.Run("negative", func(t *testing.T) {
            if Add(-1, -2) != -3 {
                t.Error("failed")
            }
        })
    })

    t.Run("Multiply", func(t *testing.T) {
        if Multiply(3, 4) != 12 {
            t.Error("failed")
        }
    })
}

実行すると TestMath/Add/positive のような3階層のパスが表示される。関連するテストをまとめたい場合に有効だが、多くの場合はテーブル駆動テスト + t.Run の1階層で十分にカバーできる。

サブテストは Go のテストを構造化するための基本的な道具であり、テーブル駆動テストと合わせて使うことで、保守しやすく読みやすいテストコードが実現できる。