3-3. testing パッケージ - Go標準のテスト文化

所要時間: 40-50分 コミット内容: ~/learn/go/level3/day03/ に各種テストパターンの実装


このレッスンのゴール

  • func TestXxx(t *testing.T) の基本形を書ける
  • t.Error / t.Fatal を場面で使い分けられる
  • t.Run でサブテストに名前を付け、-run で個別実行
  • t.Helper / t.Cleanup / t.TempDir でテストヘルパーを書ける
  • go test -race / -cover / -bench を使い分けられる
  • Example 関数でドキュメント兼テストを書く

なぜ学ぶか

TS だと Jest だった、Go の test はどう違う?」 - Go のテストは DSL なし、Go の文法そのものdescribe/expectbeforeEach もない。func TestXxx(t *testing.T)t.Errorf("...") だけで成り立つ。最初は「これだけ?」と思うが、学習コストが極限まで低いことが Go コミュニティの設計思想。一方で t.Parallel() の落とし穴、t.Helper() の意味、race detector の活用法を知らないと、フレーキーテストや本番でしか出ない競合バグを量産する。

前章とのつながり

3-2_CRUDハンドラUserRepository インターフェースを書いた。本章ではそれを モック実装で単体テスト する基盤を作る。Repository インターフェースがあるからテストが書ける、という構造を体感する。

これができると何が嬉しいか

  • テストを書くハードルが激減 - 設定ファイル不要、関数1つ書くだけ
  • -race で並行アクセスのバグを CI で検出 - 本番障害の予防
  • t.Helper() で読みやすいテストヘルパー が書ける
  • Example 関数で「動くドキュメント」 が書ける

ストーリー導入: テストは「設定ファイルなし、ただの関数」

Jest なら jest.config.js、Mocha なら .mocharc.json、Vitest なら vitest.config.ts… これら全てが Go には 不要*_test.go ファイル、Test で始まる関数、go test ./... コマンド - これだけ。「DSL を作らない、Go の文法でテストを書く」哲学。testify を入れる前に標準だけで書ける力を付ける。


大前提: Go のテストはなぜ「素朴」か

Jest / Mocha / RSpec / pytest を触ってきた人は、Go のテストを最初に書いた時に「これだけ?」と思うはずです。describeexpect().toBe()before/after each も無い。あるのは:

  • func TestXxx(t *testing.T) という関数
  • t.Errorf("expected %v, got %v", want, got) というエラー報告
  • go test ./... というコマンド

それだけ。これは Go コミュニティの 「DSL を作らない、Go の文法でテストを書く」 という哲学からきています。testify/assert を使う人もいるが、コア層は標準パッケージで十分という派が依然強い。

理由は3つ:

  1. 学習コストが低い: 文法が普通の Go なので、Go を書ければテストも書ける
  2. 読みやすい: 隠蔽が少なく、何をテストしているか一目で分かる
  3. デバッグ容易: スタックトレースが綺麗、DSL の中で迷子にならない

このレッスンでは 標準パッケージだけで書く力 を最優先で身につけ、testify などの位置付けを最後に触れます。


セッション①: testing パッケージの基本(25分)

0. 録画と作業ディレクトリ

mkdir -p ~/log ~/learn/go/level3/day03
cd ~/learn/go/level3/day03
script ~/log/go_level3_day03.log
go mod init example.com/testing-play

1. 最小のテスト

// adder.go
package adder
 
func Add(a, b int) int {
    return a + b
}
// adder_test.go
package adder
 
import "testing"
 
func TestAdd(t *testing.T) {
    got := Add(2, 3)
    want := 5
    if got != want {
        t.Errorf("Add(2, 3) = %d, want %d", got, want)
    }
}
go test ./...

Go の test の3つのお約束

  1. ファイル名は *_test.go で終わる
  2. 関数名は Test で始まり、続く文字は大文字(TestAdd OK, Testadd 認識されない)
  3. シグネチャは func TestXxx(t *testing.T)

これだけで go test が自動で見つけて実行する。設定ファイル不要

*_test.go は通常ビルドには含まれず、go build の成果物に混ざらない。テストでだけ使うパッケージ(mock 等)を *_test.go だけが import する形にすると、本番バイナリに余分なコードが入らない。これも Go の設計思想の一つ。

2. t.Error vs t.Fatal の使い分け

func TestUserRepo(t *testing.T) {
    repo := setupRepo(t)
 
    u, err := repo.Create(ctx, &User{Email: "a@b.com"})
    if err != nil {
        t.Fatalf("Create failed: %v", err) // 致命的: 続行不能
    }
 
    if u.ID == 0 {
        t.Errorf("ID = 0, want non-zero") // 続行可能
    }
    if u.Email != "a@b.com" {
        t.Errorf("Email = %q, want a@b.com", u.Email)
    }
}

t.Errort.Fatal の違い

メソッド挙動使い所
t.Error / t.Errorfエラー記録、テスト関数は続行複数の独立したアサーションがある時
t.Fatal / t.Fatalfエラー記録、テスト関数を即終了続けても無意味な場合(前提条件が崩れた、nil参照になる)

例: err != nilFatal、その後のフィールド検査は Error という使い分けが定石。err が non-nil なのに次の u.ID を見たら nil pointer で panic する、というのを防ぐ。

goroutine の中で t.Fatal を呼べない

// NG
go func() {
    if err != nil {
        t.Fatal(err) // panic: Fatal called from non-test goroutine
    }
}()

t.Fatal は内部的に runtime.Goexit を呼ぶが、これは呼び出し元の goroutine しか終わらせない。テストの goroutine から呼ぶと未定義動作。

解決: t.Errorf で記録するか、chan error でメインに送ってからメイン側で t.Fatal

3. テーブル駆動の入り口(t.Run)

func TestAdd_Table(t *testing.T) {
    cases := []struct {
        name string
        a, b int
        want int
    }{
        {"positive", 2, 3, 5},
        {"zero", 0, 0, 0},
        {"negative", -1, -2, -3},
        {"overflow_ish", 1, 1, 2},
    }
 
    for _, tc := range cases {
        t.Run(tc.name, func(t *testing.T) {
            got := Add(tc.a, tc.b)
            if got != tc.want {
                t.Errorf("Add(%d, %d) = %d, want %d", tc.a, tc.b, got, tc.want)
            }
        })
    }
}
# 全部実行
go test -v ./...
 
# 特定のサブテストだけ
go test -run TestAdd_Table/zero ./...

t.Run の威力

  • ケース単位で名前が付く: 失敗時に「どのケースが落ちたか」が TestAdd_Table/positive のような形式で出る
  • 個別に実行できる: -run TestAdd_Table/zero でピンポイントテスト
  • 並列化の単位になる: t.Parallel() をサブテスト内で呼べる
  • 共通の setup を1度だけ書ける: ループの外で DB セットアップ → ループの中で Run

詳細は 3-4_テーブル駆動テスト.md で深掘りする。

4. テストヘルパー

func mustCreateUser(t *testing.T, repo UserRepository, email string) *User {
    t.Helper() // ← これがポイント
    u, err := repo.Create(ctx, &User{Email: email})
    if err != nil {
        t.Fatalf("create user: %v", err)
    }
    return u
}
 
func TestSomething(t *testing.T) {
    u := mustCreateUser(t, repo, "x@y.com")
    // ...
}

t.Helper() を呼ぶとエラー報告が呼び出し元の行番号になる

t.Helper() を呼ばないと、エラーが起きた時のスタックトレースが ヘルパー関数の行 を指す。デバッグで「あれ、このヘルパーで失敗した?」と混乱する。

t.Helper() を最初の行で呼ぶと、Go のテストランナーは「この関数はヘルパーだから、エラーは呼び出し元の行を表示してね」と判断する。自作ヘルパーには必ず t.Helper() を入れる

5. setup / teardown とサブテスト

func TestUserRepo(t *testing.T) {
    db := setupDB(t)  // ヘルパー
    t.Cleanup(func() {
        db.Close()    // テスト終了時に実行
    })
 
    repo := &UserSQLRepo{DB: db}
 
    t.Run("Create", func(t *testing.T) {
        // ...
    })
    t.Run("Get", func(t *testing.T) {
        // ...
    })
}

t.Cleanup は LIFO で実行される

Go 1.14 で導入された。テスト終了時に必ず実行される。defer の代わりに t.Cleanup を使うと、t.Run のサブテスト内・ヘルパー関数内など、defer のスコープを跨いでもクリーンアップが効く。

func setupTempFile(t *testing.T) string {
    t.Helper()
    f, _ := os.CreateTemp("", "test-*")
    t.Cleanup(func() { os.Remove(f.Name()) })
    return f.Name()
}

ヘルパー側で t.Cleanup を呼ぶと、テスト関数側は気にしなくていい。「使うとついでに片付いてくれる」 関数を作れるのが強力。

6. テストデータ: testdata/t.TempDir

// testdata ディレクトリの中はテスト時に使うフィクスチャ
//   testdata/input.json
//   testdata/expected.json
 
func TestParse(t *testing.T) {
    data, err := os.ReadFile("testdata/input.json")
    if err != nil {
        t.Fatal(err)
    }
    // ...
}
 
func TestWritesFile(t *testing.T) {
    dir := t.TempDir() // 自動で作成・テスト終了時に削除
    path := filepath.Join(dir, "out.txt")
    if err := writeOutput(path); err != nil {
        t.Fatal(err)
    }
    // ファイル検証
}

testdata/ は特別なディレクトリ

Go のツール(go build, go vet)は testdata/ という名前のディレクトリを 無視する(コンパイル対象にしない)。だから「テスト用の壊れた JSON」や「サンプル CSV」を置いても本番ビルドに影響しない。

慣習: フィクスチャは testdata/ に置く。

t.TempDir は Go 1.15+。テストごとに 専用のテンポラリディレクトリ を作って、終了時に自動削除。os.MkdirTemp + defer os.RemoveAll を書く必要が無い。ファイルを生成するテストの定番


セッション②: カバレッジ・ベンチマーク・並列化(25-30分)

7. カバレッジ計測

# テキスト形式(合計だけ)
go test -cover ./...
 
# 詳細プロファイル
go test -coverprofile=coverage.out ./...
go tool cover -func=coverage.out   # 関数ごとの被覆率
go tool cover -html=coverage.out   # ブラウザで色付き可視化

カバレッジの読み方

  • 行カバレッジ: その行が一度でも実行されたか。Go の -cover はこれ
  • 分岐カバレッジ: if/else の各分岐が実行されたか。Go では公式未対応

HTML出力が一番役に立つ: 緑が「テストされてる」、赤が「されてない」、灰色が「コメントなど」。赤いコードを目視で減らす のがレビューでよくやる作業。

カバレッジ100%を盲信しない

カバレッジ100% でも、境界値・エラー経路・並行アクセス がテストされていないことは普通にある。

func Divide(a, b int) int {
    return a / b // b=0 で panic するが、テストは a=10/b=2 だけ → 100%表示
}

カバレッジは「足りない場所を見つける道具」であって、「品質保証の指標」ではない。重要なエッジケースを意識的にテストに含める ことが本質。

8. ベンチマーク

func BenchmarkAdd(b *testing.B) {
    for i := 0; i < b.N; i++ {
        _ = Add(2, 3)
    }
}
 
func BenchmarkParseJSON(b *testing.B) {
    payload := []byte(`{"name":"alice","age":30}`)
    b.ResetTimer() // 準備の時間を計測から除外
 
    for i := 0; i < b.N; i++ {
        var u User
        if err := json.Unmarshal(payload, &u); err != nil {
            b.Fatal(err)
        }
    }
}
go test -bench=. -benchmem ./...
# BenchmarkAdd-8         1000000000    0.3 ns/op    0 B/op    0 allocs/op
# BenchmarkParseJSON-8     5000000    250  ns/op  192 B/op    4 allocs/op

ベンチマークの読み方

  • BenchmarkAdd-8: 末尾の -8 は GOMAXPROCS(並列数)
  • 1000000000: 実行回数(b.N の最終値)
  • 0.3 ns/op: 1回あたりのナノ秒
  • 192 B/op: 1回あたりのアロケーション量
  • 4 allocs/op: 1回あたりのアロケーション回数

allocs/op が0でない箇所は最適化候補。Go の GC は速いが、ホットパスでアロケーションが発生するとレイテンシ尾が伸びる。

b.ResetTimer() を入れる癖

ベンチマークの 準備フェーズ(テストデータ生成、DB接続)の時間を計測対象から外すために b.ResetTimer() を入れる。これを忘れると、ベンチ結果に準備コストが混ざって正確な計測にならない。

9. t.Parallel と落とし穴

func TestThings(t *testing.T) {
    t.Run("A", func(t *testing.T) {
        t.Parallel()
        // 並列実行される
    })
    t.Run("B", func(t *testing.T) {
        t.Parallel()
    })
}

t.Parallel() の効果と落とし穴

サブテストを並列実行する。CPU マルチコアを活かせて速い。

典型的な落とし穴1: ループ変数キャプチャ(Go 1.22 未満)

// Go 1.22 未満で N G なパターン
for _, tc := range cases {
    t.Run(tc.name, func(t *testing.T) {
        t.Parallel()
        // tc が共有変数なので、最後のケースだけ N回実行される
    })
}
 
// 対策
for _, tc := range cases {
    tc := tc // ローカル変数にコピー
    t.Run(tc.name, func(t *testing.T) {
        t.Parallel()
        // ...
    })
}

Go 1.22 以降では for ループのスコープが変わって不要になったが、古いコードで踏むトラップ

典型的な落とし穴2: 共有状態

var counter int
 
func TestX(t *testing.T) {
    t.Run("A", func(t *testing.T) {
        t.Parallel()
        counter++ // データ競合
    })
    t.Run("B", func(t *testing.T) {
        t.Parallel()
        counter++
    })
}

並列化したテストでグローバル変数を変更すると データ競合go test -race でこれを検出できる。

race detector を CI で必ず回す

go test -race ./...

Go の data race detector は実用的でない遅さではないので、CI では必ず -race を付ける。本番障害の多くは「ローカルでは気付かない並行アクセス」。race detector が見つけてくれる。

10. Example 関数(実行可能なドキュメント)

func ExampleAdd() {
    sum := Add(2, 3)
    fmt.Println(sum)
    // Output: 5
}

Example 関数は「ドキュメント + テスト」の二刀流

  • go docpkg.go.dev でパッケージのドキュメントに そのまま表示 される
  • go test で実行されて、// Output: コメントと標準出力を比較する

ドキュメントが「コンパイル可能・テストされた状態」を保てる。標準ライブラリ(fmt, strings)はこれを大量に使っている。

// Output:         // 厳密に一致
// Unordered output:  // 順不同で一致(map イテレーションなど)

11. テストの整理: 同パッケージ vs 別パッケージ

// adder_test.go
package adder // 同じパッケージ。private 関数も呼べる
 
// adder_external_test.go
package adder_test // 別パッケージ。public API だけで書く

_test サフィックスを付けたパッケージ名

Go では1つのディレクトリに2種類のテストパッケージを置ける:

  • ホワイトボックステスト (package adder): 内部実装にアクセス可能
  • ブラックボックステスト (package adder_test): 外部から見える API だけで書く

ブラックボックステストは「外部ユーザー目線の API テスト」になる。両方を意識して使い分け ると、内部実装の変更で破壊されにくいテストが書ける。

12. テストの実行制御

# 詳細出力
go test -v ./...
 
# 短いテストだけ実行(testing.Short() で分岐)
go test -short ./...
 
# タイムアウト変更(デフォルト10分)
go test -timeout 30s ./...
 
# 失敗時に即座に止める
go test -failfast ./...
 
# 特定のテスト名で絞る(正規表現)
go test -run "TestUser/Create" ./...
 
# キャッシュ無効化
go test -count=1 ./...

testing.Short() で重いテストをスキップ

func TestSlowIntegration(t *testing.T) {
    if testing.Short() {
        t.Skip("skipping in short mode")
    }
    // 重い処理
}

go test -short を渡すと、testing.Short()true を返す。ローカル開発では -short で早く回し、CI では普通に回す という運用が定石。

13. アンチパターン集

アンチパターン: 環境依存テスト

// NG
func TestNow(t *testing.T) {
    n := time.Now()
    // n を使った検証 → 実行時刻で結果が変わる
}

解決: 「時刻を返す関数」を依存性注入する。

type Clock interface { Now() time.Time }
 
type fakeClock struct{ t time.Time }
func (c fakeClock) Now() time.Time { return c.t }

アンチパターン: ネットワーク依存

// NG
resp, _ := http.Get("https://api.example.com/...")

外部APIに依存すると CI で落ちる遅い叩きすぎてレート制限。 解決: httptest.NewServer でモックサーバーを立てる。3-4_テーブル駆動テスト.md で詳しくやる。

アンチパターン: テストの順序依存

// NG: TestA で作ったレコードを TestB が前提にしている

Go のテストは 並列・順序未定義 が前提。各テストは「ゼロから自立する」よう書く。setup を t.Run の外で1回やる場合も、サブテスト間の独立性は保つ。

アンチパターン: フレーキーテスト

「たまに落ちる」テスト。原因は大抵:

  • time.Sleep(100*time.Millisecond) で待つ → CI のマシンによって変動
  • グローバル状態の競合
  • 乱数シードを固定していない

「待ち時間」を入れたくなったら、ポーリング + タイムアウト に書き換える。

deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
    if condition() {
        return // 成功
    }
    time.Sleep(10 * time.Millisecond)
}
t.Fatal("condition not met within timeout")

14. testify は使うべき?

// testify を使う場合
import "github.com/stretchr/testify/assert"
 
func TestSomething(t *testing.T) {
    u, err := repo.GetByID(ctx, 1)
    assert.NoError(t, err)
    assert.Equal(t, "alice", u.Name)
    assert.NotEmpty(t, u.Email)
}

testify の賛否

: アサーションが短く読める、エラーメッセージが綺麗、assert.ElementsMatch などのリッチな比較

: 「Go なのに Java/Ruby っぽい DSL になる」、エラー時のスタックが分かりにくくなる場合、追加依存

コミュニティの空気感:

  • 標準パッケージ + t.Errorf 派: 「Go らしく素朴に」
  • testify 派: 「冗長な if 文を書きたくない」
  • go-cmp 派(Google系): cmp.Diff で構造体比較

個人的おすすめ: 標準で書けるところは標準、構造体の deep equal だけ go-cmp を使う、テスト中の長い if 文に疲れたら testify/requirerequireFatal 系の挙動)を入れる。

go-cmp で構造体比較

import "github.com/google/go-cmp/cmp"
 
if diff := cmp.Diff(want, got); diff != "" {
    t.Errorf("mismatch (-want +got):\n%s", diff)
}

構造体の深い比較で差分を綺麗に出してくれる。reflect.DeepEqual より読みやすい出力。


練習課題

3-2_CRUDハンドラ.md で作った UserRepository 実装に対して:

  1. TestUserRepo_Create を書く(成功ケース・重複メールエラーケース)
  2. t.Run でケース名を付ける
  3. t.Cleanup でテスト後に DB を綺麗にする
  4. t.Helper() を使ったヘルパー関数を作る
  5. go test -race ./... を通す
  6. go test -cover でカバレッジを確認
  7. BenchmarkUserRepo_GetByID を書いて1クエリの時間を計測

締め: git で証跡を残す

cd ~/learn/go/level3/day03
git add .
git commit -m "feat(go): testing パッケージで基礎テストパターンを実装"

アンチパターン集 - やらかし事例

テスト設計のやらかし

1. goroutine 内で t.Fatal

go func() { t.Fatal(err) }()  // panic

t.Fatal は呼び出し元 goroutine しか終わらせない。t.Error に変えるか chan で送る。

2. ループ変数キャプチャ罠(Go 1.21 以前)

for _, tc := range cases {
    t.Run(tc.name, func(t *testing.T) {
        t.Parallel()
        // tc が共有変数 → 最後のケースだけ N回実行
    })
}

Go 1.22+ で修正済。古いコードでは tc := tc でコピー。

3. テストの順序依存 「TestA で作った行を TestB が前提」 → 並列実行で壊れる。各テストが自立すべし。

4. time.Sleep で待つフレーキーテスト CI のマシン速度差で落ちる。ポーリング+タイムアウトに置き換え。

5. ネットワーク依存テスト

http.Get("https://api.example.com/...")  // CI で落ちる

httptest.NewServer でモック化。

6. t.Helper() 忘れ エラーがヘルパー関数の行を指す → デバッグで混乱。自作ヘルパー冒頭で t.Helper() 必須。

対比表で違いを明確化

t.Error vs t.Fatal

メソッド挙動用途
t.Error / t.Errorf記録のみ、続行複数アサーション、独立
t.Fatal / t.Fatalf記録 + 即終了前提崩れ、続けると nil panic

標準 vs testify vs go-cmp

観点標準testifygo-cmp
学習コスト
構造体比較reflect.DeepEqualassert.Equalcmp.Diff
エラーメッセージ自前で書く自動整形差分表示が綺麗
推奨全ての基礎短く書きたい時構造体 deep equal

ホワイトボックス vs ブラックボックス

観点package foopackage foo_test
アクセスprivate OKpublic のみ
用途内部実装の単体テスト外部 API テスト
場所foo/foo_test.gofoo/foo_external_test.go

自己評価チェックリスト

手を動かせた

  • func TestXxx(t *testing.T) を書いて go test で実行
  • t.Run でサブテスト
  • t.Helper() を自作ヘルパーに入れた
  • t.Cleanup で後片付けを集約
  • t.TempDir() で一時ディレクトリ
  • go test -cover でカバレッジ確認
  • BenchmarkXxx + -benchmem で計測
  • t.Parallel() + go test -race で並行検証
  • ExampleXxx を1つ書いた

説明できる

  • t.Errort.Fatal の使い分け
  • t.Helper() を呼ぶ意義
  • race detector が見つけるバグの種類
  • testify vs 標準パッケージの哲学

やらかし回避

  • goroutine 内で t.Fatal しない
  • テストの順序依存を作らない
  • time.Sleep でなくポーリングで待つ
  • ネットワーク依存は httptest でモック化

詰まった時のチートシート

やりたいこと書き方
テスト関数func TestXxx(t *testing.T)
エラー記録(続行可)t.Errorf("...")
エラー記録(即終了)t.Fatalf("...")
サブテストt.Run("name", func(t *testing.T) { ... })
クリーンアップt.Cleanup(func() { ... })
ヘルパー関数冒頭で t.Helper()
並列実行t.Parallel()
ベンチマークfunc BenchmarkXxx(b *testing.B) + for i := 0; i < b.N; i++
一時ディレクトリt.TempDir()
Examplefunc ExampleXxx() + // Output: コメント
実行go test ./...
カバレッジgo test -coverprofile=c.out -o /dev/null ./...
race detectorgo test -race ./...

「実務OK」基準

  • -cover-race を本能的に併用する: 単体・並行両方検証
  • t.Helper を自作ヘルパーで使える: 行番号がズレない
  • フレーキーテストを書かない: time.Sleep ではなくポーリング、共有状態を排除
  • Example で公開関数のドキュメントを書ける: ドキュメントとテストを一体化
  • testify vs 標準パッケージの哲学を語れる

さらに深掘りするなら


次のレッスン

3-4 テーブル駆動テスト でテーブル駆動テスト、モック、httptest、go-cmp の深掘りをする。

つながりの予告

  • 本章の t.Runテーブル駆動の核として次章で本格活用
  • httptest.NewServer で外部 API モック化
  • mock 実装で 3-2_CRUDハンドラ の Repository をテスト