3.6 KiB
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
- AssetLib → "gdUnit4" → Install (or add as git submodule under
addons/gdUnit4) - Enable the addon in Project Settings → Plugins
- 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
- Write the failing test FIRST when fixing a bug (reproduce via test, then fix)
- Run only the touched suite while iterating (
--include), full suite before commit - If a test is flaky (async timing), fix the race condition — never delete the assertion
- 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 |