Skip to content
Game dev · Workflow

Godot 4 headless GDScript tests

A SceneTree test runner the agent runs with godot --headless after every change, failing on script errors with a non-zero exit, plus a linter for tests that can't fail.

Verified install Not agent-tested v1.0.0 MIT updated godotgdscript
Equips
  • 2 2 Rules
  • 1 1 Skills
  • – 0 MCP

godot --headless --check-only tells an agent its script parses. It doesn't tell it that the enemy never dies, that a signal fires twice, or that a node was null halfway through a test. This loadout gives the agent real tests that run in Godot's headless mode, with no editor and no window, in a second or two.

The runner is one GDScript file that extends SceneTree. It finds tests/test_*.gd, runs every test_*() method with optional before_each and after_each, prints PASS or FAIL per test and quits with exit code 1 on any failure. It hooks Godot's Logger class (4.5 and later), so a script error inside a test, such as calling a method on null, fails that test. Without the hook Godot only prints the error and the test counts as a pass. The rules cover scene tests with real frames, queue_free timing, autoload state that leaks between tests, seeded randomness and the --import step a fresh clone needs before class_name types resolve. A standard-library Python linter flags tests with no check, get_tree() in a suite, OS.delay_msec, an un-awaited frame wait and quit() inside a test.

What's in it

  • One-file SceneTree runner, exit code 1 on any failure, for CI and agents
  • Script errors inside a test fail it, via Godot's Logger hook
  • Scene tests with awaited frames, queue_free timing and autoload resets
  • Linter flags tests that can only pass, blocking delays and un-awaited waits
  • Catches the fresh-clone trap where class_name types are unknown until --import

You'll need

  • Tools: godot-4, python3
Level 1 · automated

Verification

Verified install

Checked at commit cc18d7e · 9 passed, 0 failed, 1 not applicable

CheckResultWhat we found
Listinglisting.yaml matches the schema and declares every file in the folder. Pass
Schema valid; 8 files declared, none missing or extra
  • tested.status: untested (agent tests are separate from Level 1)
Files parseJSON parses; SKILL.md and .mdc front matter is valid YAML with the fields each agent needs; code fences are closed. Pass All 8 files parse; front matter valid
AdaptersCLAUDE.md imports @AGENTS.md, GEMINI.md imports @./AGENTS.md, and both match the generator. Pass
CLAUDE.md and GEMINI.md import AGENTS.md and are up to date
  • CLAUDE.md: matches the generator
  • GEMINI.md: matches the generator
Install pathsEvery file lands where each agent documents it, with no collisions, and the laid-out project is re-discovered correctly. Pass
Correct paths for all 6 agents; layouts re-discovered
  • Claude Code: 6 files, 1 skill in .claude/skills/
  • Codex CLI: 5 files, 1 skill in .agents/skills/
  • Cursor: 6 files, 1 skill in .agents/skills/, 1 rule
  • Gemini CLI: 6 files, 1 skill in .agents/skills/
  • Antigravity CLI: 5 files, 1 skill in .agents/skills/
  • GitHub Copilot: 5 files, 1 skill in .agents/skills/
MCP configmcp.json is structurally valid, converts to each agent's format, declares no literal secrets, and referenced packages exist on npm/PyPI. Not applicable No MCP servers in this loadout
Safety scanThe intake scanner finds no credentials, risky shell, hidden Unicode, instructions hidden in HTML comments or after long blank padding, or prompt-injection and covert wording (such as telling the agent to keep something from you), and the listing's network flag is honest. Pass
Clean: no credentials, risky shell, hidden text or covert instructions
  • Scanner intake-precheck/2: verdict clean; network no (listing says no)
ReferencesLinks and file paths between the loadout's files resolve, including for each agent's installed layout. Pass 1 reference between files resolves for every agent
Size limitsFile count and sizes are within the submission limits; SKILL.md bodies stay under 500 lines. Pass
8 files, 15.9 KB total; largest skills/godot-headless-tests/scripts/gd_test_lint.py 4.3 KB
  • Limits: 30 files, 32 KB per file, 128 KB total, 500 lines per skill or rule
LicenceA recognised SPDX licence, with the licence text available. Pass MIT, by SetupBay; text in the repository LICENSE
Starter buildA minimal fixture project with the loadout applied installs and builds or type-checks, and the loadout's own code examples compile. Pass
A Godot 4.7.2 project with a Health component, an arena scene and a GameState autoload. gd_test_lint.py passes its suites and flags the skill's bad sample with 7 errors and 4 warnings. Run headless, the skill's runner first fails on a fresh clone with no import cache, then passes all 7 tests after -
  • Applied 12 loadout files at every agent's paths
  • godot 4.7.2-stable
  • ✓ place the skill's runner: bash -c cp .agents/skills/godot-headless-tests/references/run_tests.gd tests/run_tests.gd && echo placed
  • ✓ lint: the project's suites are clean: bash -c python3 .agents/skills/godot-headless-tests/scripts/gd_test_lint.py tests; echo "exit=$?"
  • ✓ lint: the bad sample is flagged line by line: bash -c mkdir -p bad && cp .claude/skills/godot-headless-tests/references/bad-test-sample.gd.txt bad/test_bad.gd && python3 .claude/skills/godot-headless-tests/scripts/gd_test_lint.py bad; echo "exit=$?"
  • ✓ fresh clone without --import: class_name isn't known yet, and the run fails: bash -c rm -rf .godot && "$GODOT_BIN" --headless --path . --script res://tests/run_tests.gd 2>&1; echo "exit=$?"
  • ✓ build the import cache: bash -c "$GODOT_BIN" --headless --import --path . >/dev/null 2>&1; echo "import=$?"; test -f .godot/global_script_class_cache.cfg && echo cache-ok
  • ✓ run: every test passes, exit 0: bash -c "$GODOT_BIN" --headless --path . --script res://tests/run_tests.gd 2>&1; echo "exit=$?"
  • ✓ a broken damage rule fails the run: bash -c cp scripts/health.gd health.bak && sed -i 's/current = maxi(current - amount, 0)/current -= amount/' scripts/health.gd && "$GODOT_BIN" --headless --path . --script res://tests/run_tests.gd 2>&1; echo "exit=$?"; mv health.bak scripts/health.gd
  • ✓ a script error inside a test is a failure, not a silent pass: bash -c printf 'extends RefCounted\nvar t: SceneTree\n\nfunc test_null_enemy() -> void:\n\tvar enemy: Node = null\n\tenemy.get_name()\n\tt.check(true)\n' > tests/test_null.gd && "$GODOT_BIN" --headless --path . --script res://tests/run_tests.gd 2>&1; echo "exit=$?"; sed 's/^\tOS.add_logger(_errors)$/\tpass/' tests/run_tests.gd > tests/naive_runner.gd && echo '--- without the Logger hook ---' && "$GODOT_BIN" --headless --path . --scr
  • Covers: The runner from the skill under Godot 4.7.2 headless: passing, failing on a broken rule, and failing on a script error
  • Covers: The fresh-clone trap: class_name types are unknown until --import builds .godot/
  • Covers: Scene tests with real frames, queue_free timing and an autoload reset between tests
  • Covers: The linter on clean suites and on every rule in the bad sample
  • Covers: Not covered: rendering, audio, input devices and physics feel, which need a human play-test

What this means: a script checked the files themselves: they parse, they install where each agent looks, any MCP config is sound, the safety scan is clean, references between files resolve, and, where a free and repeatable fixture exists, a small starter project builds with the loadout applied. No AI agent was run. Per-agent results from agent runs are separate and stay untested until an agent actually runs.

Install

Choose your agent. Run the commands from your project root, or download a zip already laid out in the right folders.

Claude Code reads CLAUDE.md (imports AGENTS.md with @AGENTS.md) and skills from .claude/skills/.

Download zip
curl -fsSL --create-dirs -o AGENTS.md https://setupbay.com/raw/godot-4-headless-tests/1.0.0/AGENTS.md
curl -fsSL --create-dirs -o CLAUDE.md https://setupbay.com/raw/godot-4-headless-tests/1.0.0/CLAUDE.md
curl -fsSL --create-dirs -o .claude/skills/godot-headless-tests/SKILL.md https://setupbay.com/raw/godot-4-headless-tests/1.0.0/skills/godot-headless-tests/SKILL.md
curl -fsSL --create-dirs -o .claude/skills/godot-headless-tests/references/run_tests.gd https://setupbay.com/raw/godot-4-headless-tests/1.0.0/skills/godot-headless-tests/references/run_tests.gd
curl -fsSL --create-dirs -o .claude/skills/godot-headless-tests/scripts/gd_test_lint.py https://setupbay.com/raw/godot-4-headless-tests/1.0.0/skills/godot-headless-tests/scripts/gd_test_lint.py
curl -fsSL --create-dirs -o .claude/skills/godot-headless-tests/references/bad-test-sample.gd.txt https://setupbay.com/raw/godot-4-headless-tests/1.0.0/skills/godot-headless-tests/references/bad-test-sample.gd.txt
FileGoes to
AGENTS.mdAGENTS.md
CLAUDE.mdCLAUDE.md
skills/godot-headless-tests/SKILL.md.claude/skills/godot-headless-tests/SKILL.md
skills/godot-headless-tests/references/run_tests.gd.claude/skills/godot-headless-tests/references/run_tests.gd
skills/godot-headless-tests/scripts/gd_test_lint.py.claude/skills/godot-headless-tests/scripts/gd_test_lint.py
skills/godot-headless-tests/references/bad-test-sample.gd.txt.claude/skills/godot-headless-tests/references/bad-test-sample.gd.txt

Already have an AGENTS.md or CLAUDE.md? The commands above overwrite it — download the file instead and merge the sections you want. Which file wins when several are present: what Claude Code reads.

Or as a Claude Code plugin

Adds the skill to Claude Code for all your projects, without copying files. Plugins can't carry project rules, so add AGENTS.md and CLAUDE.md with the commands above. About the plugin marketplace.

/plugin marketplace add https://setupbay.com/marketplace.json
/plugin install godot-4-headless-tests@setupbay

Files

Rules AGENTS.md Commands, test layout, rules for writing headless tests, and the done checklist. 56 lines
Download Raw
# Godot 4 headless tests

This project's game rules are checked by GDScript tests that run in Godot's headless mode, with no
editor and no window. Run them after every change and before you say a change is done. A script that
parses can still be wrong: signal timing, freed nodes and off-by-one damage all parse fine.

## Commands

```bash
godot --headless --import --path .            # once per fresh clone or new class_name; builds .godot/
godot --headless --path . --script res://tests/run_tests.gd   # runs every test, exit 1 on any failure
python3 .agents/skills/godot-headless-tests/scripts/gd_test_lint.py tests   # catches tests that can't fail
```

Use the Godot 4 binary the project pins (`config/features` in project.godot). If it isn't on PATH,
ask for its path rather than downloading one. Wrap the run in `timeout 300` in CI so a hung test fails.

## Layout

- `tests/run_tests.gd`: the runner (copy it from the skill's `references/run_tests.gd`). It extends
  SceneTree, finds `tests/test_*.gd`, runs every `test_*()` method, prints `PASS`/`FAIL` per test
  and quits with exit code 1 if anything failed.
- `tests/test_<thing>.gd`: one suite per script or scene. A suite `extends RefCounted` and declares
  `var t: SceneTree`; the runner sets `t` to itself before each test.
- Optional `before_each()` and `after_each()` run around every test; they may `await`.

## Writing tests

- Check with `t.equal(actual, expected, "label")` and `t.check(condition, "message")`. Every test
  needs at least one. `print()` is not a check.
- A script error inside a test (calling a method on null, a bad cast) marks that test as failed. The
  runner hooks Godot's Logger for this, because otherwise Godot only prints the error and the test
  looks like a pass. Don't "fix" a failing test by guarding the null; find out why it was null.
- Add nodes under `t.root`, never `get_tree()` (a RefCounted suite isn't in the tree). Free what you
  add in `after_each()`.
- Scene tests: `load("res://scenes/x.tscn").instantiate()`, add to `t.root`, then
  `await t.frames(1)` so `_ready()` and the first process frame have run. `queue_free()` only happens
  at the end of a frame, so await a frame before you check a node is gone.
- Always `await t.frames(n)`. Never `OS.delay_msec()`, which blocks the main loop so nothing moves,
  and avoid `create_timer()` waits: call the timeout handler, or set the Timer's `wait_time` tiny.
- Seed randomness: pass a `RandomNumberGenerator` with a fixed `seed` into the code under test.
- Never call `quit()` from a test. The runner owns the exit code.
- Autoloads from project.godot are loaded in a `--script` run (Godot 4.7) and live for the whole
  run, so state leaks between tests. Reset whatever a test changes in `before_each()`.
- Editor-only code (EditorInterface, EditorPlugin) can't be exercised here. Keep plugin UI thin.

If the project already uses GUT or gdUnit4, use that instead and keep these rules for the tests.

## Done means

1. `--import` has run since the last new `class_name`, or the run fails with "Could not find type".
2. The lint script reports 0 errors on `tests/`.
3. The run ends with `0 failed` and exits 0. Add or change a test for every rule you change, and
   say which test covers it.
4. List anything that still needs a human play-test (feel, visuals, audio, input devices).
Adapter CLAUDE.md Imports AGENTS.md for Claude Code. 11 lines
Download Raw
# CLAUDE.md

@AGENTS.md

## Claude Code notes

- Skills from this loadout live in `.claude/skills/`: `godot-headless-tests`. Use them when their description matches the task.
- Follow AGENTS.md above; where this file and AGENTS.md disagree, AGENTS.md wins.

<!-- From SetupBay "Godot 4 headless GDScript tests" v1.0.0 — https://setupbay.com/loadouts/godot-4-headless-tests/ -->
Adapter GEMINI.md Imports AGENTS.md for Gemini CLI. 11 lines
Download Raw
# GEMINI.md

@./AGENTS.md

## Gemini CLI notes

- Skills from this loadout live in `.agents/skills/`: `godot-headless-tests`. Use them when their description matches the task.
- Follow AGENTS.md above; where this file and AGENTS.md disagree, AGENTS.md wins.

<!-- From SetupBay "Godot 4 headless GDScript tests" v1.0.0 — https://setupbay.com/loadouts/godot-4-headless-tests/ -->
Cursor rule .cursor/rules/godot-headless-tests.mdc Never/instead table for GDScript test suites under tests/. 24 lines
Download Raw
---
description: Writing Godot 4 GDScript tests that run headless with tests/run_tests.gd
globs: "tests/**/*.gd"
alwaysApply: false
---

# Godot 4 headless tests

Run: `godot --headless --path . --script res://tests/run_tests.gd` (after `--import` on a fresh clone).

| Never | Instead |
| --- | --- |
| A `test_*()` with no check, or `print()` as the check | `t.equal(actual, expected, "label")`, `t.check(cond, "msg")` |
| `get_tree()` in a suite | `t.root.add_child(node)`; free it in `after_each()` |
| `OS.delay_msec()` | `await t.frames(n)` |
| `t.frames(n)` without `await` | `await t.frames(n)` |
| `await t.create_timer(2.0).timeout` | Call the timeout handler, or set `wait_time` tiny |
| `randi()` / `randf_range()` in the code under test | A seeded `RandomNumberGenerator` passed in |
| `t.quit()` or `get_tree().quit()` in a test | Let the runner exit with the right code |
| Assuming an autoload starts fresh for each test | Reset its state in `before_each()`; it lives for the whole run |
| Guarding a null so a red test goes green | Find why it was null; script errors fail the test on purpose |

Each suite: `extends RefCounted`, `var t: SceneTree`, methods named `test_*`.
Skill skills/godot-headless-tests/SKILL.md Set up the runner, write a suite, lint it, run it headless and report. 82 lines
Download Raw
---
name: godot-headless-tests
description: Set up and run GDScript tests for a Godot 4 project in headless mode, with a SceneTree runner that fails on script errors and exits non-zero. Use when adding a test, changing game rules, or before saying a Godot change is done.
---

# Godot 4 headless tests

## 1. Set up once

If `tests/run_tests.gd` doesn't exist, copy `references/run_tests.gd` from this skill to
`tests/run_tests.gd`. It needs Godot 4.5 or later (it uses the `Logger` class); check
`config/features` in project.godot. Then build the import cache:

```bash
godot --headless --import --path .
```

Run `--import` again whenever you add a `class_name` or a new resource. Without it a fresh clone
fails with `Parse Error: Could not find type "Health"` even though the code is fine.

## 2. Write the test

One suite per script or scene, in `tests/test_<thing>.gd`:

```gdscript
extends RefCounted

var t: SceneTree   # set by the runner
var health: Health


func before_each() -> void:
	health = Health.new()
	health.maximum = 50
	t.root.add_child(health)   # runs _ready()


func after_each() -> void:
	health.free()


func test_damage_clamps_at_zero_and_emits_died_once() -> void:
	var deaths := [0]
	health.died.connect(func() -> void: deaths[0] += 1)
	health.damage(80)
	health.damage(10)
	t.equal(health.current, 0)
	t.equal(deaths[0], 1, "died emitted")
```

For a scene, instance it, add it under `t.root`, then `await t.frames(1)` before you poke it.
After `queue_free()`, `await t.frames(1)` before checking the node has gone. Capture signal counts
in an array (lambdas capture by value, so a plain `int` won't update).

## 3. Lint the tests

```bash
python3 scripts/gd_test_lint.py tests
```

Errors: a test with no check, `get_tree()` in a suite, `OS.delay_msec()`, `t.frames()` without
`await`, `quit()` in a test, editor-only APIs. Warnings: `create_timer()` waits, unseeded random
calls, `print()` used as a check, bare `assert()`. Fix errors; fix or justify warnings
(`# lint: ok` on the line). Try it on `references/bad-test-sample.gd.txt` (7 errors, 4 warnings).

## 4. Run

```bash
timeout 300 godot --headless --path . --script res://tests/run_tests.gd
```

Each test prints `PASS suite::test_name` or `FAIL suite::test_name: reason`, then
`N passed, M failed`. Exit code 1 means at least one failure. A script error inside a test is a
failure with the message and line, such as `script error: Cannot call method 'get_name' on a null
value. (test_arena.gd:14)`. A suite that doesn't parse shows as `FAIL test_x.gd: script did not
load`.

## 5. Report

Give the last line of the run, the tests you added or changed and which rule each covers, and
anything left for a human play-test.
Skill file skills/godot-headless-tests/references/run_tests.gd The SceneTree test runner to copy to tests/run_tests.gd. 100 lines
Download Raw
extends SceneTree
## Headless test runner: godot --headless --path . --script res://tests/run_tests.gd
## Runs every test_*() method in res://tests/test_*.gd, prints PASS/FAIL, and exits 1 on any failure.

const TEST_DIR := "res://tests"

var _failures: Array[String] = []
var _current := ""
var _errors := ErrorCatcher.new()


## Script errors (null calls, bad casts, failed asserts) only print by default: the test
## function stops, but the run still counts it as passed. This logger turns each one into a
## failure of the running test. Warnings are ignored.
class ErrorCatcher extends Logger:
	var _lock := Mutex.new()
	var _seen: Array[String] = []

	func _log_error(_function: String, file: String, line: int, code: String, rationale: String, _editor_notify: bool, error_type: int, _script_backtrace: Array[ScriptBacktrace]) -> void:
		if error_type == ERROR_TYPE_WARNING:
			return
		_lock.lock()
		_seen.append("%s (%s:%d)" % [rationale if not rationale.is_empty() else code, file.get_file(), line])
		_lock.unlock()

	func take() -> Array[String]:
		_lock.lock()
		var out := _seen.duplicate()
		_seen.clear()
		_lock.unlock()
		return out


func _initialize() -> void:
	OS.add_logger(_errors)
	_run.call_deferred()


func _run() -> void:
	var passed := 0
	var failed := 0
	for file in _test_files():
		var script: GDScript = load(TEST_DIR.path_join(file))
		if script == null or not script.can_instantiate():
			failed += 1
			print("FAIL %s: script did not load (parse error?)" % file)
			continue
		for method in script.get_script_method_list():
			var name: String = method["name"]
			if not name.begins_with("test_"):
				continue
			var suite: Object = script.new()
			suite.set("t", self)
			if suite.has_method("before_each"):
				await suite.before_each()
			_failures.clear()
			_errors.take()
			_current = "%s::%s" % [file.get_basename(), name]
			await suite.call(name)
			if suite.has_method("after_each"):
				await suite.after_each()
			for e in _errors.take():
				_failures.append("script error: " + e)
			if suite is Node and is_instance_valid(suite):
				suite.free()
			if _failures.is_empty():
				passed += 1
				print("PASS %s" % _current)
			else:
				failed += 1
				print("FAIL %s: %s" % [_current, "; ".join(_failures)])
	print("%d passed, %d failed" % [passed, failed])
	quit(1 if failed > 0 else 0)


func _test_files() -> PackedStringArray:
	var files := PackedStringArray()
	for f in DirAccess.get_files_at(TEST_DIR):
		if f.begins_with("test_") and f.ends_with(".gd"):
			files.append(f)
	files.sort()
	return files


## Assertions, called from tests as t.check(...) and t.equal(...).
func check(condition: bool, message := "expected true") -> void:
	if not condition:
		_failures.append(message)


func equal(actual: Variant, expected: Variant, message := "") -> void:
	if actual != expected:
		_failures.append("%sexpected %s, got %s" % ["" if message.is_empty() else message + ": ", var_to_str(expected), var_to_str(actual)])


## Wait for n frames of the real main loop (process and physics run headless too).
func frames(n := 1) -> void:
	for i in n:
		await process_frame
Skill file skills/godot-headless-tests/scripts/gd_test_lint.py Read-only linter for test suites that can't fail or that block the run. 110 lines
Download Raw
#!/usr/bin/env python3
"""Read-only linter for Godot 4 headless test suites (tests/test_*.gd).

Finds tests that can't fail, tests that block or end the run, and timing that makes
headless runs slow or flaky. Prints findings and exits 1 if there are errors.

Usage: python3 gd_test_lint.py [path ...]   (default: tests)
A line ending in "# lint: ok" is skipped.
"""
import re
import sys
from pathlib import Path

ASSERT = re.compile(r"\bt\.(check|equal|fail)\s*\(")
LINE_RULES = [
    # (level, id, pattern, advice)
    ("error", "get-tree-in-suite", re.compile(r"(?<![.\w])get_tree\s*\("),
     "a RefCounted suite isn't in the tree; use t (the runner's SceneTree), e.g. t.root.add_child()"),
    ("error", "blocking-delay", re.compile(r"\bOS\.delay_(msec|usec)\s*\("),
     "blocks the main loop, so no frames run; await t.frames(n) instead"),
    ("error", "frames-without-await", re.compile(r"^(?!.*\bawait\b).*\bt\.frames\s*\("),
     "t.frames() is a coroutine; without await the test carries on at once"),
    ("error", "quit-in-test", re.compile(r"(\bt\.|get_tree\(\)\.)quit\s*\("),
     "ends the whole run early and hides the remaining results; let the runner quit"),
    ("error", "editor-only-api", re.compile(r"\b(EditorInterface|EditorPlugin|EditorScript)\b"),
     "the EditorInterface singleton and editor plugins are not available in a --script run; test the game code, not the plugin UI"),
    ("warn", "wall-clock-timer", re.compile(r"\bcreate_timer\s*\("),
     "real-time waits make headless runs slow and flaky; call the timeout handler or set Timer.wait_time tiny"),
    ("warn", "unseeded-random", re.compile(r"(?<![.\w])(randi|randf|randi_range|randf_range)\s*\("),
     "global RNG differs per run; use RandomNumberGenerator with a fixed seed"),
    ("warn", "print-as-check", re.compile(r"(?<![.\w])print\s*\("),
     "a printed value is not a check; use t.equal() so the run fails"),
    ("warn", "bare-assert", re.compile(r"(?<![.\w])assert\s*\("),
     "assert() is stripped from release builds and gives no expected/actual; use t.check() or t.equal()"),
]


def strip_comment(line: str) -> str:
    out, quote = [], ""
    for ch in line:
        if quote:
            out.append(ch)
            if ch == quote:
                quote = ""
        elif ch in "\"'":
            quote = ch
            out.append(ch)
        elif ch == "#":
            break
        else:
            out.append(ch)
    return "".join(out)


def lint(path: Path):
    findings = []
    lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
    test_name, test_line, has_assert = None, 0, False

    def close_test():
        if test_name and not has_assert:
            findings.append(("error", "no-assertion", test_line,
                             f"{test_name}() has no t.check/t.equal/t.fail, so it can only pass"))

    for n, raw in enumerate(lines, 1):
        if raw.rstrip().endswith("# lint: ok"):
            continue
        code = strip_comment(raw)
        if not code.strip():
            continue
        m = re.match(r"^func\s+(\w+)\s*\(", code)
        if m or re.match(r"^\S", code):
            close_test()
            test_name = m.group(1) if m and m.group(1).startswith("test_") else None
            test_line, has_assert = n, False
        if test_name and ASSERT.search(code):
            has_assert = True
        for level, rid, pat, advice in LINE_RULES:
            if pat.search(code):
                findings.append((level, rid, n, advice))
    close_test()
    return sorted(findings, key=lambda f: f[2])


def main(argv):
    targets = [Path(a) for a in argv] or [Path("tests")]
    files = []
    for t in targets:
        if t.is_dir():
            files += sorted(p for p in t.rglob("test_*.gd") if ".godot" not in p.parts)
        elif t.is_file():
            files.append(t)
        else:
            print(f"gd_test_lint: no such path: {t}", file=sys.stderr)
            return 2
    errors = warnings = 0
    for f in files:
        for level, rid, line, advice in lint(f):
            print(f"{f}:{line}: {level} [{rid}] {advice}")
            if level == "error":
                errors += 1
            else:
                warnings += 1
    print(f"gd_test_lint: {len(files)} file(s), {errors} error(s), {warnings} warning(s)")
    return 1 if errors else 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))
Skill file skills/godot-headless-tests/references/bad-test-sample.gd.txt A suite with the usual mistakes, for checking the linter. 43 lines
Download Raw
extends RefCounted
## A suite with the usual headless-test mistakes. Copy it to test_bad.gd to try the linter.

var t: SceneTree
var player: Node


func before_each() -> void:
	player = load("res://scenes/player.tscn").instantiate()
	get_tree().root.add_child(player)


func test_spawns() -> void:
	print(player.position)


func test_dash_cooldown() -> void:
	player.dash()
	OS.delay_msec(500)
	t.equal(player.can_dash, true)


func test_respawn() -> void:
	player.die()
	await t.create_timer(2.0).timeout
	t.frames(1)
	t.check(player.is_alive())


func test_loot_roll() -> void:
	var roll := randi_range(1, 6)
	assert(roll >= 1)


func test_editor_gizmo() -> void:
	var ei = EditorInterface.get_selection()
	t.check(ei != null)


func test_last() -> void:
	t.equal(player.lives, 3)
	t.quit()

All files: source zip · Licence MIT · By SetupBay · First listed

Installing and checks

How do I install Godot 4 headless GDScript tests in Claude Code?

From your project root, put AGENTS.md, CLAUDE.md, skills/godot-headless-tests/SKILL.md at .claude/skills/godot-headless-tests/SKILL.md, skills/godot-headless-tests/references/run_tests.gd at .claude/skills/godot-headless-tests/references/run_tests.gd, skills/godot-headless-tests/scripts/gd_test_lint.py at .claude/skills/godot-headless-tests/scripts/gd_test_lint.py, skills/godot-headless-tests/references/bad-test-sample.gd.txt at .claude/skills/godot-headless-tests/references/bad-test-sample.gd.txt. The "Claude Code" tab on this page has the curl commands, or download https://setupbay.com/download/godot-4-headless-tests/claude-code.zip, which is already laid out. Do not overwrite an existing AGENTS.md or CLAUDE.md; merge it.

How do I install Godot 4 headless GDScript tests in Codex CLI?

From your project root, put AGENTS.md, skills/godot-headless-tests/SKILL.md at .agents/skills/godot-headless-tests/SKILL.md, skills/godot-headless-tests/references/run_tests.gd at .agents/skills/godot-headless-tests/references/run_tests.gd, skills/godot-headless-tests/scripts/gd_test_lint.py at .agents/skills/godot-headless-tests/scripts/gd_test_lint.py, skills/godot-headless-tests/references/bad-test-sample.gd.txt at .agents/skills/godot-headless-tests/references/bad-test-sample.gd.txt. The "Codex CLI" tab on this page has the curl commands, or download https://setupbay.com/download/godot-4-headless-tests/codex.zip, which is already laid out. Do not overwrite an existing AGENTS.md or CLAUDE.md; merge it.

How do I install Godot 4 headless GDScript tests in Cursor?

From your project root, put AGENTS.md, .cursor/rules/godot-headless-tests.mdc, skills/godot-headless-tests/SKILL.md at .agents/skills/godot-headless-tests/SKILL.md, skills/godot-headless-tests/references/run_tests.gd at .agents/skills/godot-headless-tests/references/run_tests.gd, skills/godot-headless-tests/scripts/gd_test_lint.py at .agents/skills/godot-headless-tests/scripts/gd_test_lint.py, skills/godot-headless-tests/references/bad-test-sample.gd.txt at .agents/skills/godot-headless-tests/references/bad-test-sample.gd.txt. The "Cursor" tab on this page has the curl commands, or download https://setupbay.com/download/godot-4-headless-tests/cursor.zip, which is already laid out. Do not overwrite an existing AGENTS.md or CLAUDE.md; merge it.

How do I install Godot 4 headless GDScript tests in Gemini CLI?

From your project root, put AGENTS.md, GEMINI.md, skills/godot-headless-tests/SKILL.md at .agents/skills/godot-headless-tests/SKILL.md, skills/godot-headless-tests/references/run_tests.gd at .agents/skills/godot-headless-tests/references/run_tests.gd, skills/godot-headless-tests/scripts/gd_test_lint.py at .agents/skills/godot-headless-tests/scripts/gd_test_lint.py, skills/godot-headless-tests/references/bad-test-sample.gd.txt at .agents/skills/godot-headless-tests/references/bad-test-sample.gd.txt. The "Gemini CLI" tab on this page has the curl commands, or download https://setupbay.com/download/godot-4-headless-tests/gemini-cli.zip, which is already laid out. Do not overwrite an existing AGENTS.md or CLAUDE.md; merge it.

How do I install Godot 4 headless GDScript tests in Antigravity CLI?

From your project root, put AGENTS.md, skills/godot-headless-tests/SKILL.md at .agents/skills/godot-headless-tests/SKILL.md, skills/godot-headless-tests/references/run_tests.gd at .agents/skills/godot-headless-tests/references/run_tests.gd, skills/godot-headless-tests/scripts/gd_test_lint.py at .agents/skills/godot-headless-tests/scripts/gd_test_lint.py, skills/godot-headless-tests/references/bad-test-sample.gd.txt at .agents/skills/godot-headless-tests/references/bad-test-sample.gd.txt. The "Antigravity CLI" tab on this page has the curl commands, or download https://setupbay.com/download/godot-4-headless-tests/antigravity-cli.zip, which is already laid out. Do not overwrite an existing AGENTS.md or CLAUDE.md; merge it.

How do I install Godot 4 headless GDScript tests in GitHub Copilot?

From your project root, put AGENTS.md, skills/godot-headless-tests/SKILL.md at .agents/skills/godot-headless-tests/SKILL.md, skills/godot-headless-tests/references/run_tests.gd at .agents/skills/godot-headless-tests/references/run_tests.gd, skills/godot-headless-tests/scripts/gd_test_lint.py at .agents/skills/godot-headless-tests/scripts/gd_test_lint.py, skills/godot-headless-tests/references/bad-test-sample.gd.txt at .agents/skills/godot-headless-tests/references/bad-test-sample.gd.txt. The "GitHub Copilot" tab on this page has the curl commands, or download https://setupbay.com/download/godot-4-headless-tests/copilot.zip, which is already laid out. Do not overwrite an existing AGENTS.md or CLAUDE.md; merge it.

What has been checked in Godot 4 headless GDScript tests?

It passed the Level 1 automated install checks on 2026-10-29: the files parse, install to each agent's documented paths, and pass the safety scan. No AI agent has run it yet, so no agent pass or fail is claimed.

More for Claude Code, Codex, Cursor, Gemini CLI, Antigravity CLI and GitHub Copilot.