Claude Code で Go のテスト駆動開発(TDD)を回すには、テーブル駆動テストで先に仕様を書かせ、hooks で編集のたびに gofmt・go vet・go test -race を自動で走らせるのが一番の近道です。テストが落ちると hooks が exit 2 で失敗内容を Claude に返し、Claude はテストが通るまで自分で直します1 。この記事では Go 1.26 のサンプルを作り、hooks の設定ファイルとスクリプトを載せ、Claude Code 2.1.289 で実際に TDD を回させた結果まで示します。
ここでいう TDD は「失敗するテストを書く(Red)→ 通す最小の実装(Green)→ 整える(Refactor)」の繰り返しです。Claude Code に任せる場合、人が仕様をテストの形で渡し、Claude が Red と Green を回し、hooks が毎回の検証を受け持ちます。
Claude Code が Go のファイルを編集するたびに、PostToolUse の hook が gofmt・go vet・go test -race を走らせ、失敗を Claude に返す流れ 図: 筆者作成
Anthropic の公式ベストプラクティスは、Claude に「自分の作業を確かめる手段」を渡すことを最初に挙げています2 。確かめる手段が無いと、Claude は「できたように見える」ところで止まり、人が確認役になってしまいます2 。テストと hooks は、この確認をループの中に組み込む仕組みです。
役割を分けると次のようになります。
担当 やること Go での道具 人 仕様を決める、テストケースの表をレビューする テーブル駆動テスト Claude Code テストを足す、実装する、失敗を読んで直す Edit・Write・Bash ツール hooks 編集のたびに整形・静的解析・テストを走らせる gofmt・go vet・go test -race
Go は標準の go test だけでテスト・ベンチマーク・カバレッジ・競合検出までそろいます。外部のテストランナーを入れなくてよいので、hooks のスクリプトが短くなります。
コンパイルが速く、型エラーが go vet やビルドの段階で出ることも効きます。Claude が書き損じても、数秒で機械的な失敗として返ってきます。
関心も伸びています。Google トレンド(日本・過去 90 日、2026-10-05 取得)の相対値で、「Go言語」は直近 4 週の平均が 2.0、その前の 8 週の平均が 0.3 でした。値は小さいものの上向きです3 。Go と AI エージェントの組み合わせ全般は Go 言語と AI エージェントの相性 で詳しく扱っています。
テーブル駆動テストは、入力と期待値の組を表(スライス)に並べ、1 つのループで全ケースを回す Go の定番の書き方です4 。Claude に TDD をさせるとき、この表が「仕様書」になります。人は表の行を読めば、何をテストしているかを確かめられます。
サンプルとして、カートの合計金額を計算する cart.Total を作ります。先にテストだけを書きます。
package cart
import (
"errors"
"testing"
)
func TestTotal (t *testing.T) {
t.Parallel()
tests := []struct {
name string
items []Item
coupon string
want int
wantErr error
}{
{name: "空のカートは0円" , items: nil , want: 0 },
{name: "単価×数量の合計" , items: []Item{{Price: 300 , Qty: 2 }, {Price: 150 , Qty: 1 }}, want: 750 },
{name: "10%オフクーポン" , items: []Item{{Price: 1000 , Qty: 1 }}, coupon: "OFF10" , want: 900 },
{name: "割引の端数は切り捨て" , items: []Item{{Price: 999 , Qty: 1 }}, coupon: "OFF10" , want: 900 },
{name: "数量0以下はエラー" , items: []Item{{Price: 100 , Qty: 0 }}, wantErr: ErrInvalidQty},
{name: "未知のクーポンはエラー" , items: []Item{{Price: 100 , Qty: 1 }}, coupon: "FREE" , wantErr: ErrUnknownCoupon},
}
for _, tt := range tests {
t.Run(tt.name, func (t *testing.T) {
t.Parallel()
got, err := Total(tt.items, tt.coupon)
if !errors.Is(err, tt.wantErr) {
t.Fatalf("Total() error = %v, wantErr %v" , err, tt.wantErr)
}
if got != tt.want {
t.Errorf("Total() = %d, want %d" , got, tt.want)
}
})
}
}
書き方のポイントは 4 つです。
name を日本語で書く : 失敗したとき TestTotal/ 割引の端数は切り捨て のように仕様がそのまま出る。Claude も人も原因をつかみやすい
エラーは errors.Is で比べる : 文字列比較にしないので、エラーメッセージを直してもテストが壊れない
t.Parallel() を付ける : ケースを並行に回し、共有状態の問題を -race で見つけやすくする。Go 1.22 以降はループ変数がイテレーションごとに作られるので、tt := tt は要らない
境界値を表に入れる : 空・0・端数・未知の値。AI は正常系だけで満足しがちなので、人が境界の行を足す
実装が無い状態で go test を実行すると、まずビルドが失敗します。これが最初の Red です。
$ go test ./...
# example.com/tdddemo/cart [example.com/tdddemo/cart.test]
cart/cart_test.go:13:13: undefined: Item
...
cart/cart_test.go:29:16: undefined: Total
FAIL example.com/tdddemo/cart [build failed]
型と関数を用意し、割引を sum * 90 / 100 で計算する素直な実装を書くと、端数のケースだけが落ちます。
$ go test ./...
--- FAIL: TestTotal (0.00s)
--- FAIL: TestTotal/割引の端数は切り捨て (0.00s)
cart_test.go:34: Total() = 899, want 900
FAIL
仕様は「割引額の端数を切り捨てる」なので、999 円の 10% 引きは割引額 99 円、支払いは 900 円です。sum * 90 / 100 は支払額のほうを切り捨てるため 899 円になります。割引額を先に計算するよう直すと Green になります。
case "OFF10" :
discount := sum * 10 / 100
return sum - discount, nil
$ go test -race -cover ./...
ok example.com/tdddemo/cart 2.603s coverage: 100.0% of statements
-race は Go の競合検出器(race detector)を有効にするフラグです。複数の goroutine が同じ変数を同期なしに読み書きすると、その場所を報告します5 。
ロックの無いカウンタを 50 個の goroutine から増やすテストを -race 付きで回すと、次のように止まりました(出力の一部)。
$ go test -race ./stats
WARNING: DATA RACE
Read at 0x00c000116540 by goroutine 9:
...
example.com/tdddemo/stats.(*Counter).Inc()
/tmp/write-article/claude-code-go-tdd/sample/stats/stats.go:11 +0x55
...
--- FAIL: TestCounterConcurrent (0.02s)
stats_test.go:16: Get() = 30, want 50
testing.go:1712: race detected during execution of test
FAIL
50 回増やしたはずが 30 になり、競合も検出されています。sync.Mutex で守ると通ります。
-race は重いフラグです。公式ドキュメントは、競合検出を有効にするとメモリ使用量が 5〜10 倍、実行時間が 2〜20 倍になりうると書いています5 。
競合検出(-race)を有効にしたときのコストの目安(倍率) データを表で見る 競合検出(-race)を有効にしたときのコストの目安(倍率) 項目 下限(倍) 上限(倍) メモリ使用量 5 10 実行時間 2 20
出典: Go 公式ドキュメント「Data Race Detector」の記載(2026-10-05 参照)
そのため、hooks では「編集したファイルのパッケージだけ」に -race をかけ、モジュール全体はターンの終わりに 1 回だけ回す、という分け方にします。
カバレッジは -cover で割合を、-coverprofile で関数ごとの内訳を見られます6 。
go test -coverprofile=cover.out ./...
go tool cover -func=cover.out
example.com/tdddemo/cart/cart.go:18: Total 100.0%
total: (statements) 100.0%
Claude に「カバレッジが 100% でない関数を挙げて、足りないケースを表に追加して」と頼むと、テストの穴埋めにも使えます。
hooks は、Claude Code の決まったタイミングで自分のコマンドを走らせる仕組みです1 。Go の TDD では 2 つのイベントを使います。
イベント いつ動くか ここでやること PostToolUse(matcher Edit|Write)Claude がファイルを編集・作成した直後 そのファイルを gofmt、そのパッケージに go vet と go test -race StopClaude が応答を終えようとしたとき モジュール全体の go test -race ./ ...。落ちていたら終わらせない
PostToolUse で exit 2 を返すと、ツールはすでに実行済みなので取り消されませんが、標準エラー出力が Claude に渡ります1 。exit 0 の標準エラー出力はデバッグログにしか出ず、Claude には見えません1 。失敗を Claude に読ませたいなら exit 2 を使います。
プロジェクトの .claude/ settings.json はリポジトリにコミットしてチームで共有できます1 。
{
"hooks" : {
"PostToolUse" : [
{
"matcher" : "Edit|Write" ,
"hooks" : [
{
"type" : "command" ,
"command" : "${CLAUDE_PROJECT_DIR}/.claude/hooks/go-check.sh" ,
"args" : [ ] ,
"timeout" : 120 ,
"statusMessage" : "gofmt / go vet / go test"
}
]
}
] ,
"Stop" : [
{
"hooks" : [
{
"type" : "command" ,
"command" : "${CLAUDE_PROJECT_DIR}/.claude/hooks/go-test-all.sh" ,
"args" : [ ] ,
"timeout" : 300
}
]
}
]
}
}
設定のポイントです。
matcher の Edit|Write は、Edit ツールと Write ツールのどちらにも一致します1
${CLAUDE_PROJECT_DIR} はセッションを始めたプロジェクトのルートです1 。作業ディレクトリが変わってもスクリプトが見つかります
args を書くとシェルを通さずに実行する exec 形式になります1 。パスにプレースホルダを使うときはこちらが勧められています1
timeout の単位は秒です。既定は command フックで 600 秒です1
Stop には matcher がありません。毎回動きます1
hooks には JSON が標準入力で届き、Edit・Write の場合は tool_input.file_path に編集したファイルの絶対パスが入っています1 。.claude/ hooks/ go-check.sh として保存し、chmod +x で実行権限を付けます。
#!/usr/bin/env bash
set -uo pipefail
file=$(jq -r '.tool_input.file_path // empty' )
[[ "$file " == *.go ]] || exit 0
[[ -f "$file " ]] || exit 0
cd "$(dirname "$file " ) " || exit 0
if ! out=$(gofmt -l -w "$file " 2>&1); then
echo "gofmt failed: $out " >&2
exit 2
fi
if ! out=$(go vet . 2>&1); then
echo "go vet failed:" >&2
echo "$out " >&2
exit 2
fi
if ! out=$(go test -race -count=1 . 2>&1); then
echo "go test failed:" >&2
echo "$out " | tail -n 30 >&2
exit 2
fi
exit 0
jq が必要です。-count=1 はテスト結果のキャッシュを使わないための指定です。出力を tail -n 30 で切っているのは、長いログで Claude の文脈を埋めないためです。
Stop の hook は {"decision": "block", "reason": "..."} を返すと Claude を止めずに続けさせ、reason が Claude に渡ります1 。入力の stop_hook_active は、すでに Stop hook のせいで続行している最中なら true になります1 。これを見て無限ループを防ぎます。
#!/usr/bin/env bash
set -uo pipefail
input=$(cat )
if [[ "$(jq -r '.stop_hook_active' <<<"$input " ) " == "true" ]]; then
exit 0
fi
cd "${CLAUDE_PROJECT_DIR:-.} " || exit 0
[[ -f go.mod ]] || exit 0
if ! out=$(go test -race -count=1 ./... 2>&1); then
jq -n --arg r "go test ./... が失敗しています。直してから終えてください:
$(tail -n 30 <<<"$out " ) " '{decision: "block", reason: $r}'
fi
exit 0
Claude Code には、Stop hook による続行が 8 回続くと次のブロックを無視してターンを終える上限もあります1 。
Claude Code を起動する前に、hooks に届く JSON を手で流し込んで動きを確かめます。わざと割引の計算を壊した状態で試した結果を録画しました。
hook スクリプトに PostToolUse の JSON を流し込み、テストの失敗で exit 2 が返ること、Stop 用スクリプトが decision: block を返すことを確かめる操作 実際の実行結果を録画。macOS・Go 1.26.2
gofmt -w が整形も済ませるので、壊した行の sum*90/ 100, nil は保存後に sum * 90 / 100, nil に直っていました。設定を入れたら、Claude Code の中で / hooks を打つと登録された hooks を一覧で確かめられます7 。
設定を入れたサンプルで、Claude Code 2.1.289 に次の指示を 1 回だけ出しました。-p は対話せずに結果を出して終わるモード、--allowedTools は許可なしに使えるツールの指定です8 。
claude -p "cart パッケージに OFF20 クーポン(20% 引き、割引額の端数は切り捨て)を追加してください。テスト駆動で進めます: まず cart/cart_test.go のテーブルにケースを足し、テストが失敗するのを確かめてから cart/cart.go を実装してください。" \
--permission-mode acceptEdits \
--allowedTools "Bash(go test *)" "Bash(go vet *)" \
--output-format stream-json --verbose --include-hook-events
--include-hook-events を付けると、hooks の開始と結果も出力に流れます8 。記録から読み取れた流れは次のとおりです(モデルは Claude Opus 5.5、9 ターン・約 55 秒)。
Claude がテーブルに 20%オフクーポン(1000 円 → 800 円)と 20%オフの割引の端数は切り捨て(999 円 → 800 円)の 2 行を追加した
直後の PostToolUse hook が exit 2 を返し、Total() error = cart: unknown coupon, wantErr <nil> が Claude に渡った(Red)
Claude は自分でも go test ./ cart/ を実行し、失敗が想定どおりであることを確かめた
cart.go の switch に case "OFF20" を追加した。hook は exit 0 になった(Green)
最後に go vet ./ ... と go test ./ ... を実行し、Stop hook も通って終了した
Claude Code がテストを追加して hook の exit 2 で失敗を受け取り、実装後に exit 0 と全体テストの成功で終えるまでの記録 実際の stream-json の記録を要約して表示(一部省略)。画面の見た目はイメージ
途中で 1 つ気づいたことがあります。Claude は cart.go をコメントの修正と case の追加の 2 回に分けて編集しました。1 回目の直後の hook はまだテストが通らないので exit 2 を返しましたが、Claude はそれを「途中の編集に対する失敗」と判断して作業を続けました。PostToolUse は編集のたびに動くので、Red の間は失敗が続くのが正常です。
最終的な差分は、テーブルの 2 行と case の 3 行だけでした。
+ case "OFF20":
+ discount := sum * 20 / 100 // 割引額の端数を切り捨てる
+ return sum - discount, nil
対話モードでも考え方は同じです。指示には「テストを先に」「失敗を確かめてから実装」「何が通れば完了か」を入れます。
> cart.Total に送料の計算を追加したい。仕様: 合計 3,000 円未満は送料 500 円、以上は無料。
まず TestTotal のテーブルに境界値(2,999 円・3,000 円)を含むケースを足して、
落ちることを確かめてから実装して。go test -race ./... が通ったら完了。
ベストプラクティスには、1 つのセッションにテストを書かせ、別のセッションにテストを通すコードを書かせる方法も紹介されています2 。テストを書いた本人が実装すると、テストを実装に合わせて書き換えてしまうことがあるためです。重要なロジックでは、テストの表を人がレビューしてから実装に進むと確実です。
症状 原因 対処 hook が動いていないように見える 成功時(exit 0)は会話に何も出ない7 / hooks で登録を確認する。手で JSON を流して試す失敗しているのに Claude が気づかない exit 1 で終わっている。exit 1 は止めない扱いで、標準エラー出力も Claude に見えない1 失敗は exit 2 で返す 編集のたびに待たされる 毎回 ./ ... 全体を -race で回している PostToolUse はパッケージ単位、全体は Stop で 1 回Stop hook でループする 失敗が直せない状態でブロックし続ける stop_hook_active が true なら exit 0 で抜けるBash で書き換えたファイルに hook が動かない Edit|Write の matcher は Bash による書き換えに反応しない1 必要なら FileChanged イベントを使う
hooks のスクリプトはあなたの権限でそのまま実行されます。他人のリポジトリの .claude/ settings.json を使う前に、中身を読んでください。危険なコマンドを止める使い方は Claude Code の hooks 入門 で解説しています。
Claude Code で Go の TDD を回す鍵は、テーブル駆動テストで仕様を渡し、hooks で検証を自動化すること
PostToolUse(Edit|Write)で gofmt・go vet・go test -race をパッケージ単位に走らせ、失敗は exit 2 で返す
Stop でモジュール全体のテストを回し、stop_hook_active でループを防ぐ
-race はメモリ 5〜10 倍・時間 2〜20 倍のコストがあるので、かける範囲を絞る
実際の実行では、Claude はテスト追加 → 失敗確認 → 実装 → 全体テストの順で 9 ターンで終えた
次にやることは、手元の Go リポジトリに上の 2 つのスクリプトと settings.json を置き、小さな機能を 1 つ TDD で頼んでみることです。Go 1.27 への移行と組み合わせる方法は Go 1.27 の新機能と移行手順 で紹介しています。
チームで共有するならプロジェクトの .claude/ settings.json、自分だけなら .claude/ settings.local.json、全プロジェクト共通なら ~/ .claude/ settings.json です1 。
取り消されません。PostToolUse はツールの実行後に動くので、ファイルの変更は残ります。exit 2 の効果は、標準エラー出力を Claude に見せることです1 。編集そのものを止めたいなら PreToolUse を使います。
遅くなります。公式ドキュメントでは実行時間が 2〜20 倍になりうるとされています5 。編集ごとはそのパッケージだけにかけ、モジュール全体は Stop hook や CI で回すと負担を抑えられます。
Go 1.22 以降は不要です。ループ変数がイテレーションごとに新しく作られるので、tt := tt を書かなくても各サブテストは自分のケースを参照します。go.mod の go が 1.22 以上であることを確かめてください。
Claude Code の中で / hooks を打つと、登録された hooks を確認できます7 。スクリプト自体は、hooks に届くのと同じ形の JSON を echo で流し込めば単体で試せます。