テーブル駆動テストとは?

  1. はじめに
  2. 通常のテストコードの書き方(おさらい)
  3. テーブル駆動テストとは何か
  4. 基本的な書き方(struct + slice + for)
  5. t.Runを使ったサブテスト
  6. 使ってみて感じたメリット
  7. 向き不向きもある?
  8. おわりに

はじめに

テーブル駆動テスト(Table-Driven Test) での書き方に最近出会ったのでまとめてみようと思いました。

調べてみると、これはGoコミュニティで広く使われているイディオムでした。Go公式のwikiにも記載があるほど、Goらしい書き方のひとつとして定着しているとのことです。


通常のテストコードの書き方(おさらい)

まず、普通のGoのテストコードを確認しておきます。

たとえば「整数を受け取って2倍にして返す」関数 Double があったとします。

func Double(n int) int {
    return n * 2
}

このテストを素直に書くとこうなります。

func TestDouble(t *testing.T) {
    result := Double(3)
    if result != 6 {
        t.Errorf("Double(3) = %d; want 6", result)
    }

    result = Double(0)
    if result != 0 {
        t.Errorf("Double(0) = %d; want 0", result)
    }

    result = Double(-5)
    if result != -10 {
        t.Errorf("Double(-5) = %d; want -10", result)
    }
}

動くには動きます。でも何となく「同じ構造の繰り返し」が気になります。テストケースのパターンが増えるほど、この繰り返しはどんどん膨らんでいきます。


テーブル駆動テストとは何か

テーブル駆動テストとは、テストケースをデータ(テーブル)として定義し、ループで回して検証する書き方です。入力と期待値をズラッと並べて、それを一括で処理します。

Goでは struct のスライスとしてテストケースを定義し、for ループで回すのが定番のパターンになっています。


基本的な書き方(struct + slice + for)

先ほどの Double 関数のテストをテーブル駆動で書き直すとこうなります。

func TestDouble(t *testing.T) {
    tests := []struct {
        input    int
        expected int
    }{
        {input: 3, expected: 6},
        {input: 0, expected: 0},
        {input: -5, expected: -10},
    }

    for _, tt := range tests {
        result := Double(tt.input)
        if result != tt.expected {
            t.Errorf("Double(%d) = %d; want %d", tt.input, result, tt.expected)
        }
    }
}

見た目がスッキリしました。テストケースが tests という一覧にまとまっており、何をテストしているかが一目でわかります。新しいケースを追加したいときは、スライスに1行足すだけで済みます。


t.Runを使ったサブテスト

さらに t.Run を組み合わせると、各テストケースに名前をつけることができます。

func TestDouble(t *testing.T) {
    tests := []struct {
        name     string
        input    int
        expected int
    }{
        {name: "正の数", input: 3, expected: 6},
        {name: "ゼロ", input: 0, expected: 0},
        {name: "負の数", input: -5, expected: -10},
    }

    for _, tt := range tests {
        tt := tt // ループ変数のキャプチャ(Go 1.22未満では必要)
        t.Run(tt.name, func(t *testing.T) {
            result := Double(tt.input)
            if result != tt.expected {
                t.Errorf("got %d; want %d", result, tt.expected)
            }
        })
    }
}

テストが失敗したとき、出力に TestDouble/正の数 のように どのケースが落ちたかが明示されます。テストケースが増えてきたときに特に助かる機能です。また、-run フラグで特定のサブテストだけを実行することもできます。

go test -run TestDouble/正の数

使ってみて感じたメリット

以下の点がとくに便利だと感じました。

① テストパターンを網羅しやすい

テストケースがテーブルとして並ぶので、初めて見るコードでもどんなケースがあるか見渡しやすく、コードを読むというより、表を眺めるような感覚でレビューできます。

コーディングするときにも過不足があるかといった見落としに気づきやすいと思いました。

② 追加・変更がしやすい

仕様変更でケースを追加したいとき、スライスに1行足すだけで済みます。通常の書き方だと、if 文や変数の宣言を丸ごと追加する必要があって、地味に手間がかかります。

③ テストの意図が読みやすい

テーブルに name フィールドを設けておくと、「なぜこのケースをテストしているか」がドキュメントのように残ります。チームで開発しているときに、後から読む人への親切でもあります。


向き不向きもある?

たとえば、各テストケースで前処理・後処理が大きく異なる場合は、テーブルにまとめようとするとかえって複雑になります。structのフィールドが増えすぎて、どのケースで何が有効なのかが分かりにくくなることもあります。

また、テストの流れそのものが複雑な場合(複数の関数呼び出しをまたいで状態を確認するような場合)は、素直に独立した関数として書いた方が読みやすいこともあります。

テーブル駆動テストは「同じ関数に、異なる入力を渡して検証する」というシンプルなユースケースで最も力を発揮します。使う場面を選ぶことが大切だと思います。


おわりに

書き方を理解してしまえばシンプルで、同じ検証ロジックを何度も書かずに済み、テストケースの一覧性が高まるのは便利だと思いました。

とはいえ、何でもかんでもこれでテストを書けばいいかといえば逆効果のこともあると思うので、テスト手法は適材適所かなと思いました。

コメント

タイトルとURLをコピーしました