Skills/godot-headless-workflow/references/testing.md

3.6 KiB

gdUnit4 Testing Deep Dive

Standardize every Godot project on gdUnit4 — the actively maintained test framework with a headless CLI runner built for CI and agents.

Install

  1. AssetLib → "gdUnit4" → Install (or add as git submodule under addons/gdUnit4)
  2. Enable the addon in Project Settings → Plugins
  3. Commit addons/gdUnit4/ with the project so agents on fresh clones can run tests

Layout

tests/
├── unit/          # pure logic: score, economy, state machines
│   └── test_score_manager.gd
├── integration/   # scenes wired together
└── fixtures/      # test data (.tres)
reports/           # generated HTML/XML — .gitignore this

Suite Conventions

# tests/unit/test_score_manager.gd
class_name TestScoreManager
extends GdUnitTestSuite

const ScoreManager := preload("res://scripts/core/score_manager.gd")

var _sm

func before_test() -> void:
	_sm = ScoreManager.new()          # auto-freed after each test

func test_add_score_increments() -> void:
	_sm.add(10)
	assert_int(_sm.score).is_equal(10)

func test_score_never_negative() -> void:
	_sm.add(-5)
	assert_int(_sm.score).is_equal(0)

func test_addition_is_parameterized(a: int, b: int, expected: int) -> void:
	_sm.add(a + b)
	assert_int(_sm.score).is_equal(expected)   # supply via test-suite args or use fuzzers

Assertion helpers to know: assert_int, assert_float, assert_str, assert_bool, assert_that(expr), assert_array, assert_dict, assert_object(obj).is_instanceof(...), assert_signal(spy).is_emitted("name"). Full list in gdUnit4 docs.

Async & Scene Tests

Await signals/frames inside tests — essential for scene-dependent logic:

func test_enemy_dies_after_hit() -> void:
	var enemy := auto_free(load("res://scenes/entities/enemy.tscn").instantiate())
	scene_runner().add_child(enemy)     # scene_runner() gives a managed tree context
	await get_tree().process_frame

	enemy.take_damage(enemy.hp)
	await assert_signal(enemy).is_emitted("died")

Keep rendering-dependent asserts out of headless runs: no pixel/screenshot checks in CI.

Running From CLI

# everything under res://tests
addons/gdUnit4/runtest.cmd -a res://tests --continue       # Windows
./addons/gdUnit4/runtest.sh -a res://tests --continue      # Linux/macOS

# one suite only
addons/gdUnit4/runtest.cmd -a tests/unit/test_score_manager.gd

# filter by name pattern; stop-on-first-failure for fast loops
addons/gdUnit4/runtest.cmd -a res://tests --include test_score --no-failure
  • Exit code 0 = all passed; non-zero = failures → CI/agent gate safe
  • Reports: res://reports/ (HTML + XML/JUnit)
  • Runner is headless by default when no display is available

Agent Workflow Integration

  1. Write the failing test FIRST when fixing a bug (reproduce via test, then fix)
  2. Run only the touched suite while iterating (--include), full suite before commit
  3. If a test is flaky (async timing), fix the race condition — never delete the assertion
  4. New feature ⇒ new suite; refactoring ⇒ tests unchanged, code changes

Common Pitfalls

Pitfall Fix
Test class missing class_name / wrong base class Must be extends GdUnitTestSuite + class_name Test*
Asserting inside awaited coroutines after timeout Use await assert_signal(...) helpers, not manual timers
Scene loads but _ready errors Run smoke test (SKILL.md §Smoke run) — it surfaces these
Tests pass alone, fail together Suites sharing autoload state → reset in before_test()
Adding tests without enabling addon --import + check plugin enabled in project.godot