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
.gdtest fails. - Use when debugging why a
godot --headlessinvocation 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
- Confirm the binary resolves headless. Godot 4.x ships
--headlessbuilt in (no export template needed); rungodot --headless --versionand confirm it prints a version string, not a GUI window. - On a fresh checkout, import before running tests.
.godot/is normally not committed, so a clean checkout has no import cache:class_nametypes fail to resolve (Identifier "X" not declared in the current scope) and imported assets fail to load (No loader found for resource: res://...). Rungodot --headless --path <project_dir> --importonce first, in CI and locally. - Write the runner as a
SceneTreescript, not aNodescene. ASceneTreescript's_initialize()runs once before any frame — enough for pure-logic tests and no.tscnrequired to launch. - Track pass/fail counts yourself and call
quit(<code>)explicitly. Do not use bareassert()to fail a test. Godot does not turn the process exit code non-zero onpush_error()by itself — the runner must count failures and callquit(1). Worse, a failedassert()inside_initialize()(official/debug build) printsSCRIPT ERROR: Assertion failedand stops execution beforequit()runs, so the process never exits and CI hangs until its own timeout. Use anassert_eq()-style helper that records the failure and keeps going. - Invoke with
godot --headless --path <project_dir> --script res://<runner>.gdand read the process exit code, not just stdout, from the shell or CI step.--scriptaccepts both ares://-relative path and an absolute filesystem path (e.g. a runner outside the project folder); either works. - 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. - Add a step timeout in CI. Even with the
assert()pitfall avoided, anawaitthat never resolves (Pattern #2) hangs the runner forever; atimeout-minuteson the CI step is a backstop CI-side, not a substitute for backing everyawaitwith a timeout node.
Patterns
1. Minimal SceneTree test runner with a real exit code
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
_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
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.--scriptaccepts ares://-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, soclass_nametypes and imported assets aren't resolved yet. Rungodot --headless --path <project_dir> --importonce before the test step.- A failed
assert()hangs instead of failing the test → in an official/debug build, a failedassert()inside_initialize()printsSCRIPT ERROR: Assertion failedand stops that function before it reachesquit()— the process never exits and CI waits until its own timeout. Use anassert_eq()counter (Pattern #1) instead of bareassert()in test runners. - Exit code stays 0 despite failed assertions → the runner never called
quit(1), or (Pattern #2) it always callsquit(0)regardless of failures. Track failures yourself and callquit()explicitly with a code that reflects them; do not rely onassert()orpush_error()alone to change the exit code. _initialize()runs before nodes, timers, or signals exist → logic that needs a frame to have processed mustawaita signal or a timer before asserting; see Pattern #2.rootitself is not inside the tree yet, so aTimeradded and started there errors and itstimeoutnever fires (the runner hangs) —await process_framefirst.- 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
SceneTreescript keeps running until something callsquit(). A test thatawaits a signal that never fires hangs the job forever — always back anawaitwith a timeout node as a fallback, and settimeout-minuteson 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--headlessuse case.


