Claude Media
Claude CodeでGDScriptをGUTでテストする — PostToolUseで失敗を返す

Claude CodeでGDScriptをGUTでテストする — PostToolUseで失敗を返す

GUTのCLIをPostToolUse hookから回し、失敗をClaude Codeに返す設定と、型ヒント付きGDScriptを書かせる指示をまとめます。

GDScriptの単体テストは、GUT(Godot Unit Test)をClaude CodeのPostToolUse hookから呼ぶ形にすると、.gdを書き換えるたびに結果が返ってきます。失敗したら、hookがスクリプトの終了コード2で出力をClaudeに戻します。Claudeは自分が壊したテストを、その場で直せます。

この記事では、GUTの入れ方、CLIの形、hookのスクリプト、型ヒント付きGDScriptを書かせる指示の順に進めます。Godotプロジェクトの全体設定はClaude CodeでGodotゲーム開発を始める手順にあります。ここではテストの回し方に絞ります。

GUTは何で、どのバージョンを選ぶか

GUTはGDScriptのテストをGDScriptで書くためのテストフレームワークです。Godot 4.x向けがGUT 9.x、Godot 3.x向けが7.xという対応になっています。READMEのバージョン表では、Godotのマイナーバージョンごとに対応するGUTが分かれています。

くらべる

GUTとGodotの対応(READMEの表より)

Godot 4.7.x

GUT 9.7.1

godot_4_7ブランチで配布されています。

Godot 4.6.x

GUT 9.6.1

Asset Libraryから入れられる版です。

Godot 4.5.x / 4.3〜4.4.x

GUT 9.5.0 / 9.4.0

古いGodotを使うプロジェクトは、対応する版のタグから取得します。

READMEによると、現在のGodotが必要バージョンに満たないとき、GUTはAsset Libraryに表示されません。Claude Codeに導入を頼むときは、使っているGodotのバージョンを先に伝えます。

導入は、Asset Libraryから入れるか、zipを展開してaddons/gutをプロジェクトに置きます。そのあとProject Settingsのプラグインタブで、Gutを有効にします。Gitのサブモジュールとして入れる方法もありますが、GUTのリポジトリはaddons/gutだけを取り出す構成になっていません。サブモジュールにするなら、リポジトリ全体をクローンしてからaddons/gutへシンボリックリンクを張る手順になります。テストやドキュメントも一緒に落ちてくるため、zip展開のほうが手間が少なく済みます。

テストの書き方と、型ヒント付きの例

GUTのテストスクリプトは、GutTestを継承します。ファイル名は既定でtest_で始め、関数名もtestで始めます。セットアップはbefore_each、後始末はafter_eachに書きます。

体力を管理する小さなクラスを例にします。まず、型ヒントを付けた本体です。

class_name Health
extends RefCounted
 
signal depleted
 
var max_value: int = 100
var current: int = 100
 
func take_damage(amount: int) -> void:
    current = maxi(current - amount, 0)
    if current == 0:
        depleted.emit()
 
func heal(amount: int) -> void:
    current = mini(current + amount, max_value)

対応するテストは次のようになります。test/unit/test_health.gdに置きます。

extends GutTest
 
var _health: Health
 
func before_each() -> void:
    _health = Health.new()
 
func test_damage_reduces_current() -> void:
    _health.take_damage(30)
    assert_eq(_health.current, 70)
 
func test_damage_never_goes_below_zero() -> void:
    _health.take_damage(500)
    assert_eq(_health.current, 0)
 
func test_heal_is_capped_at_max() -> void:
    _health.take_damage(10)
    _health.heal(50)
    assert_eq(_health.current, 100)
 
func test_depleted_signal_emitted_at_zero() -> void:
    watch_signals(_health)
    _health.take_damage(100)
    assert_signal_emitted(_health, "depleted")

watch_signalsとassert_signal_emittedは、GUTのシグナル用のアサートです。Claude Codeにテストを書かせるときは、境界値(0、上限、上限超え)を名前に入れるよう指示すると、失敗メッセージだけで原因が読めます。

テストの種類が増えたら、GUTのクイックスタートが勧めるとおり、単体テストと統合テストでディレクトリを分けます。res://test/unitとres://test/integrationです。hookで回すのは速い単体テストだけにして、遅い統合テストはコミット前やCIに回す、という分担がしやすくなります。

時間が絡むテストではawaitを使います。物理フレームを待つwait_physics_framesは、公式の説明で「少なくとも2フレーム待つのが無難で、1フレームだと不安定になりやすい」とされています。Claudeがwait_physics_frames(1)と書いたら、直す目印にできます。

生成したノードはautofreeかadd_child_autofreeで解放させます。解放し忘れると、実行後にorphan(孤立ノード)の数が報告されます。

コマンドラインでGUTを回す

hookに載せる前に、手元でCLIの形を固めます。GUTのコマンドラインページにある基本形は次のとおりです。

godot -d -s --path "$PWD" addons/gut/gut_cmdln.gd

-dはデバッグ実行、-sはスクリプトの実行、--pathはプロジェクトのルート指定です。全テストが通れば終了コード0、1つでも失敗すれば1が返ります。保留(pending)のテストは終了コードに影響しません。

hookではこの形にいくつか足します。

  • --headlessを付けて、ウィンドウを開かない
  • -gdir=res://test/unitでテストの置き場所を指定する
  • -gexitを付けて、実行後にGodotを終了させる

-gexitは忘れやすい項目です。オプション一覧に「指定しなければ手動でウィンドウを閉じる必要がある」と書かれています。hookの中でGodotが終わらなければ、PostToolUseの完了を待ち続けます。

特定のテストだけを回すオプションも用意されています。

オプション役割
-gtest=<パス>役割テストスクリプトを指定して実行する
-gdir=<ディレクトリ>役割ディレクトリ内のtest_で始まるスクリプトを探す
-gselect=<文字列>役割ファイル名に文字列を含むスクリプトだけ実行する
-gunit_test_name=<文字列>役割名前に文字列を含むテストだけ実行する
-gexit役割実行後に終了する
-glog=<0〜3>役割ログのレベル。既定は1
-gjunit_xml_file=<パス>役割結果をJUnit XMLで書き出す

値の渡し方には癖があります。-gselect=only_meのように=でつなぐか、スペースで区切るかのどちらかです。コマンドラインページには、-gselectのあとに誤ってスペースを入れてERROR: No loader found for resourceが出た例が載っています。Claudeが組み立てたコマンドでこのエラーが出たら、引数の区切りを疑います。

オプションが増えたら、res://.gutconfig.jsonにまとめられます。GUTは既定でこのファイルを読み、コマンドラインで指定した値のほうを優先します。

{
  "dirs": ["res://test/unit/"],
  "include_subdirs": true,
  "prefix": "test_",
  "should_exit": true,
  "log_level": 1
}

設定ファイルの例にもshould_exitがあります。-gprint_gutconfig_sampleを付けて実行すると、雛形のJSONが出力されます。

PostToolUse hookで失敗をClaudeに返す

仕組みは単純です。PostToolUseはツールが成功した直後に動き、入力JSONにtool_input(送った引数)とtool_response(戻り値)が入ります。EditやWriteを対象にすれば、tool_input.file_pathから編集されたファイルが分かります。

hookが終了コード2で終わると、PostToolUseではstderrがClaudeに見えます。ツールはすでに実行済みなので、ファイルの書き換えは取り消されません。Claudeは次の判断で、失敗内容を読んで直せます。終了コード0で終わった場合、Claudeはstderrを見ません。警告を渡したいときも、2で終える必要があります。

設定ファイル

.claude/settings.jsonには、次のように書きます。

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/gut-after-edit.sh",
            "timeout": 120,
            "statusMessage": "GUTを実行中"
          }
        ]
      }
    ]
  }
}

timeoutの既定は、コマンドhookで600秒です。テストが固まったときに10分待たされないよう、短く絞ります。statusMessageは、実行中に表示されるスピナーの文言です。

.gdだけに絞る手段として、ifフィールドにEdit(*.gd)と書く方法もあります。ただしifに書けるルールは1つだけです。拡張子の判定はスクリプト側に置いたほうが、後から条件を足しやすくなります。

スクリプト本体

.claude/hooks/gut-after-edit.shは次のとおりです。編集されたファイルに対応するテストがあればそれだけを、なければ単体テスト全体を回します。

#!/usr/bin/env bash
input=$(cat)
file=$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty')
case "$file" in *.gd) ;; *) exit 0 ;; esac
 
godot_bin="${GODOT_BIN:-godot}"
cd "$CLAUDE_PROJECT_DIR" || exit 0
 
name=$(basename "$file" .gd)
case "$name" in
  test_*) target="res://test/unit/$name.gd" ;;
  *)      target="res://test/unit/test_$name.gd" ;;
esac
 
if [ -f "${target#res://}" ]; then
  args=("-gtest=$target")
else
  args=("-gdir=res://test/unit" "-ginclude_subdirs")
fi
 
out=$("$godot_bin" --headless -d -s --path "$PWD" \
  addons/gut/gut_cmdln.gd "${args[@]}" -glog=1 -gexit 2>&1)
status=$?
 
if [ "$status" -eq 127 ]; then
  echo "godot が見つかりません。GODOT_BIN を設定してください。" >&2
  exit 1
fi
if [ "$status" -ne 0 ]; then
  echo "GUT失敗(終了コード $status): $file の編集後" >&2
  printf '%s\n' "$out" | tail -n 40 >&2
  exit 2
fi
exit 0

実行権限はchmod +xで付けます。ポイントは4つあります。

  • 対応するテストを先に探す: scripts/health.gdを編集したらtest/unit/test_health.gdだけを回します。待ち時間が短くなります。
  • 出力は末尾40行に絞る: GUTのログは長くなります。全部をstderrに流すと、Claudeのコンテキストを無駄に使います。
  • 終了コード127は別扱いにする: Godotの実行ファイルが見つからないだけなのに、テスト失敗として扱うと、Claudeが存在しない失敗を直そうとします。1で終えれば非ブロッキングのエラーになり、ツール結果はそのまま進みます。
  • GODOT_BINで実行ファイルを指す: GUTのドキュメントの例は、シェルのエイリアスでgodotを定義しています。hookはClaude Codeが起動するプロセスなので、環境変数でパスを渡すほうが安全です。

JSON出力で返す方法もあります。decision: "block"とreasonを返すと、理由がツール結果の隣に追加されます。終了コード2とJSONは併用しない選び方が公式の推奨で、シェルスクリプトで済ませるなら終了コード2のほうが短く書けます。

hookが動かない場面

PostToolUseのEdit|Writeは、Bashコマンドや外部プロセスが同じファイルを書き換えたときには動きません。Claudeがsedで.gdを直した場合は素通りです。Bashからの書き換えも拾いたいときは、マッチャーを*にして、スクリプトの中でgit status --porcelainを見る構成にします。

.tscnの編集では何も起きません。シーンの構造を変えると壊れるテストは、.gdの編集を待たずに残ります。シーン絡みのテストを含めるなら、.tscnも対象にするよう拡張子の判定を広げます。

型ヒント付きGDScriptを書かせる

GDScriptは型を省略しても動きます。Claudeに何も指示しないと、型なしのコードが混ざります。GUTのテストは文字列や数値の比較が中心なので、型の抜けが見つかりにくく、実行時に初めて落ちる原因になります。

書き方の基本は、名前の後ろにコロンと型です。

var damage: float = 10.5
const MOVE_SPEED: float = 50.0
 
func sum(a: float = 0.0, b: float = 0.0) -> float:
    return a + b

型を書かずにコロンだけ残すと、Godotが右辺から推論します。var damage := 10.5の形です。定数では=と:=に違いはありません。

指示はCLAUDE.mdか、パス限定ルールに書きます。次の断片は.claude/rules/gdscript.mdの例です。

---
paths:
  - "scripts/**/*.gd"
  - "test/**/*.gd"
---
 
# GDScriptの書き方
 
- すべての変数・引数・戻り値に型を書く。推論できる右辺は := を使う
- 戻り値のない関数は -> void と書く
- scripts/foo.gd を作ったら test/unit/test_foo.gd を同じ作業で作る
- テストは GutTest を継承し、境界値を関数名に含める
- テストを直すときは期待値を変えず、まず実装を疑う

最後の1行は、テストを通すために期待値を書き換える動きへの歯止めです。hookが失敗を返すたびに、Claudeがテスト側を緩めて通す展開を防げます。

型の抜けは、Godot自身の警告でも拾えます。公式の静的型付けのページによると、型付きGDScript向けの警告は既定で無効です。Project SettingsのDebug > GDScriptで、Advanced Settingsを有効にすると設定できます。UNTYPED_DECLARATIONは、型のない宣言に警告を出します。警告はプロジェクト設定で個別にエラーへ切り替えられ、エラーにした警告が残るとコンパイルが通りません。

型なしの宣言をエラーにしておけば、Claudeが型を省いたコードを書いたとき、実行に失敗する形で気づけます。GUTの実行で、その失敗がテスト結果として出力に乗るかは、プロジェクトによって見え方が変わりえます。有効にしたあと、あえて型なしのコードを書いて、hookのstderrにどう出るかを一度確かめておくと確実です。

つまずきやすい点

症状原因の候補対処
hookが終わらない原因の候補-gexitがなくGodotが残っている対処-gexitかshould_exitを設定する
No loader found for resource原因の候補-gselectなどの後ろにスペースを入れた対処=でつなぐか、値の前後を確認する
何も実行されない原因の候補ファイル名がtest_で始まっていない対処-gprefixか名前を見直す
Claudeがstderrを読まない原因の候補hookが終了コード0で終わっている対処失敗時はexit 2にする
Bashで直したときだけ走らない原因の候補`Edit対処Write`はBash経由の変更を拾わない
非同期テストが不安定原因の候補wait_physics_frames(1)など対処2フレーム以上待つか、シグナルを待つ

保存のたびに全テストを回すと、プロジェクトが大きくなったときに遅くなります。単体テストをtest/unitに集め、hookでは単体テストだけを回します。重い統合テストは、PRの前やCIで回します。

Stop hookとの使い分け

PostToolUseは、編集のたびに結果が返る分、1回ごとの待ち時間がかかります。Claudeが何度も小さく編集する作業では、その分が積み重なります。

「作業を終える直前に全テストを通す」関門にするなら、Stop hookのほうが合います。終了コード2で、Claudeが止まるのを防げます。ただし、Stop hookによる連続の継続は8回で打ち切られます。テストが直らないまま延々と回り続けることはありません。

両方を組み合わせる構成が現実的です。編集のたびに該当テストだけをPostToolUseで回し、終了前に全体をStopで回します。使い分けの詳細はClaude Code hooksでテストを自動実行する — PostToolUseとStopの使い分けにあります。失敗するテストから書かせる進め方はClaude Codeでテスト駆動開発(TDD)を回す手順が扱っています。

GUTのテストは、JUnit XML形式で書き出せるので、CIで結果を読む用途にも使えます。-gjunit_xml_fileにパスを渡すだけです。CIで失敗を読ませてClaude Codeに直させる流れは、言語を問わず同じ形です。

まとめ

hookに載せる価値があるのは、「どのテストを、どの条件で、どれだけの出力で返すか」を決める部分です。GUTのCLIは終了コードが0か1なので、スクリプトはそれを2に変換して返すだけで足ります。

型ヒントは指示で書かせ、期待値の書き換えは指示で禁じ、-gexitの付け忘れはスクリプトで防ぎます。この3つを押さえれば、GDScriptの修正が、テストの結果を見ながら進む作業になります。

参照した一次ソース

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

  1. code.claude.com/docs/en/hooks
  2. code.claude.com/docs/en/hooks-guide
  3. gut.readthedocs.io/en/latest/Command-Line.html
  4. gut.readthedocs.io/en/latest/Quick-Start.html
  5. gut.readthedocs.io/en/latest/Install.html
  6. docs.godotengine.org/en/stable/tutorials/scripting/gdscript/static_typing.html
  7. docs.godotengine.org/en/stable/tutorials/scripting/gdscript/warning_system.html
  8. docs.godotengine.org/en/stable/tutorials/editor/command_line_tutorial.html
この記事を共有:XはてブLinkedIn