godot-gdscript-headless-testing — Claude Code skill
Runs GDScript test suites in Godot 4 via godot --headless and returns real process exit codes. It helps verify game logic in CI pipelines without launching the editor GUI.
npx skills add gamedev-skills/awesome-gamedev-agent-skills --skill godot-gdscript-headless-testing -a claude-codeWhat godot-gdscript-headless-testing does
The skill covers headless GDScript testing for Godot 4.x and 4.7 without external testing addons. The test runner extends SceneTree, so its _initialize() method runs before any frame renders. On a fresh repository checkout, you must run godot --headless --path <project_dir> --import first. Without that step, Godot lacks the .godot/ import cache. This breaks class_name type resolution and fails resource loading.
A major trap in headless execution is Godot's built-in assert(). A failed assert() in debug builds halts execution before quit() ever runs. The process hangs indefinitely. CI runs until hitting a job timeout. The runner avoids this by tracking passes and failures in custom counters, then exiting via quit(1). For timers or signals, tests must await process_frame first because root is not attached to the tree during _initialize().
What it helps with
- run GDScript unit tests from the terminal without opening the Godot editor
- return a non-zero exit code to CI when a test fails
- prevent headless Godot processes from hanging on failed assertions
- initialize the
.godot/import cache on clean CI checkouts
Example prompts for the agent
- Create a SceneTree test runner for GDScript that exits with code 1 on failure
- Configure a GitHub Actions step to run Godot 4 headless tests and catch failures
- Debug why godot --headless hangs in CI when an assertion fails
How to install godot-gdscript-headless-testing
1Skills CLI (Vercel)
npx skills add gamedev-skills/awesome-gamedev-agent-skills --skill godot-gdscript-headless-testing -a claude-code2Manually: put the files in place
~/.claude/skills/godot-gdscript-headless-testing/SKILL.mdCopy the whole skill folder; for one project use .claude/skills/godot-gdscript-headless-testing/
1Skills CLI (Vercel)
npx skills add gamedev-skills/awesome-gamedev-agent-skills --skill godot-gdscript-headless-testing -a codex2Manually: put the files in place
.agents/skills/godot-gdscript-headless-testing/SKILL.mdFor all projects use ~/.agents/skills/
1Skills CLI (Vercel)
npx skills add gamedev-skills/awesome-gamedev-agent-skills --skill godot-gdscript-headless-testing -a cursor2Manually: put the files in place
.cursor/skills/godot-gdscript-headless-testing/SKILL.mdCursor also reads .agents/skills/ and .claude/skills/
1GitHub CLI
gh skill install gamedev-skills/awesome-gamedev-agent-skills godot-gdscript-headless-testing2Manually: put the files in place
.github/skills/godot-gdscript-headless-testing/SKILL.md1Skills CLI (Vercel)
npx skills add gamedev-skills/awesome-gamedev-agent-skills --skill godot-gdscript-headless-testing -a gemini-cli2Platform CLI
gemini skills install https://github.com/gamedev-skills/awesome-gamedev-agent-skills --path skills/godot/godot-gdscript-headless-testingManually: put the files in place
.devin/skills/godot-gdscript-headless-testing/SKILL.mdThe legacy .windsurf/skills/ path still works
Skills CLI (Vercel)
npx skills add gamedev-skills/awesome-gamedev-agent-skills --skill godot-gdscript-headless-testing -a opencodeClaude.ai
Zip the folder and upload it in Settings → Capabilities → Skills
Step by step, with commands for each platform: How to install skills in Claude Code: five ways
Questions about godot-gdscript-headless-testing
How to install godot-gdscript-headless-testing in Claude Code?
Run npx skills add gamedev-skills/awesome-gamedev-agent-skills --skill godot-gdscript-headless-testing -a claude-code.
What does godot-gdscript-headless-testing do?
It provides patterns for running GDScript unit tests from the terminal in Godot 4.x. It guides setting up a SceneTree runner script, propagating failure exit codes to CI, and handling frame delays.
Why does a bare assert() hang Godot in CI?
A failed assert() inside _initialize() prints an error and stops execution immediately. Because it halts before calling quit(), Godot never exits and CI hangs until reaching its step timeout.
Source text
SKILL.md · Apache-2.0
---
name: godot-gdscript-headless-testing
description: >
Run GDScript test suites from the command line with `godot --headless`, using a
SceneTree/MainLoop runner script that exits non-zero on failure so CI can gate
merges. Use when a Godot project needs unit tests without opening the editor,
when wiring a CI job (GitHub Actions or similar) that must fail the build on a
failing `.gd` test, or when a `godot --headless` invocation hangs, opens a
window, or exits 0 despite failed assertions.
---
# Godot GDScript headless testing (4.x)
Run GDScript tests from the command line, without the editor GUI, and get a real
process exit code CI can act on. Targets **Godot 4.7** headless CLI.
## When to use
- Use when a Godot project has no testing addon installed and needs a fast way to
verify GDScript logic (pure functions, resource loading, autoload state) from a
terminal or CI pipeline.
- Use when wiring a CI job that must fail the build when a `.gd` test fails.
- Use when debugging why a `godot --headless` invocation hangs, opens a window, or
exits 0 despite failing assertions.
**When *not* to use:** GDScript syntax or language features themselves →
`godot-gdscript`; export/build pipeline and platform templates → `godot-export`
(its own `--headless` use case, producing a binary, not running tests).
## Workflow
1. **Confirm the binary resolves headless.** Godot 4.x ships `--headless` built in
(no export template needed); run `godot --headless --version` and confirm it
prints a version string, not a GUI window.
2. **On a fresh checkout, import before running tests.** `.godot/` is normally not
committed, so a clean checkout has no import cache: `class_name` types fail to
resolve (`Identifier "X" not declared in the current scope`) and imported
assets fail to load (`No loader found for resource: res://...`). Run
`godot --headless --path <project_dir> --import` once first, in CI and locally.
3. **Write the runner as a `SceneTree` script, not a `Node` scene.** A `SceneTree`
script's `_initialize()` runs once before any frame — enough for pure-logic
tests and no `.tscn` required to launch.
4. **Track pass/fail counts yourself and call `quit(<code>)` explicitly. Do not use
bare `assert()` to fail a test.** Godot does not turn the process exit code
non-zero on `push_error()` by itself — the runner must count failures and call
`quit(1)`. Worse, a failed `assert()` inside `_initialize()` (official/debug
build) prints `SCRIPT ERROR: Assertion failed` and **stops execution before
`quit()` runs**, so the process never exits and CI hangs until its own timeout.
Use an `assert_eq()`-style helper that records the failure and keeps going.
5. **Invoke with `godot --headless --path <project_dir> --script res://<runner>.gd`**
and read the **process exit code**, not just stdout, from the shell or CI step.
`--script` accepts both a `res://`-relative path and an absolute filesystem
path (e.g. a runner outside the project folder); either works.
6. **Redirect stdout and stderr to files when scripting the invocation from a
wrapper shell** (PowerShell, some CI runners). `push_error()` output goes to
stderr and can be dropped or reordered when only stdout is captured live.
7. **Add a step timeout in CI.** Even with the `assert()` pitfall avoided, an
`await` that never resolves (Pattern #2) hangs the runner forever; a
`timeout-minutes` on the CI step is a backstop CI-side, not a substitute for
backing every `await` with a timeout node.
## Patterns
### 1. Minimal SceneTree test runner with a real exit code
```gdscript
# 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")
```
Verified against Godot 4.7.2: `godot --headless --path . --script
res://test_runner.gd` prints `Results: N passed, M failed` to stdout, routes
`push_error` lines to stderr, and returns process exit code `0` when
`failed == 0`, `1` otherwise.
### 2. Testing something that needs a frame, a timer, or a signal
```gdscript
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()` may `await`, which is what makes this pattern work for anything
that needs a node to actually enter the tree, a timer to fire, or a signal to
emit — none of which happen before the engine has processed at least one frame.
Use the same `passed`/`failed` counter and `assert_eq()` helper as Pattern #1;
a version of this pattern that always calls `quit(0)` can never fail a build.
### 3. CI step (GitHub Actions) that gates on the exit code
```yaml
- 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
```
The import step is required on a clean checkout — without it, `class_name` types
and imported resources fail to resolve. No extra flag is needed for the test
step itself: the runner already fails the job on a non-zero exit code from
`run:`; the discipline lives in the runner script's `quit()` call, not in the CI
configuration. `timeout-minutes` is a backstop against a hung `await` (see
Pitfalls), not a substitute for backing every `await` with a timeout node.
## Pitfalls
- **Script "does nothing" or opens the editor window** → missing `--headless`, or
the script path is wrong. `--script` accepts a `res://`-relative path resolved
against `--path <project_dir>`, and also an absolute filesystem path — both work.
- **`Identifier "X" not declared in the current scope`, or a resource fails to
load, only on a fresh checkout** → `.godot/` (the import cache) is normally not
committed, so `class_name` types and imported assets aren't resolved yet. Run
`godot --headless --path <project_dir> --import` once before the test step.
- **A failed `assert()` hangs instead of failing the test** → in an official/debug
build, a failed `assert()` inside `_initialize()` prints `SCRIPT ERROR:
Assertion failed` and stops that function before it reaches `quit()` — the
process never exits and CI waits until its own timeout. Use an `assert_eq()`
counter (Pattern #1) instead of bare `assert()` in test runners.
- **Exit code stays 0 despite failed assertions** → the runner never called
`quit(1)`, or (Pattern #2) it always calls `quit(0)` regardless of failures.
Track failures yourself and call `quit()` explicitly with a code that reflects
them; do not rely on `assert()` or `push_error()` alone to change the exit code.
- **A test that hits a runtime error is silently skipped and the run still exits 0** →
an error such as calling a method on `null` prints `SCRIPT ERROR` and aborts only that
test function; the runner carries on, nothing was counted as failed, and `quit(0)` runs.
Declare tests `-> bool`, end each with `return true`, and count anything else as a
failure — an aborted function hands its caller a default value instead (`await call(t)`
works the same for tests that `await`):
```gdscript
for t: String in ["test_add", "test_load"]:
if call(t) != true:
failed += 1
push_error("FAIL %s: did not finish (runtime error?)" % t)
```
- **`_initialize()` runs before nodes, timers, or signals exist** → logic that
needs a frame to have processed must `await` a signal or a timer before
asserting; see Pattern #2. `root` itself is not inside the tree yet, so a
`Timer` added and started there errors and its `timeout` never fires (the
runner hangs) — `await process_frame` first.
- **Output looks empty or out of order from a wrapper shell** → some shells
(PowerShell in particular) can reorder or drop a native process's live
stdout/stderr. Redirect both streams to files and read the files after the
process exits, instead of trusting the live console.
- **Runner never terminates** → a `SceneTree` script keeps running until
something calls `quit()`. A test that `await`s a signal that never fires hangs
the job forever — always back an `await` with a timeout node as a fallback, and
set `timeout-minutes` on the CI step as a backstop.
## Related skills
- `godot-gdscript` — the language syntax and node lifecycle this pattern's
runner script itself uses.
- `godot-export` — headless CLI export/build, a different `--headless` use case.
© gamedev-skills · Apache-2.0 · gamedev-skills/awesome-gamedev-agent-skills