mcpField
G
godot-gdscript-headless-testing

godot-gdscript-headless-testing

github.com/gamedev-skillsテスト・品質5.4k3.9k 回インストール1 日前に更新
リポジトリ

godot-gdscript-headless-testing について

Godot 4.7のヘッドレスコマンドラインでGDScriptテストを実行するためのスキルガイドです。終了コードやCI設定、インポート手順、ハングの原因を扱います。

主な機能

  1. 01ヘッドレスでのGDScriptテスト実行
  2. 02SceneTreeテストランナーのパターン
  3. 03失敗数とプロセス終了コード
  4. 04新規チェックアウト時のインポート設定
  5. 05CIタイムアウトとトラブルシューティング

活用シーン

  1. 01ターミナルでロジックテストを実行
  2. 02テスト失敗時にCIビルドを失敗させる
  3. 03ヘッドレス実行のハングや異常を調査

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/gamedev-skills/awesome-gamedev-agent-skills --skill godot-gdscript-headless-testing

インストール後、Claude Code で/godot-gdscript-headless-testingを入力して読み込まれたか確認します。

詳細

Godot GDScript のヘッドレステスト(4.x)

エディター GUI を使わずにコマンドラインから GDScript テストを実行し、CI が判定できる実際のプロセス終了コードを取得します。対象は Godot 4.7 のヘッドレス CLI です。

使用する場面

  • Godot プロジェクトにテスト用アドオンがインストールされておらず、ターミナルまたは CI パイプラインから GDScript のロジック(純粋関数、リソースの読み込み、オートロードの状態)を手早く検証したい場合に使用します。
  • .gd テストが失敗したときにビルドを失敗させる CI ジョブを設定する場合に使用します。
  • godot --headless の実行がハングする、ウィンドウが開く、またはアサーションが失敗しても終了コードが 0 になる原因を調べる場合に使用します。

使用しない場面: GDScript の構文や言語機能そのもの → godot-gdscript、エクスポート/ビルドのパイプラインやプラットフォームテンプレート → godot-export(独自の --headless のユースケースであり、テストの実行ではなくバイナリを生成します)。

ワークフロー

  1. バイナリがヘッドレスで起動することを確認します。 Godot 4.x には --headless が標準で組み込まれています(エクスポートテンプレートは不要です)。godot --headless --version を実行し、GUI ウィンドウではなくバージョン文字列が出力されることを確認します。
  2. 新しいチェックアウトでは、テストの実行前にインポートします。 通常 .godot/ はコミットされないため、クリーンなチェックアウトにはインポートキャッシュがありません。そのため、class_name 型の解決に失敗し(Identifier "X" not declared in the current scope)、インポート済みアセットの読み込みにも失敗します(No loader found for resource: res://...)。CI とローカルの両方で、最初に一度 godot --headless --path <project_dir> --import を実行します。
  3. ランナーは Node シーンではなく、SceneTree スクリプトとして作成します。 SceneTree スクリプトの _initialize() は最初のフレームより前に一度実行されるため、純粋なロジックのテストには十分で、起動用の .tscn も不要です。
  4. 成功数と失敗数は自分で記録し、明示的に quit(<code>) を呼び出します。テストを失敗させるために、単独の assert() を使わないでください。 push_error() だけでは Godot はプロセスの終了コードを非ゼロにしません。ランナー側で失敗数を数え、quit(1) を呼び出す必要があります。さらに問題なのは、_initialize() 内の assert() が失敗すると(公式/デバッグビルド)、SCRIPT ERROR: Assertion failed が出力され、quit() が実行される前に処理が停止するため、プロセスが終了せず、CI がタイムアウトするまでハングすることです。失敗を記録し、処理を続ける assert_eq() 形式のヘルパーを使用します。
  5. **godot --headless --path <project_dir> --script res://<runner>.gd で実行し、**シェルまたは CI のステップから標準出力だけでなく、プロセス終了コードを確認します。 --script には res:// からの相対パスと絶対ファイルシステムパス(プロジェクトフォルダー外にあるランナーなど)の両方を指定でき、どちらも動作します。
  6. ラッパーシェルから実行をスクリプト化する場合は、標準出力と標準エラー出力をファイルにリダイレクトします(PowerShell、一部の CI ランナー)。push_error() の出力は標準エラー出力に送られるため、標準出力だけをライブで取得すると、欠落したり順序が入れ替わったりすることがあります。
  7. CI にステップのタイムアウトを設定します。 assert() の落とし穴を回避しても、解決されない await(パターン #2)があるとランナーは永久にハングします。CI ステップの timeout-minutes は CI 側の最後の防護策であり、すべての await にタイムアウトノードを用意する代わりにはなりません。

パターン

1. 実際の終了コードを返す最小限の SceneTree テストランナー
# res://test_runner.gd — run with:
#   godot --headless --path . --script res://test_runner.gd
extends SceneTree

var passed := 0
var failed := 0

func _initialize() -> void:
    test_add()
    print("Results: %d passed, %d failed" % [passed, failed])
    quit(1 if failed > 0 else 0)   # non-zero exit fails the CI step

func assert_eq(actual, expected, label: String) -> void:
    if actual == expected:
        passed += 1
    else:
        failed += 1
        push_error("FAIL %s: expected %s, got %s" % [label, expected, actual])

func test_add() -> void:
    assert_eq(2 + 2, 4, "test_add")

Godot 4.7.2 で検証済みです。godot --headless --path . --script res://test_runner.gd を実行すると、Results: N passed, M failed が標準出力に出力され、push_error の行は標準エラー出力に送られます。また、failed == 0 の場合はプロセス終了コード 0、それ以外の場合は 1 が返されます。

2. フレーム、タイマー、またはシグナルが必要なものをテストする
extends SceneTree

var passed := 0
var failed := 0

func _initialize() -> void:
    await run_tests()
    print("Results: %d passed, %d failed" % [passed, failed])
    quit(1 if failed > 0 else 0)   # track and report failures here too

func assert_eq(actual, expected, label: String) -> void:
    if actual == expected:
        passed += 1
    else:
        failed += 1
        push_error("FAIL %s: expected %s, got %s" % [label, expected, actual])

func run_tests() -> void:
    # `root` is not inside the tree yet during _initialize(): a Timer started now
    # errors ("not inside the tree") and its `timeout` never fires. Wait one frame.
    await process_frame
    var timer_node := Timer.new()
    timer_node.one_shot = true   # default Timer restarts after timeout
    root.add_child(timer_node)
    timer_node.start(0.1)
    await timer_node.timeout
    # assertions here can rely on the node having been in the tree for a frame
    assert_eq(timer_node.is_stopped(), true, "timer_fires_once")
    timer_node.queue_free()

_initialize() は await できます。これにより、ノードが実際にツリーに入る必要がある場合、タイマーが発火する必要がある場合、またはシグナルを送出する必要がある場合にも、このパターンが機能します。いずれもエンジンが少なくとも 1 フレーム処理するまでは発生しません。パターン #1 と同じ passed/failed カウンターと assert_eq() ヘルパーを使用します。常に quit(0) を呼び出すこのパターンの実装では、ビルドを失敗させることができません。

3. 終了コードでゲートする CI ステップ(GitHub Actions)
- name: Import project (populates .godot/ on a fresh checkout)
  run: godot --headless --path . --import
- name: Run GDScript tests
  timeout-minutes: 5
  run: godot --headless --path . --script res://test_runner.gd

クリーンなチェックアウトでは、インポートステップが必要です。これを行わないと、class_name 型とインポート済みリソースの解決に失敗します。テストステップ自体に追加のフラグは必要ありません。run: の終了コードが 0 以外の場合、ランナーはすでにジョブを失敗させます。徹底すべきなのは CI の設定ではなく、ランナースクリプト内で quit() を呼び出すことです。timeout-minutes は await がハングした場合の最後の保護策であり(「落とし穴」を参照)、すべての await にタイムアウトノードを用意する代わりにはなりません。

落とし穴

  • スクリプトが「何もしない」、またはエディターウィンドウが開く → --headless が指定されていないか、スクリプトのパスが間違っています。--script は --path <project_dir> を基準に解決される res:// 相対パスと、絶対ファイルシステムパスを受け付けます。どちらも機能します。
  • 新しいチェックアウトでのみ、Identifier "X" not declared in the current scope が表示される、またはリソースの読み込みに失敗する → .godot/(インポートキャッシュ)は通常コミットされないため、class_name 型とインポート済みアセットはまだ解決されていません。テストステップの前に、godot --headless --path <project_dir> --import を一度実行してください。
  • assert() が失敗しても、テストが失敗せずにハングする → 公式ビルドまたはデバッグビルドでは、_initialize() 内の assert() が失敗すると SCRIPT ERROR: Assertion failed が出力され、quit() に到達する前にその関数が停止します。プロセスは終了せず、CI はタイムアウトまで待機します。テストランナーでは、単独の assert() ではなく assert_eq() カウンター(パターン #1)を使用してください。
  • アサーションが失敗しても終了コードが 0 のままになる → ランナーが quit(1) を呼び出していないか、(パターン #2)失敗の有無にかかわらず常に quit(0) を呼び出しています。失敗を自分で追跡し、その結果を反映するコードを明示的に指定して quit() を呼び出してください。終了コードが変わることを assert() や push_error() のみに頼らないでください。
  • ノード、タイマー、シグナルがまだ存在しないうちに _initialize() が実行される → フレームの処理を必要とするロジックでは、アサーションを行う前にシグナルまたはタイマーを await する必要があります。パターン #2 を参照してください。root 自体がまだシーンツリー内にないため、そこで Timer を追加して開始するとエラーが発生し、timeout は発火しません(ランナーがハングします)。まず await process_frame を実行してください。
  • ラッパーシェルから見ると、出力が空に見えたり順序が乱れたりする → 一部のシェル(特に PowerShell)は、ネイティブプロセスの stdout/stderr をリアルタイムに表示する際、順序を変えたり出力を取りこぼしたりすることがあります。リアルタイムのコンソール表示を信頼するのではなく、両方のストリームをファイルにリダイレクトし、プロセス終了後にファイルを読み込んでください。
  • ランナーが終了しない → SceneTree スクリプトは、何かが quit() を呼び出すまで実行を続けます。発火しないシグナルを await するテストはジョブを永久にハングさせます。必ず await にフォールバック用のタイムアウトノードを用意し、最後の保護策として CI ステップに timeout-minutes を設定してください。

関連スキル

  • godot-gdscript — このパターンのランナースクリプトで使用する言語構文とノードのライフサイクルです。
  • godot-export — ヘッドレス CLI によるエクスポート/ビルドです。--headless の別の使用例です。

コメント