Day 023 — testing パッケージ①(テーブル駆動テスト)

2026-08-20 🔵 中級者 / Phase 2 概念理解→実装 testing パッケージ①(テーブル駆動テスト)

📚 背景知識(読んでから問題へ)

Day 022では、Map・Filter・Sum・Stackそれぞれに対してTestMap・TestFilter・TestSumInt・TestSumFloat64・TestSumNamedType・TestStackPushPop・TestStackPopEmpty……と、ケースごとに個別のTest関数を書きました。動くには動きますが、この書き方には構造的な問題があります。

  1. Arrange-Act-Assertの型がケースごとに重複する。入力を用意し、関数を呼び、結果を比較する——というコードの「型」自体は毎回ほぼ同じなのに、関数を増やすたびにそれをコピー&ペーストすることになります。
  2. ケースを1つ追加するコストが「関数を1つ追加するコスト」になる。本来は「入力と期待値のペアを1行足す」だけで済むはずの作業が、func TestXxx(t *testing.T) { ... }という定型文ごと複製する作業になってしまいます。
  3. 境界値のテスト漏れに気づきにくい。個別関数で書いていると「あといくつのケースを足すべきか」が視覚的に分かりにくく、テストケースの一覧性が失われます。

Goコミュニティはこの問題を、言語機能ではなくイディオム(書き方の慣習)で解決しました。それがテーブル駆動テスト(table-driven test)です。考え方はシンプルで、「入力と期待値の組」を構造体のスライス(テーブル)として定義し、そのスライスをforループで回して同じ検証ロジックを1回だけ書く、というものです。

cases := []struct {
	name string
	in   int
	want int
}{
	{"positive", 3, 9},
	{"zero", 0, 0},
	{"negative", -3, 9},
}

for _, c := range cases {
	got := Square(c.in)
	if got != c.want {
		t.Errorf("[%s] Square(%d) = %d, want %d", c.name, c.in, got, c.want)
	}
}

このパターンがGoで特に定着した理由は、Goの言語仕様そのものと相性が良いからです。無名構造体のスライスリテラルという文法が既に存在するため、外部ライブラリやアノテーション(Javaの@ParameterizedTestやPythonの@pytest.mark.parametrizeのようなデコレータ)を一切必要とせず、標準のfor文とstructだけでパラメータ化テストが書けます。Goの設計思想「言語機能を増やすより、既存の機能を組み合わせて表現する」がテストコードの書き方にもそのまま現れている例です。

なお今日は「テーブルにしてループで回す」という基本形までを扱います。各ケースを独立した名前付きサブテストとして実行するt.Runは、次回(testing パッケージ②)でテーブル駆動テストと組み合わせて学びます。

📝 問題

配送料金を計算する関数ShippingFeeを実装し、テーブル駆動テストで検証してください。

Part A: 実装対象の関数

func ShippingFee(weightKg float64, express bool) (int, error) を実装すること。

  • 重量が0以下の場合はfee=0とエラーを返す(不正な入力)
  • 重量に応じた基本料金(円)は次の階層とする
    • 0 < weightKg <= 1: 500円
    • 1 < weightKg <= 5: 800円
    • 5 < weightKg <= 10: 1200円
    • weightKg > 10: 2000円
  • express(速達)がtrueの場合、基本料金を2倍にする
  • エラーがない場合はerrorにnilを返す

Part B: まず「個別Test関数」の痛みを体感する(コード提出は不要)

次の3つのケースを、それぞれ独立したTestXxx関数として頭の中で(あるいは手元で試しに)書いてみてください。

  1. 1kgちょうど・通常便 → 500円
  2. 5.1kg・通常便 → 1200円
  3. 重量0・エラーになること

3つとも「値を用意する→関数を呼ぶ→比較する→t.Errorf」という同じ骨格のコードを3回書くことになったはずです。この「同じ骨格の反復」がテーブル駆動テストで解消したい対象です。このステップの成果物をファイルに保存する必要はありません。

Part C: テーブル駆動テストとして実装する(ここが提出物)

  1. name(ケース名)・weightKg・express・wantFee・wantErr(bool)を持つ無名構造体のスライスとしてテストケースのテーブルを定義すること
  2. 最低10ケースを用意すること。特に境界値(各階層の境界ちょうど・境界の直後)と、エラーになるべき入力(0・負の値)を必ず含めること
  3. for文でテーブルを回し、wantErrがtrueのケースとfalseのケースで分岐しながら検証すること
  4. 失敗時のt.Errorfメッセージには、必ずケース名(c.name)を含めること(後述の「よくある誤解」参照)

すべてgo runまたはgo testでそのまま動作するコードとして提示すること。

🔍 ヒント(段階的開示)

ヒント1 — 方向性

テーブル駆動テストは「新しい構文」ではなく「構造体のスライス+forループ」という、Day 004-008で既に学んだ機能の組み合わせです。まずPart Bの個別関数を3つ、実際に手を動かして書いてみると、「入力」「期待値」「検証ロジック」のうち、行ごとに変わるのは「入力」と「期待値」だけだと実感できます。その2つだけを構造体のフィールドとして抜き出すのがテーブル駆動テストの発想です。

ヒント2 — アプローチ
  • テーブルの型は[]struct{ name string; weightKg float64; express bool; wantFee int; wantErr bool }のように、テストのその場でしか使わない無名構造体で十分です(別名の型をtypeで定義する必要はありません)
  • wantErr boolを持たせておくと、「エラーになるべきか」と「返ってくる値が正しいか」を1つのテーブルで両方扱えます
  • ループ内でwantErrがtrueのケースを先に処理してcontinueすると、後続の値比較コードとネストが浅くなります
  • 失敗メッセージにc.nameを含めないと、テーブルが育ったときに「何番目のケースで落ちたか」がgo testの出力から分からなくなります
ヒント3 — コード骨格
package main

import (
	"errors"
	"fmt"
)

func ShippingFee(weightKg float64, express bool) (int, error) {
	if weightKg <= 0 {
		return 0, errors.New("invalid weight")
	}
	var fee int
	switch {
	case weightKg <= 1:
		fee = 500
	case weightKg <= 5:
		fee = 800
	case weightKg <= 10:
		fee = 1200
	default:
		fee = 2000
	}
	if express {
		fee *= 2
	}
	return fee, nil
}

func main() {
	fee, err := ShippingFee(3.5, false)
	fmt.Println(fee, err)
}
// main_test.go
package main

import "testing"

func TestShippingFeeTable(t *testing.T) {
	cases := []struct {
		name     string
		weightKg float64
		express  bool
		wantFee  int
		wantErr  bool
	}{
		// TODO: 最低10ケース。境界値とエラーケースを必ず含める
	}

	for _, c := range cases {
		// TODO: ShippingFee を呼び、wantErr で分岐して検証する
	}
}

✅ 模範解答

ファイル: main.go

package main

import (
	"errors"
	"fmt"
)

// ShippingFee は重量(kg)と速達フラグから配送料金を計算する。
// weightKg が 0 以下の場合はエラーを返す。
// express が true の場合、通常料金の2倍になる。
func ShippingFee(weightKg float64, express bool) (int, error) {
	if weightKg <= 0 {
		return 0, errors.New("invalid weight: must be greater than 0")
	}

	var fee int
	switch {
	case weightKg <= 1:
		fee = 500
	case weightKg <= 5:
		fee = 800
	case weightKg <= 10:
		fee = 1200
	default:
		fee = 2000
	}

	if express {
		fee *= 2
	}
	return fee, nil
}

func main() {
	fee, err := ShippingFee(3.5, false)
	fmt.Println("3.5kg 通常便:", fee, err)

	fee2, err2 := ShippingFee(-1, false)
	fmt.Println("不正な重量:", fee2, err2)
}

ファイル: main_test.go

package main

import "testing"

// 【Before(比較用・参考)】
// テーブル駆動にする前は、ケースごとにこのような個別関数を書くことになる。
// 3ケース書いただけでも「値を用意する→呼ぶ→比較する→t.Errorf」の
// 骨格が丸ごと重複しているのが分かる。ケースが10・20と増えると
// このコピー&ペーストのコストが線形に増えていく。
//
// func TestShippingFee1kgStandard(t *testing.T) {
// 	got, err := ShippingFee(1.0, false)
// 	if err != nil {
// 		t.Fatalf("unexpected error: %v", err)
// 	}
// 	if got != 500 {
// 		t.Errorf("ShippingFee(1.0, false) = %d, want 500", got)
// 	}
// }
//
// func TestShippingFee5point1kgStandard(t *testing.T) {
// 	got, err := ShippingFee(5.1, false)
// 	if err != nil {
// 		t.Fatalf("unexpected error: %v", err)
// 	}
// 	if got != 1200 {
// 		t.Errorf("ShippingFee(5.1, false) = %d, want 1200", got)
// 	}
// }
//
// func TestShippingFeeZeroWeight(t *testing.T) {
// 	_, err := ShippingFee(0, false)
// 	if err == nil {
// 		t.Error("ShippingFee(0, false) expected error, got nil")
// 	}
// }

// 【After】テーブル駆動テスト。
// ケースを増やすコストが「テーブルに1行足すだけ」になっている。
func TestShippingFeeTable(t *testing.T) {
	cases := []struct {
		name     string
		weightKg float64
		express  bool
		wantFee  int
		wantErr  bool
	}{
		{"below zero weight is invalid", -1, false, 0, true},
		{"zero weight is invalid", 0, false, 0, true},
		{"1kg exactly at light tier boundary", 1, false, 500, false},
		{"just above light tier boundary", 1.1, false, 800, false},
		{"5kg exactly at medium tier boundary", 5, false, 800, false},
		{"just above medium tier boundary", 5.1, false, 1200, false},
		{"10kg exactly at heavy tier boundary", 10, false, 1200, false},
		{"just above heavy tier boundary", 10.1, false, 2000, false},
		{"1kg light tier with express", 1, true, 1000, false},
		{"10.1kg heaviest tier with express", 10.1, true, 4000, false},
		{"very small positive weight", 0.01, false, 500, false},
	}

	for _, c := range cases {
		gotFee, err := ShippingFee(c.weightKg, c.express)

		if c.wantErr {
			if err == nil {
				t.Errorf("[%s] ShippingFee(%.2f, %v): expected error, got nil (fee=%d)",
					c.name, c.weightKg, c.express, gotFee)
			}
			continue
		}

		if err != nil {
			t.Errorf("[%s] ShippingFee(%.2f, %v): unexpected error: %v",
				c.name, c.weightKg, c.express, err)
			continue
		}

		if gotFee != c.wantFee {
			t.Errorf("[%s] ShippingFee(%.2f, %v) = %d, want %d",
				c.name, c.weightKg, c.express, gotFee, c.wantFee)
		}
	}
}
▶ 実行結果を見る(go run . / go test ./... で検証済み)
$ go run .
3.5kg 通常便: 800 <nil>
不正な重量: 0 invalid weight: must be greater than 0

$ go test -v ./...
=== RUN   TestShippingFeeTable
--- PASS: TestShippingFeeTable (0.00s)
PASS
ok  	example.com/day023	0.002s

※ main.go・main_test.goを1つのモジュール(go.modでmodule example.com/day023と宣言)として同一パッケージmainに配置しています。実際にgo run . / go test -v ./... / go vet ./...で検証済みです。

🪜 Step-by-Step 解説

1
まず本体のロジックを素直に実装する
func ShippingFee(weightKg float64, express bool) (int, error) {
	if weightKg <= 0 {
		return 0, errors.New("invalid weight: must be greater than 0")
	}
	// ...
}
テーブル駆動テストの本題に入る前に、テスト対象自体は普通の関数として書きます。境界値をswitchのcase weightKg <= Nで表現しているので、「境界ちょうどの値がどちらの階層に属するか」がコードを読むだけで分かります(<=1なので1kgちょうどは軽量帯)。これが後でテストの境界値ケースを書くときの手がかりになります。
2
Before(個別Test関数)を実際に3つ書いてみる
Part Bで指示した通り、TestShippingFee1kgStandardのような関数を3つ書くと、いずれも「ShippingFeeを呼ぶ→errをチェック→値を比較する」という同じ形が繰り返されることに気づきます。この「痛み」を体感してからテーブル駆動に進むと、なぜこのイディオムが必要なのかが腹落ちします。
3
テーブルの型を設計する
cases := []struct {
	name     string
	weightKg float64
	express  bool
	wantFee  int
	wantErr  bool
}{ /* ... */ }
テーブルの各フィールドは「関数への入力」と「期待される出力」に1対1で対応させます。wantErr boolを独立したフィールドにすることで、「エラーになるべきケース」と「正常系ケース」を同じテーブル・同じループで扱えるようにしています。これを分けずにwantFeeだけで表現しようとすると、「エラー時のfeeは何を期待値にすればよいか」という不自然な設計になってしまいます。
4
ループの中で分岐しつつ検証する
for _, c := range cases {
	gotFee, err := ShippingFee(c.weightKg, c.express)

	if c.wantErr {
		if err == nil {
			t.Errorf(/* ... */)
		}
		continue
	}

	if err != nil {
		t.Errorf(/* ... */)
		continue
	}

	if gotFee != c.wantFee {
		t.Errorf(/* ... */)
	}
}
wantErrがtrueのケースを先に処理してcontinueすることで、「エラーになるべきケース」と「正常に値を返すべきケース」のロジックが混線しません。ここでt.Fatalfではなくt.Errorfを使っているのは、1ケースの失敗でループ全体を止めず、テーブル内の他のケースの結果も一度の実行で全部確認したいためです(t.Fatalfはその場でテスト関数自体を終了させてしまう)。
5
境界値を意識してテーブルを埋める
1kg・5kg・10kgという境界ちょうどの値と、その直後(1.1kg・5.1kg・10.1kg)を両方含めることで、switchの条件式(<=か<か)を間違えたときにテストが確実に落ちるようにしています。境界値の「ちょうど」と「直後」をペアで用意するのは、バグの実に多くが<と<=の取り違えから生まれるためです。

💡 設計思想・なぜこう書くのか

📌
言語機能を増やすのではなく、既存機能の組み合わせでイディオムを作る: 他言語ではパラメータ化テストのために専用のアノテーション(Javaの@ParameterizedTest + @CsvSource)やデコレータ(Pythonの@pytest.mark.parametrize)といった、テストフレームワーク側の特別な仕組みが必要になることが多いです。Goではstructのスライスとfor文という、Day 004・Day 008で既に学んだ基本機能だけでパラメータ化テストが実現できます。「新しい構文を覚える」のではなく「知っている機能の新しい組み合わせ方を覚える」だけで済むのは、Goの言語仕様を意図的に小さく保つ設計判断の恩恵です。
📌
「テストの一覧性」を最優先する: テーブル駆動テストの本質的な価値は、コードの行数が減ることそのものではなく、「今どんなケースをカバーしていて、どこが手薄か」が構造体のスライスを一目見るだけで分かることにあります。個別のTest関数が20個並んでいる状態では、関数名を1つずつ読まないと網羅状況が把握できませんが、テーブルであればnameの列を眺めるだけでカバレッジの偏りに気づけます。これはGoのテスト文化が「動くコードを書く」だけでなく「テストの見通しの良さ」を重視していることの表れです。

🌐 他言語との比較

観点Go(テーブル駆動テスト)Java(JUnit 5)Python(pytest)TypeScript(Jest)
パラメータ化の書き方構造体のスライス + for文(言語機能のみ)@ParameterizedTest + @MethodSource/@CsvSource(フレームワーク機能)@pytest.mark.parametrize("in,want", [...])(デコレータ)test.each([...])('...', (in, want) => {...})(API)
追加ライブラリの要否不要(標準testingのみ)JUnit本体で標準搭載pytest本体で標準搭載Jest本体で標準搭載
ケース名の扱い構造体のnameフィールドとして自分で設計する@DisplayNameや引数から自動生成パラメータ値から自動生成(test_func[in0-want0]等)フォーマット文字列で指定可能
1ケース失敗時の挙動デフォルトでは他のケースも継続実行(t.Errorf使用時)各パラメータが独立したテストとして実行される各パラメータが独立したテストとして実行される各エントリが独立したテストとして実行される
学習コスト言語の基本文法のみで完結(新しい概念なし)アノテーションの種類・組み合わせを覚える必要デコレータの引数の書式を覚える必要test.eachのタプル/オブジェクト記法を覚える必要

Java/pytest/Jestはいずれも「各パラメータの組が独立したテストケースとして扱われ、失敗しても他のケースの実行が止まらない」のが標準の挙動です。Goの素朴なforループ版でもt.Errorf(t.Fatalfではない)を使えば同様に継続実行できますが、「失敗したのがどのケースか」を自分でメッセージに含める必要がある点が異なります。この「独立したケースとして名前付きで実行する」という体験をt.Runを使わずに再現しようとした結果生じるこの手間が、次回学ぶサブテスト(t.Run)の動機になります。

🏆 実務での使いどころ

  • バリデーション関数のテスト: メールアドレス形式チェック・パスワード強度チェックのような「正常系・境界値・異常系」が多岐にわたる関数は、テーブル駆動テストが最も威力を発揮する典型例です
  • 料金・税額計算のような階層ロジック: 本日のShippingFeeのように、入力の範囲によって出力が変わるロジックは、境界値を漏れなくテーブルに列挙することでリグレッションを防ぎやすくなります
  • APIハンドラのステータスコード網羅: リクエストの内容ごとに期待するHTTPステータスコード・レスポンスボディを一覧化し、ハンドラの分岐網羅を可視化する際にも同じ発想が使われます(Day 018のHTTPサーバー実装で書いたハンドラのテストに応用できます)
  • 既存の個別Testの負債整理: 実務のコードレビューでは「似た構造のTest関数が3つ以上並んでいたらテーブル駆動へのリファクタリングを検討する」という目安がよく使われます

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
t.Errorfのメッセージにケース名(c.name)を含め忘れるループ内なので「1つのテストだから1つのエラーメッセージでいい」と錯覚するテーブルが育つと、go testの出力だけでは「テーブルの何行目で失敗したか」が分からなくなる。c.nameをメッセージに含めるのは可読性ではなく必須のデバッグ情報
失敗したら即座にt.Fatalfでループごと止めてしまう個別Test関数の感覚で「失敗したら終わり」と考えるt.Fatalfはそのテスト関数自体を即終了させる(後続のケースが一切実行されない)。テーブル駆動では通常t.Errorfを使い、1回の実行で全ケースの結果をまとめて確認できるようにする
境界値ちょうどのケースを入れない(「だいたい合っていればいい」という感覚でテストを書く)境界値バグ(<と<=の取り違え)は動作確認では気づきにくい<=1と<1は多くの場合1行の違いだが挙動は大きく変わる。境界ちょうど・境界の直後をペアで用意することで、この種の1文字バグを機械的に検出できる
テーブルのnameフィールドを「何をテストしているか」ではなく単なる連番("case1", "case2")にしてしまうフィールドを埋める作業として機械的に書いてしまうnameはテスト失敗時に読む人への説明でもある。「何が起きるべきか」を短く書いておくと、テーブルの一覧を読むだけでカバレッジの意図が伝わる

🚀 次のステップ

  • 発展: 今日のShippingFeeテーブルに、「重量がNaNの場合」や「極端に大きい重量(1e9kg)」のような異常系ケースを追加し、現在の実装がそれらをどう扱うか(意図通りか、想定外の挙動か)を確認してみましょう
  • 次回予告: Day 024 — testing パッケージ②(サブテスト・testify)(実装)。今日のforループ内のt.Errorfをt.Run(c.name, func(t *testing.T) {...})に置き換え、各ケースを独立した名前付きサブテストとして実行できるようにします。あわせてtestifyのようなアサーションライブラリで比較コードをさらに簡潔にする方法も学びます

🎯 自己評価

自分の回答

気づき・メモ