Claude Media
Claude CodeでGoのテーブル駆動テストを書く — -raceとカバレッジを完了条件に

Claude CodeでGoのテーブル駆動テストを書く — -raceとカバレッジを完了条件に

Claude Codeに書かせるGoのテーブル駆動テストの規約をCLAUDE.mdに置き、go test -race・-count・カバレッジをStop hookの完了条件にして、失敗出力を絞って返す書き方をまとめます。

テーブル駆動テストは「規約」と「完了条件」の2点で任せられる

Goのテーブル駆動テストは、入力と期待値を持つ構造体の表を作り、1つのテスト本体でその表を回す書き方です。公式Wikiは、テーブル駆動テストを「ツールでもパッケージでもなく、テストをきれいに書くための方法と視点」と説明しています。

この書き方は、Claude Codeに任せやすい部類です。ケースの追加は表に1行足すだけで済み、テスト本体は一度書けば使い回されます。一方、任せきりにすると崩れる点も2つあります。

  • サブテスト名が空や連番になり、失敗したときにどのケースか分からない
  • 並行処理に触れるコードで、通常のgo testは通るのにレースが残る

前者はCLAUDE.mdの規約で、後者はgo test -raceを終了条件にするhookで防ぎます。Go全体の進め方はClaude CodeでGoアプリを開発する手順にあります。ここではテストの書き方と、その検証をhookに載せる部分に絞ります。

CLAUDE.mdにテストの書き方を書く

Claude Codeに「テーブル駆動で」と毎回頼む代わりに、書き方の規約をCLAUDE.mdに置きます。規約の根拠は公式Wikiの記述です。

## Goのテスト規約
- 表は []struct か map[string]struct で作り、各ケースに名前を付ける
- 本体は t.Run(名前, func(t *testing.T) {...}) で回す
- 失敗メッセージは入力・got・want を含める
  例: t.Errorf("Reverse(%q) = %q, want %q", in, got, want)
- 後続の検証が無意味になるときだけ t.Fatalf、それ以外は t.Errorf
- 並行に走らせてよいケースだけ t.Parallel() を付ける
- go.mod の go 行が 1.22 未満なら t.Run 内で使うループ変数を tt := tt で再宣言する(1.22 以上は不要)
- 検証は go test -race -count=1 ./... が通ること

各行の理由は次のとおりです。

サブテスト名には入力か説明を使います。Wikiのfmtの例では、入力そのものをt.Runの名前に使っています。失敗時に「どのケースが、何を期待して、何を返したか」がテストコードを読まずに分かる、という趣旨です。mapで表を持つ場合は、キーがそのままテスト名になります。

t.Errorfはアサーションではありません。Wikiの記述では、t.Errorfは失敗を記録してもテストを止めません。整数入力のテストなら「全入力で失敗するのか、奇数だけか、2の冪だけか」が分かるため、継続できる場面ではt.Errorfが向きます。mapの例がt.Fatalfを使っているのは、その後の検証が意味を持たないケースです。規約にt.Fatalfの使いどころを書いたのはそのためです。

次の例は、規約どおりに書かれたテストの形です。Wikiのmapの例をもとにした例示で、Claudeの実出力ではありません。

func TestReverse(t *testing.T) {
	tests := map[string]struct {
		input string
		want  string
	}{
		"empty string":         {input: "", want: ""},
		"one character":        {input: "x", want: "x"},
		"one multi byte glyph": {input: "🎉", want: "🎉"},
		"multiple glyphs":      {input: "🥳🎉🐶", want: "🐶🎉🥳"},
	}
 
	for name, tt := range tests {
		t.Run(name, func(t *testing.T) {
			t.Parallel()
			if got := Reverse(tt.input); got != tt.want {
				t.Errorf("Reverse(%q) = %q, want %q", tt.input, got, tt.want)
			}
		})
	}
}

mapを選ぶ利点も、Wikiに書かれています。mapの反復順は仕様で定まらず、反復のたびに変わりうるため、テスト同士が独立していて実行順に結果が左右されないことの確認になります。

t.Parallelを付けるときのループ変数

並行実行にすると、ループ変数の扱いが落とし穴になります。Wikiの並行テストの例には、ループ変数を再宣言する行がGo < 1.22向けの注記つきで残っています。Go 1.22のリリースノートには、ループ変数が反復ごとに新しく作られるようになったと書かれており、再宣言が要るかどうかはgo.modのGoバージョンで変わります。

そこでCLAUDE.mdには、go.modのgo行を前提として書いておきます。1.22未満のモジュールなら「tt := ttを付ける」、1.22以上なら不要、という分岐をClaudeに読ませるためです。1.22未満のモジュールでは、再宣言つきの次の形になります。Wikiの並行テストの例をもとにした例示です。

for name, tt := range tests {
	tt := tt // Go 1.22未満のみ必要
	t.Run(name, func(t *testing.T) {
		t.Parallel()
		// 検証は上の例と同じ
	})
}

再宣言を忘れると、並行に走るサブテストが最後の反復のケースを参照し、同じ入力を何度も検証する形になりえます。go.modが1.22以上なら、この1行は付けなくて構いません。

完了条件をStop hookに置く

規約を書いても、守られたかどうかはテストを走らせるまで分かりません。そこで、Claudeが「終わりました」と言う瞬間にgo testを走らせるStop hookを置きます。Stop hookはClaudeが応答を終えるたびに発火し、decision: "block"か、exit 2で終了コードとstderrを返すと、Claudeは止まらず続きの作業に入ります。exit 2のstderrは、Claudeが続行する理由として渡されます。

走らせるコマンドの中身

オプション役割根拠
-race役割レース検出器を有効にする根拠go testにも-raceを付けられる(レース検出器の解説)
-count=1役割テストの結果キャッシュを使わない根拠キャッシュを明示的に無効にする定番の方法とされている
-coverprofile=cover.out役割カバレッジを記録する根拠テスト成功後にプロファイルを書き出す
-failfast役割最初の失敗以降の新規テストを始めない根拠出力を短くしたいときに使う

レース検出器には前提条件があります。cgoが有効である必要があり、Darwin以外ではCコンパイラが要ります。対応プラットフォームも限られているので、CIとローカルで-raceが使えるかを先に確かめます。

カバレッジには、もう一点あります。-covermodeの既定はsetですが、-raceを付けるとatomicになります。atomicはマルチスレッドのテストでも正しく数えられる代わり、かなり高コストと説明されています。

hookスクリプト

次のスクリプトを.claude/hooks/go-test-gate.shに置きます。公式ガイドのStop hook例にならい、stop_hook_activeを見て無限ループを避けます。

#!/bin/bash
INPUT=$(cat)
# すでにこのhookで続行中なら止めない
if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
  exit 0
fi
 
cd "$CLAUDE_PROJECT_DIR" || exit 0
[ -f go.mod ] || exit 0
 
OUT=$(go test -race -count=1 -failfast -coverprofile=cover.out ./... 2>&1)
if [ $? -ne 0 ]; then
  {
    echo "go test -race が失敗しました。要点だけ抜粋します。"
    echo "$OUT" | grep -E "FAIL|panic|DATA RACE|_test.go:[0-9]+" | head -30
  } >&2
  exit 2
fi
 
TOTAL=$(go tool cover -func=cover.out | tail -1 | awk '{print $3}' | tr -d '%')
if awk -v t="$TOTAL" 'BEGIN { exit !(t < 80) }'; then
  echo "カバレッジが ${TOTAL}% です。80% 以上にしてください。" >&2
  exit 2
fi
exit 0

settings.jsonには次のように登録します。Stop hookはmatcherを持たないため、matcherは書きません。

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/go-test-gate.sh",
            "timeout": 600
          }
        ]
      }
    ]
  }
}

timeoutは秒で、command hookの既定は10分です。レース検出器つきの全パッケージ実行が長くなるリポジトリでは、ここを明示しておくと、遅いのか止まったのかを切り分けやすくなります。

go tool cover -funcの最終行は、Goのカバレッジ解説ではtotal:で始まり、(statements)と割合が並ぶ形で示されています。スクリプトのawk '{print $3}'はその3列目を取る前提なので、使うGoのバージョンで出力を一度確かめてください。

スクリプトはcover.outをプロジェクト直下に書き出します。コミットに混ざらないよう、.gitignoreにcover.outを入れておきます。

失敗出力を絞って返す

テストの出力は、そのままClaudeに返すと長くなりがちです。絞り方は3段あります。

  1. -failfastで、最初の失敗以降は新しいテストを始めない
  2. grepとheadで、失敗行・パニック・レース警告・テストファイルの行番号だけを抜く
  3. 再現はサブテスト単位に絞り、-runの正規表現で対象を指定する

-runは、スラッシュで区切って親テストとサブテストを順に絞れます。go test -run 'TestReverse/empty' -vのように書くと、該当ケースだけを詳細ログつきで見られます。ただし公式の説明では、-run=X/YはXにマッチする親のテストを、Yに合うサブテストがなくても走らせます。親がセットアップを持つなら、その分の時間は掛かります。

-vは成功したテストのt.Logまで出すため、hookの常時実行には向きません。詳細ログは、失敗後の再実行で-runと組み合わせる使い方にとどめます。

-jsonで機械可読な形式にすることもできますが、情報量は-vと同じです。長さを減らす手段ではない点に注意してください。

-raceと-countで再現を確かめる

レースやテスト間の依存は、1回通っただけでは見つかりません。再現確認の道具は次のとおりです。

  • -count=N: 各テストをN回繰り返す。既定は1回
  • -shuffle=on: テストの実行順をランダムにする。シードがレポートされるので、失敗した順序を-shuffle=<シード>で再現できる
  • -race: レース検出時にWARNING: DATA RACEが出る

Claudeへの指示としては、「レースが出たら、同じ失敗を-race -count=10 -run <テスト名>で再現してから直す」と書いておくと、修正前後で同じコマンドを比較できます。t.Parallelを付けたケースがレースの原因なのか、被テスト側の共有状態が原因なのかは、-runで1ケースに絞って切り分けます。

編集のたびに回す軽いhook

Stop hookは全パッケージを回すため重くなります。編集直後の確認だけを軽くしたいなら、PostToolUse hookで編集したファイルのパッケージに限って走らせます。PostToolUseで警告をClaudeに見せるには、exit 2を使います。stderrはClaudeに渡りますが、ツールの実行は取り消せません。

#!/bin/bash
FILE=$(jq -r '.tool_input.file_path')
case "$FILE" in
  *.go) ;;
  *) exit 0 ;;
esac
 
cd "$(dirname "$FILE")" || exit 0
OUT=$(go test -race -count=1 -failfast . 2>&1)
if [ $? -ne 0 ]; then
  echo "$OUT" | head -30 >&2
  exit 2
fi

matcherはEdit|Writeにします。ただしClaudeはシェルコマンドでもファイルを書き換えられるため、このhookだけでは取りこぼしが出ます。取りこぼしはStop hookが拾う構成にしておきます。Lintを同じ形で載せる方法はgolangci-lintをhookに載せる記事にまとめています。

つまずきやすい点

落とし穴

Stop hookがいつまでも終わらない

hookが毎回ブロックしていると、Claudeは止まれずに作業を続けます。Claude Codeは、Stop hookが連続8回ブロックし、その間にClaudeがツールを呼ばなかった場合に、次のブロックを上書きしてターンを終えます。ツール呼び出しがあるたびに連続回数は戻ります。8回で収まらない収束が必要ならCLAUDE_CODE_STOP_HOOK_BLOCK_CAPで上限を上げられます。仕組みはStop hookの無限ブロックを止める環境変数で扱っています。

もう1点、上のスクリプトはstop_hook_activeがtrueなら検証せずに通します。つまりhookが一度ブロックしたあとの2回目の停止は検証されません。毎回検証したい場合は、このチェックを外して上限に任せる設計もありますが、上限に達するまで同じ失敗で回り続けるおそれがあります。どちらを取るかは、テストの実行時間と許容できる反復回数で決めます。

カバレッジの閾値にも注意が要ります。カバレッジは文が実行されたかを見る指標で、期待値が正しいかは見ません。数字だけを完了条件にすると、検証の弱いケースを足して通す方向に動く余地があります。閾値は下限の目安として置き、失敗メッセージにgotとwantを含める規約と併用します。

テストが通らない原因がテストではなく被テスト関数にあるときは、t.Errorfのメッセージが判断材料になります。got/wantを揃えた規約は、そこで効きます。

まとめ

Goのテーブル駆動テストをClaude Codeに任せるときは、書き方の規約をCLAUDE.mdに、合格の判定をgo test -race -count=1とカバレッジのStop hookに分けて置きます。失敗は-failfastとgrepで短く返し、再現は-runでサブテストに絞ります。

最初に入れるなら、規約の1行と、-raceだけのStop hookです。カバレッジの閾値は、プロジェクトの現状の数字を見てから足すほうが、通らないまま回り続ける事態を避けられます。エージェントを使ってテストの合否を検証させる別の方法はagent hookで「テストが通るまで止めない」を検証させる記事にあります。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. code.claude.com/docs/en/hooks-guide
  2. code.claude.com/docs/en/hooks
  3. go.dev/wiki/TableDrivenTests
  4. go.dev/doc/articles/race_detector
  5. pkg.go.dev/cmd/go
  6. go.dev/blog/cover
  7. go.dev/doc/go1.22
この記事を共有:XはてブLinkedIn