テーブル駆動テストで網羅的に検証する
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 パッケージのテストコードを読むと実践的な書き方が学べる。パターンに慣れてしまえば、テストを書くハードルが大きく下がるはずだ。