Day 024 — testing パッケージ②(サブテスト・testify)

2026-08-21 🔵 中級者 / Phase 2 実装 testing パッケージ②(サブテスト・testify)

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

Day 023では、テーブル駆動テストの基本形——「構造体のスライス+forループ+t.Errorf」——を学びました。このスタイルには、実務で使ううえで2つの不便な点が残っています。

  1. 失敗した1件が、テスト結果の一覧上で見分けにくい。go test -vを実行しても、表示されるのはTestShippingFeeTableという1個のテスト名だけです。11ケース中1ケースが落ちても、出力をt.Errorfのメッセージまで読まないと「どのケースが落ちたか」が分かりません。
  2. 特定の1ケースだけを再実行したいときに絞り込めない。go test -runはテスト関数名を正規表現でマッチさせる仕組みなので、TestShippingFeeTableという1つの関数の中にある11ケースのうち1つだけをピンポイントで再実行する手段がありません。

Goのtestingパッケージは、この2点をサブテスト(subtest)という仕組みで解決します。使い方はシンプルで、t.Run(名前, 関数)を呼ぶだけです。

for _, c := range cases {
	t.Run(c.name, func(t *testing.T) {
		// このtは親のtとは別の *testing.T
		// ここでの Errorf/Fatalf はこのサブテストだけを失敗させる
	})
}

t.Runを使うと、次の3つが手に入ります。

  • 個別の合否表示: go test -vの出力が--- PASS: TestShippingFeeTable/zero_weight_is_invalidのように、ケースごとの行になります。どのケースが落ちたかが一目で分かります。
  • 名前による絞り込み実行: go test -run "TestShippingFeeTable/zero"のように、/区切りでサブテスト名までマッチさせて特定の1ケースだけを再実行できます。
  • t.Fatalfの影響範囲がケース単位に閉じる: サブテスト内でt.Fatalfを呼んでも、そのサブテストが終了するだけで、他のケースの実行は止まりません。Day 023では「t.Fatalfはループ全体を止めるので使えない」という制約がありましたが、t.Runを使えばこの制約自体がなくなります。

もう1つ、Day 023のコードには「比較のたびにif got != want { t.Errorf(...) }という定型文を書く」という地味な繰り返しがありました。これを解消するのがtestify(github.com/stretchr/testify)という、Goコミュニティで最も広く使われているサードパーティ製アサーションライブラリです。標準のtestingパッケージは意図的に薄く保たれていて、比較ロジックやメッセージ整形は自分で書く前提になっています。testifyはその上に、assert.Equal(t, want, got)のような読みやすい比較関数を提供する薄いレイヤーです。

// 標準のtestingだけの場合
if got != want {
	t.Errorf("got %v, want %v", got, want)
}

// testifyの場合
assert.Equal(t, want, got)

testifyにはassertとrequireという2つのサブパッケージがあり、役割が明確に分かれています。

パッケージ失敗時の挙動使いどころ
assertt.Errorfと同じ。失敗を記録して処理を継続複数の項目を1つのテストでまとめて検証したいとき
requiret.Fatalfと同じ。失敗したらその場でテストを終了後続のコードが「その値が正しく取れている」ことに依存しているとき(例: errがnilでなければnilのはずの戻り値に触れるとpanicする)

Goの標準ツールチェーンはtestifyのような外部ライブラリの追加をgo get一発でサポートしており、go.modに依存が1行追加されるだけです。「標準ライブラリだけで完結させる」Goらしさと、「実務ではテストの可読性のために薄い外部ライブラリを足すのが一般的」という現実のバランスを、今日は両方体感します。

📝 問題

Day 023で実装したShippingFee関数(重量と速達フラグから配送料金を計算する関数)はそのまま使い、テストコードだけを次の3段階でリファクタリングしてください。

Part A: サブテスト化

Day 023のTestShippingFeeTableをコピーし、forループの中身をt.Run(c.name, func(t *testing.T) { ... })で包んでサブテスト化すること。

  • サブテスト名(c.name)に空白が含まれる場合、go testの出力上では自動的に_に置換される(例: "zero weight is invalid" → TestShippingFeeTable/zero_weight_is_invalid)。この置換ルールは覚えておくこと(後述の「よくある誤解」参照)
  • サブテスト内のアサーションは、いったんDay 023と同じt.Errorfのままでよい(Part Bでtestifyに置き換える)

Part B: testifyへの置き換え

assert・requireを使ってアサーションを書き換えること。

  • errがnilであるべきケースの検証にはrequire.NoError(t, err)を使うこと(errがnilでなければ、その後のgotFeeの比較に進む意味がないため)
  • errが非nilであるべきケースの検証にはrequire.Error(t, err)を使うこと
  • gotFeeとc.wantFeeの比較にはassert.Equal(t, c.wantFee, gotFee)を使うこと(assert.Equalの引数順は(t, 期待値, 実際の値)であることに注意)

Part C: 並列実行

すべてのサブテストの先頭でt.Parallel()を呼び、サブテスト同士が並列に実行されるようにすること。

  • go test -v ./...を実行し、出力にPAUSE(t.Parallel()呼び出し時点で一時停止し、親テストの他のサブテストが全てt.Parallel()を呼び終えてから再開・並列実行される)が表示されることを確認すること
  • go test -run "TestShippingFeeTable/1kg"のように、サブテスト名で絞り込んで実行できることも確認すること

go.modにgithub.com/stretchr/testifyへの依存を追加し、go run . / go test -v ./...でそのまま動作する完全なコードとして提示すること。

🔍 ヒント(段階的開示)

ヒント1 — 方向性

t.Runは「新しいテスト関数を作る」のではなく、「今のループの中に、もう1段小さいテストを埋め込む」機能です。渡す第2引数の関数func(t *testing.T) {...}が受け取るtは、外側のtとは別物(サブテスト専用の*testing.T)である点を意識してください。testifyのassert/requireの関数群は、このt(testing.Tが実装しているt.Errorf相当のインターフェース)を第1引数として受け取るだけの、ごく薄いラッパーです。

ヒント2 — アプローチ
  • go.modにrequire github.com/stretchr/testify v1.9.0を追記し、main_test.goで"github.com/stretchr/testify/assert"と"github.com/stretchr/testify/require"をimportします
  • t.Parallel()はt.Runに渡す関数の先頭で呼び出します。ループ変数cはGo 1.22以降、forの各イテレーションごとに新しい変数として扱われるため、c := cのような明示的なキャプチャは不要です(Go 1.21以前ではこのキャプチャが必須でした。理由は「よくある誤解」参照)
  • require.NoError(t, err)はエラーがあった場合そこでサブテストをFailNowする(後続のassert.Equalに進まない)ため、Day 023で書いた「err != nilならcontinue」という手動の分岐が不要になります
  • wantErrがtrueのケースの分岐はif c.wantErr { require.Error(t, err); return }のように早期returnで表現すると読みやすくなります
ヒント3 — コード骨格
package main

import (
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func TestShippingFeeTable(t *testing.T) {
	cases := []struct {
		name     string
		weightKg float64
		express  bool
		wantFee  int
		wantErr  bool
	}{
		// Day 023 のテーブルをそのまま流用
	}

	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			t.Parallel()

			gotFee, err := ShippingFee(c.weightKg, c.express)

			if c.wantErr {
				require.Error(t, err)
				return
			}

			require.NoError(t, err)
			assert.Equal(t, c.wantFee, gotFee)
		})
	}
}

✅ 模範解答

ファイル: go.mod

module example.com/day024

go 1.22

require github.com/stretchr/testify v1.9.0

require (
	github.com/davecgh/go-spew v1.1.1 // indirect
	github.com/pmezard/go-difflib v1.0.0 // indirect
	gopkg.in/yaml.v3 v3.0.1 // indirect
)

※ testifyのassert/requireパッケージ自体はgo-spew(構造体の差分表示)・go-difflib(差分計算)・yaml.v3(一部のフォーマッタ)を間接依存として使っています。実際にこのモジュールでgo mod tidyを実行すると、これらの行と対応するgo.sumが自動生成されます。

ファイル: main.go

package main

import (
	"errors"
	"fmt"
)

// ShippingFee は重量(kg)と速達フラグから配送料金を計算する。
// weightKg が 0 以下の場合はエラーを返す。
// express が true の場合、通常料金の2倍になる。
// (Day 023 の実装をそのまま流用)
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)
}

ファイル: main_test.go

package main

import (
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

// TestShippingFeeTable は Day 023 のテーブルを、
// サブテスト化 + testify + 並列実行 に書き換えたもの。
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 {
		t.Run(c.name, func(t *testing.T) {
			t.Parallel() // Go 1.22+ なので c のキャプチャ不要

			gotFee, err := ShippingFee(c.weightKg, c.express)

			if c.wantErr {
				require.Error(t, err, "ShippingFee(%.2f, %v) should return an error", c.weightKg, c.express)
				return
			}

			require.NoError(t, err, "ShippingFee(%.2f, %v) should not return an error", c.weightKg, c.express)
			assert.Equal(t, c.wantFee, gotFee, "ShippingFee(%.2f, %v)", c.weightKg, c.express)
		})
	}
}
▶ 実行結果を見る(go run . / go test -v ./... で検証済み)
$ go run .
3.5kg 通常便: 800 <nil>

$ go test -v ./...
=== RUN   TestShippingFeeTable
=== RUN   TestShippingFeeTable/below_zero_weight_is_invalid
=== PAUSE TestShippingFeeTable/below_zero_weight_is_invalid
=== RUN   TestShippingFeeTable/zero_weight_is_invalid
=== PAUSE TestShippingFeeTable/zero_weight_is_invalid
=== RUN   TestShippingFeeTable/1kg_exactly_at_light_tier_boundary
=== PAUSE TestShippingFeeTable/1kg_exactly_at_light_tier_boundary
...(以下、全11ケース分のRUN/PAUSEが続く)...
=== CONT  TestShippingFeeTable/below_zero_weight_is_invalid
=== CONT  TestShippingFeeTable/1kg_exactly_at_light_tier_boundary
--- PASS: TestShippingFeeTable/below_zero_weight_is_invalid (0.00s)
--- PASS: TestShippingFeeTable/1kg_exactly_at_light_tier_boundary (0.00s)
...(以下、全11ケース分のPASSが続く)...
--- PASS: TestShippingFeeTable (0.00s)
PASS
ok  	example.com/day024	0.003s

$ go test -run "TestShippingFeeTable/1kg" -v ./...
=== RUN   TestShippingFeeTable
=== RUN   TestShippingFeeTable/1kg_exactly_at_light_tier_boundary
=== PAUSE TestShippingFeeTable/1kg_exactly_at_light_tier_boundary
=== RUN   TestShippingFeeTable/1kg_light_tier_with_express
=== PAUSE TestShippingFeeTable/1kg_light_tier_with_express
=== CONT  TestShippingFeeTable/1kg_exactly_at_light_tier_boundary
=== CONT  TestShippingFeeTable/1kg_light_tier_with_express
--- PASS: TestShippingFeeTable/1kg_exactly_at_light_tier_boundary (0.00s)
--- PASS: TestShippingFeeTable/1kg_light_tier_with_express (0.00s)
--- PASS: TestShippingFeeTable (0.00s)
PASS
ok  	example.com/day024	0.002s

※ go.mod・main.go・main_test.goを1つのモジュール(module example.com/day024)として配置し、go mod tidyでtestifyの依存を解決した上でgo run . / go test -v ./...で検証済みです。-runの"TestShippingFeeTable/1kg"は正規表現マッチのため、1kgを含む2ケース(1kg_exactly_at_light_tier_boundaryと1kg_light_tier_with_express)が両方ヒットしています。

🪜 Step-by-Step 解説

1
ループの中身をそのままt.Runで包む
for _, c := range cases {
	t.Run(c.name, func(t *testing.T) {
		// ここに検証ロジック
	})
}
まず一番シンプルな変更として、forループの中身を無名関数に切り出し、t.Run(c.name, ...)に渡すだけです。この時点でDay 023のコードと機能的な違いはほぼありません(t.Errorfのままでも動きます)が、go test -vの出力がケースごとの行に分かれる効果はこの1ステップだけで得られます。
2
requireで「前提条件」を、assertで「検証項目」を書き分ける
if c.wantErr {
	require.Error(t, err, /* ... */)
	return
}
require.NoError(t, err, /* ... */)
assert.Equal(t, c.wantFee, gotFee, /* ... */)
ここでのrequire.NoError(t, err, ...)は「errがnilでなければ、そもそもgotFeeを比較する意味がない(err != nilのときgotFeeは0固定なので比較しても情報がない)」という前提条件のチェックです。requireは失敗した瞬間にサブテストを終了させるため、Day 023であった「err != nilのときはcontinueする」という手動の制御フローが不要になります。一方gotFeeの比較は「このサブテストの本題」なので、複数のアサーションを積み重ねられるassertを使います(今回は1個しかありませんが、フィールドが増えたときにassertなら複数の不一致を1回の実行でまとめて報告できます)。
3
t.Parallel()を追加する
t.Run(c.name, func(t *testing.T) {
	t.Parallel()
	// ...
})
サブテスト関数の先頭でt.Parallel()を呼ぶと、そのサブテストは即座に一時停止(PAUSE)し、親テスト(TestShippingFeeTable)内の他のすべてのサブテストが「t.Parallel()を呼ぶところまで」進むのを待ってから、まとめて並列実行(CONT)されます。今回のように1ケースの実行が一瞬(マイクロ秒オーダー)で終わるテストでは並列化の恩恵は体感しにくいですが、HTTPリクエストを伴うテストやI/O待ちのあるテストでは、11ケースを直列に実行するのと並列に実行するのとで実行時間に大きな差が出ます。
4
go.modに依存を追加する
require github.com/stretchr/testify v1.9.0
assert・requireパッケージをimportした状態でgo mod tidyを実行すると、Goツールチェーンが自動的にこの行と、間接依存(go-spew・go-difflib・yaml.v3)・go.sumのハッシュ値を生成します。標準ライブラリだけで完結していたDay 023までとの違いはここだけで、コードの書き方自体はGoの通常のimportと何も変わりません。

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

📌
「階層」を型ではなくAPIで表現する: 他言語のテストフレームワークでは、パラメータ化テストや入れ子のテストグループを表現するために専用のクラス・アノテーション階層(JUnitの@Nestedなど)を使うことが多いです。Goではt.Runが単に「今の*testing.Tから、子の*testing.Tを作って関数を1つ実行する」という素朴なAPI呼び出しでしかありません。階層構造は「t.Runをネストして呼ぶ」という実行時のコードの形がそのまま反映されるだけで、専用の構文や型を新しく学ぶ必要がありません。
📌
標準ライブラリは薄く保ち、好みが分かれる部分は外部に任せる: testingパッケージがassert.Equalのような比較ヘルパーを持たないのは、Go開発チームの怠慢ではなく意図的な設計判断です。「等値比較の失敗メッセージはどう整形すべきか」「浮動小数点はどこまで許容誤差を持たせるべきか」といった問いには唯一の正解がなく、プロジェクトや好みによって最適解が変わります。標準ライブラリはその判断に立ち入らずt.Errorfという最小限のプリミティブだけを提供し、testifyのような外部ライブラリが「多数派に支持される1つの解」を提示する——という役割分担は、Goの「言語・標準ライブラリは小さく保ち、エコシステムで多様性を吸収する」という一貫した設計思想の表れです。

🌐 他言語との比較

観点Go(t.Run + testify)Java(JUnit 5)Python(pytest)TypeScript(Jest)
個別ケースの入れ子表現t.Run(name, func(t *testing.T){...})を好きな階層でネスト可能@Nestedアノテーション付きの内部クラスクラスベースのグルーピング、またはpytestのfixture合成describeブロックのネスト
名前による絞り込み実行go test -run "親/子"(正規表現、/区切り)IDEやMaven/Gradleの-Dtest=指定pytest -k "キーワード"test.only / -t "パターン"
アサーション比較の書き方標準はif got != want、testifyならassert.Equal(t, want, got)JUnit標準のassertEquals(want, got)(フレームワーク内蔵)標準のassert got == want(言語機能そのもの)expect(got).toBe(want)(フレームワーク内蔵)
アサーションライブラリの位置づけ標準に含まれず外部パッケージ(testify等)を追加するのが一般的フレームワーク本体に標準搭載言語のassert文+pytestの高度な差分表示が標準搭載フレームワーク本体に標準搭載
「失敗したら即終了」と「継続」の使い分けrequire(即終了) vs assert(継続)を明示的に選択明示的な使い分けの概念は薄い(例外で自然と即終了)明示的な使い分けの概念は薄い明示的な使い分けの概念は薄い

Java・pytest・Jestは「アサーション機能がフレームワークに標準搭載されている」のが共通点で、外部ライブラリを足すという発想自体が薄いです。一方Goではrequire/assertという2種類の失敗挙動を自分で選んで書く設計になっており、「このチェックが失敗したら後続の処理に意味がなくなるか」を書く側が毎回意識させられる点が特徴的です。この「暗黙に決まらず、書く側が明示する」という感触は、Goのエラーハンドリング(if err != nilを毎回書く)と同じ設計哲学の延長線上にあります。

🏆 実務での使いどころ

  • CI高速化のための並列化: I/O待ちを伴う統合テスト(DB接続・HTTPモック等)が多いテストスイートでは、各サブテストにt.Parallel()を仕込むだけでCIの実行時間を大幅に短縮できることがあります
  • 障害調査での絞り込み実行: CIで特定のケースだけが落ちたとき、go test -run "TestXxx/該当ケース名"でそのケースだけをローカルで即座に再現・デバッグできます。サブテスト名を「何が起きるべきか」で丁寧に書いておくことが、この絞り込みの精度に直結します
  • 構造体の差分をtestifyで見やすく: assert.Equalはプリミティブ値だけでなく構造体・スライス・マップの比較にも使え、失敗時にフィールド単位の差分(go-spewによるダンプ)を表示してくれるため、標準のt.Errorf("%v", got)より障害調査が速くなります
  • requireでセットアップの失敗を早期検出: テストの前準備(テスト用DBへの接続・テストデータのINSERT等)が失敗した場合、require.NoError(t, err)でその場でテストを止めることで、「セットアップが失敗しているのに本題のアサーションでnilポインタ参照してpanicする」という分かりにくい二次障害を防げます

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
ループ変数cを並列サブテストの外でキャプチャし忘れて、全サブテストが最後のケースの値で実行されるGo 1.21以前のfor文はループ変数を全イテレーションで再利用していたため、t.Parallel()でサブテストが遅延実行されるまでにループが進み、cの値が書き換わっていたGo 1.22以降はforの各イテレーションで新しい変数インスタンスが作られるようになったため、c := cの明示的キャプチャは不要になった。ただしgo.modのgoディレクティブが1.22未満のプロジェクトでは今も必要(自分のプロジェクトのgo.modのバージョンを必ず確認すること)
assert.Equal(t, got, want)のように引数順を逆にしてしまう「実際の値→期待値」という自然言語の語順につられるtestifyは(t, expected, actual, ...)の順(expectedが先)。順序を間違えても比較結果自体は変わらないが、失敗時のメッセージがexpected: <実際の値>, actual: <期待値>のように逆転して表示され、デバッグ時に混乱する
assertとrequireをなんとなく使い分けず、全部assertで統一してしまうどちらも同じような見た目の関数なので違いを意識しないerrがnilのはずなのにassert.NoErrorのまま後続でerrの中身(例: err.Code)に触れると、errが実際に非nilだった場合にnilポインタ参照でpanicする。「後続のコードが今のチェックの成功に依存しているか」でrequire/assertを選ぶ
サブテスト名に含めた記号(/や.など)がgo test -runのパターンマッチを予期せず壊すサブテスト名は自由な文字列だと思い込んでいるt.Runの名前に含まれる空白は_に自動置換されるが、/は階層区切り文字として特別扱いされる。サブテスト名に/を含めると意図しない階層ができてしまうため、避けるのが無難

🚀 次のステップ

  • 発展: 今日のテストにassert.InDelta(浮動小数点の許容誤差付き比較)を試しに使ってみて、weightKgの計算に浮動小数点誤差が絡むケース(例: 0.1 + 0.2のような値)でなぜassert.EqualではなくInDeltaが必要になるのかを確認してみましょう
  • 次回予告: Day 025 — ベンチマークテスト(実装)。testing.Bとb.Nを使い、ShippingFeeのような関数の実行時間・アロケーション回数を計測する方法を学びます。今日学んだt.Runと対になるb.Run(サブベンチマーク)にも触れます

🎯 自己評価

自分の回答

気づき・メモ