📚 背景知識(読んでから問題へ)
Day 022では、Map・Filter・Sum・Stackそれぞれに対してTestMap・TestFilter・TestSumInt・TestSumFloat64・TestSumNamedType・TestStackPushPop・TestStackPopEmpty……と、ケースごとに個別のTest関数を書きました。動くには動きますが、この書き方には構造的な問題があります。
- Arrange-Act-Assertの型がケースごとに重複する。入力を用意し、関数を呼び、結果を比較する——というコードの「型」自体は毎回ほぼ同じなのに、関数を増やすたびにそれをコピー&ペーストすることになります。
- ケースを1つ追加するコストが「関数を1つ追加するコスト」になる。本来は「入力と期待値のペアを1行足す」だけで済むはずの作業が、
func TestXxx(t *testing.T) { ... }という定型文ごと複製する作業になってしまいます。 - 境界値のテスト漏れに気づきにくい。個別関数で書いていると「あといくつのケースを足すべきか」が視覚的に分かりにくく、テストケースの一覧性が失われます。
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関数として頭の中で(あるいは手元で試しに)書いてみてください。
- 1kgちょうど・通常便 → 500円
- 5.1kg・通常便 → 1200円
- 重量0・エラーになること
3つとも「値を用意する→関数を呼ぶ→比較する→t.Errorf」という同じ骨格のコードを3回書くことになったはずです。この「同じ骨格の反復」がテーブル駆動テストで解消したい対象です。このステップの成果物をファイルに保存する必要はありません。
Part C: テーブル駆動テストとして実装する(ここが提出物)
name(ケース名)・weightKg・express・wantFee・wantErr(bool)を持つ無名構造体のスライスとしてテストケースのテーブルを定義すること- 最低10ケースを用意すること。特に境界値(各階層の境界ちょうど・境界の直後)と、エラーになるべき入力(0・負の値)を必ず含めること
for文でテーブルを回し、wantErrがtrueのケースとfalseのケースで分岐しながら検証すること- 失敗時の
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 解説
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ちょうどは軽量帯)。これが後でテストの境界値ケースを書くときの手がかりになります。
Part Bで指示した通り、
TestShippingFee1kgStandardのような関数を3つ書くと、いずれも「ShippingFeeを呼ぶ→errをチェック→値を比較する」という同じ形が繰り返されることに気づきます。この「痛み」を体感してからテーブル駆動に進むと、なぜこのイディオムが必要なのかが腹落ちします。
cases := []struct {
name string
weightKg float64
express bool
wantFee int
wantErr bool
}{ /* ... */ }
テーブルの各フィールドは「関数への入力」と「期待される出力」に1対1で対応させます。wantErr boolを独立したフィールドにすることで、「エラーになるべきケース」と「正常系ケース」を同じテーブル・同じループで扱えるようにしています。これを分けずにwantFeeだけで表現しようとすると、「エラー時のfeeは何を期待値にすればよいか」という不自然な設計になってしまいます。
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はその場でテスト関数自体を終了させてしまう)。
1kg・5kg・10kgという境界ちょうどの値と、その直後(1.1kg・5.1kg・10.1kg)を両方含めることで、switchの条件式(<=か<か)を間違えたときにテストが確実に落ちるようにしています。境界値の「ちょうど」と「直後」をペアで用意するのは、バグの実に多くが<と<=の取り違えから生まれるためです。
💡 設計思想・なぜこう書くのか
@ParameterizedTest + @CsvSource)やデコレータ(Pythonの@pytest.mark.parametrize)といった、テストフレームワーク側の特別な仕組みが必要になることが多いです。Goではstructのスライスとfor文という、Day 004・Day 008で既に学んだ基本機能だけでパラメータ化テストが実現できます。「新しい構文を覚える」のではなく「知っている機能の新しい組み合わせ方を覚える」だけで済むのは、Goの言語仕様を意図的に小さく保つ設計判断の恩恵です。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のようなアサーションライブラリで比較コードをさらに簡潔にする方法も学びます