Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Run gdUnit4 unit tests and parse results into structured output. Use this skill after writing or modifying code to verify correctness via unit tests, when diagnosing test failures, or when writing new test files. Triggers: "run tests", "test fails", "write a test", any gdUnit4/unit test mention. Supports both GDScript (.gd) and C# (.cs) test files.
.claude/skills/randallliuxin-gdunit-driver/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-02 | ✗→✓ | ▲ Improved | 1041% | 0% |
| case-04 | ✗→✓ | ▲ Improved | 190% | 0% |
| case-07 | ✗→✓ | ▲ Improved | 359% | 0% |
| case-08 | ✗→✓ | ▲ Improved | 59% | 0% |
| case-10 | ✗→✓ | ▲ Improved | 92% | 0% |
$ARGUMENTS
Read the path from the project's config file. This avoids hardcoding paths that differ per machine.
bash# From the project root: python tools/agent_runtime.py godot_path
The CLI runner across all supported versions (v4.x, v5.x, v6.x) is addons/gdUnit4/bin/GdUnitCmdTool.gd. The path uses capital-U gdUnit4/ to match the upstream repo layout — Windows is case-insensitive but Godot's global script registry de-duplicates by exact path string, so a casing mismatch between the runner invocation and the on-disk directory triggers Class "..." hides a global script class parse errors and a non-zero exit.
bash# Single file "<godot_path>" --headless -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd \ --add res://test/test_example.gd --ignoreHeadlessMode # Multiple files "<godot_path>" --headless -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd \ --add res://test/test_physics.gd --add res://test/test_spawner.gd \ --ignoreHeadlessMode # All tests in a directory "<godot_path>" --headless -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd \ --add res://test/ --ignoreHeadlessMode
Notes:
--ignoreHeadlessMode for headless runs.--add to enqueue test files or directories (repeat the flag for multiples).res:// prefix and the capital-U addons/gdUnit4/ casing.::method syntax for single test methods — run the whole file instead.GdUnitCmdTool.gd supports C# test files too, but ensure dotnet build passes first — gdUnit4 runs compiled assemblies, not source files.
bashdotnet build && "<godot_path>" --headless -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd \ --add res://test/csharp/TestExample.cs --ignoreHeadlessMode
| Flag | Purpose | |------|---------| | --add <path> | Add test path to execution (file or directory; repeat to enqueue multiple) | | --ignoreHeadlessMode | Allow headless execution | | --report-directory <path> | Override report output directory |
gdUnit4 has a default test timeout (configurable in GdUnitSettings). If tests hang:
await callsGdUnitCmdTool.gd output contains ANSI color codes — strip them before parsing. Format:
Run Test Suite res://test/test_example.gd
Run Test: res://test/test_example.gd > test_basic_math :PASSED 38ms
Run Test: res://test/test_example.gd > test_will_fail :FAILED 39ms
Report:
line <n/a>: Expecting:
'2'
but was
'1'
Statistics: | 2 tests cases | 0 error | 1 failed | 0 flaky | 0 skipped | 0 orphans |
Executed test suites: (1/1)
Executed test cases: (2/2)
Total time: 128ms
Exit code: 100Notes:
Run Test:; suite lines with Run Test Suite.38ms).line <n/a> instead of exact lines.Statistics: with pipe-delimited counts.Extract from each test line:
test_* function name (after >).PASSED, FAILED, SKIPPED, ERROR.Nms after the status.Report: lines following a FAILED test (assertion details + source location if available).Use --report-directory <path> for JUnit XML reports. The default report directory is res://reports/, which maps to project-root reports/.
xml<testsuites> <testsuite name="TestExample" tests="4" failures="1" errors="0" skipped="1"> <testcase name="test_basic_math" classname="TestExample" time="0.002"/> <testcase name="test_will_fail" classname="TestExample" time="0.003"> <failure message="Expecting '2' but was '1'" type="AssertionError"> at: res://test/test_example.gd:15 </failure> </testcase> </testsuite> </testsuites>
Report results in this format:
## Test Results: test_example.gd
| Test | Status | Duration |
|------|--------|----------|
| test_basic_math | PASS | 0.002s |
| test_string_concat | PASS | 0.001s |
| test_will_fail | FAIL | 0.003s |
| test_skipped | SKIP | 0.000s |
**Summary: 4 total, 2 passed, 1 failed, 1 skipped, 0 errors**
### Failures
**test_will_fail** (res://test/test_example.gd:15)
> Expecting '2' but was '1'Always include the file:line for failures — the agent (or user) needs this to navigate to the problem.
When the agent needs to write a new test file, follow these patterns.
gdscript# res://test/test_my_system.gd extends GdUnitTestSuite # Runs before each test func before_test() -> void: pass # Runs after each test func after_test() -> void: pass func test_example() -> void: assert_int(2 + 2).is_equal(4) func test_string_operations() -> void: assert_str("hello").contains("ell")
gdscript# Integers assert_int(value).is_equal(expected) assert_int(value).is_greater(threshold) assert_int(value).is_between(low, high) # Floats assert_float(value).is_equal_approx(expected, 0.001) # Strings assert_str(value).is_equal(expected) assert_str(value).contains(substring) assert_str(value).starts_with(prefix) # Booleans assert_bool(value).is_true() assert_bool(value).is_false() # Objects assert_that(value).is_not_null() assert_that(value).is_instanceof(MyClass) # Arrays assert_array(arr).has_size(3) assert_array(arr).contains([1, 2]) # Signals await assert_signal(node).is_emitted("my_signal") await assert_signal(node).wait_until(2.0).is_emitted("my_signal") # Errors / Warnings (assert that code pushes expected error) assert_error(callable).is_push_error("expected message")
When testing code that needs nodes or scene tree:
gdscriptfunc test_with_scene() -> void: # auto_free ensures cleanup even if test fails — prevents scene leaks var scene = auto_free(load("res://scenes/player.tscn").instantiate()) add_child(scene) # Now test with the live scene assert_that(scene.get_node("Sprite2D")).is_not_null()
auto_free() is critical — without it, test failures leak nodes and eventually crash the runner.
A stub class must expose every property and method the system-under-test reads or calls on it.
gdscript# WRONG — bare Node has no global_position, no velocity var entity = auto_free(Node.new()) system.process_one(entity) # Invalid access to property "global_position" # RIGHT — stub class carries the properties the system reads var entity = auto_free(CharacterBody2D.new()) system.process_one(entity)
Grep the system code for every property and method it touches on the stubbed argument; each one must exist on the stub class.
For code involving signals, timers, or physics frames:
gdscriptfunc test_signal_emission() -> void: var emitter = auto_free(SignalEmitter.new()) add_child(emitter) emitter.trigger_action() await assert_signal(emitter).wait_until(2.0).is_emitted("action_done") func test_after_physics_frame() -> void: var node = auto_free(MyNode.new()) add_child(node) # Wait for physics to process await get_tree().physics_frame assert_float(node.position.x).is_greater(0.0)
Always pass a timeout to wait_until() — unbounded waits hang the test runner.
When tests need _process / _physics_process to actually execute, or need to simulate player input, use gdUnit4's SceneRunner. Without it, adding a node to the tree does NOT advance frames — process callbacks never fire.
GDScript:
gdscriptfunc test_player_moves_on_input() -> void: var runner := scene_runner("res://scenes/player.tscn") # Simulate input action (matches Input Map) runner.simulate_action_pressed("move_right") await runner.simulate_frames(5) var player = runner.scene() assert_float(player.position.x).is_greater(0.0) func test_physics_movement() -> void: var runner := scene_runner("res://scenes/player.tscn") runner.set_property("velocity", Vector2(100, 0)) # Advance 10 frames — _physics_process runs each frame await runner.simulate_frames(10) assert_float(runner.scene().position.x).is_greater(0.0)
C#:
csharp[TestCase] public async Task TestPlayerMovesOnInput() { var runner = ISceneRunner.Load("res://scenes/Player.tscn"); runner.SimulateActionPressed("move_right"); await runner.SimulateFrames(5); var player = runner.Scene<PlayerController>(); AssertFloat(player.Position.X).IsGreater(0.0f); }
Key SceneRunner methods:
| GDScript | C# | Purpose | |----------|-----|---------| | scene_runner(path) | ISceneRunner.Load(path) | Load scene for testing | | runner.scene() | runner.Scene() | Get the root node | | await runner.simulate_frames(n) | await runner.SimulateFrames(n) | Advance N frames | | runner.set_property(name, val) | runner.SetProperty(name, val) | Set node property | | runner.simulate_key_press(key) | runner.SimulateKeyPress(key) | Simulate key press | | runner.simulate_action_pressed(action) | runner.SimulateActionPressed(action) | Simulate Input Map action | | runner.simulate_mouse_move_absolute(pos) | runner.SimulateMouseMoveAbsolute(pos) | Move mouse to position | | await runner.await_input_processed() | await runner.AwaitInputProcessed() | Wait for input processing |
When to use SceneRunner vs plain auto_free:
auto_free(Node.new()) + direct calls — faster, simplercsharp// res://test/csharp/TestMySystem.cs using GdUnit4; using static GdUnit4.Assertions; [TestSuite] public partial class TestMySystem : TestSuite { [TestCase] public void TestExample() { AssertInt(2 + 2).IsEqual(4); } [TestCase] public async Task TestAsync() { var node = AutoFree(new MyNode()); AddChild(node); await AssertSignal(node).WaitUntil(2000).IsEmitted("ready"); } }
extends GdUnitTestSuitetest_ (GDScript) or have [TestCase] attribute (C#)res:// paths, not absolute OS pathsawait calls — always use wait_until(seconds) / WaitUntil(ms)simulate_frames() instead of raw add_child()auto_free()before_test() creates nodes, after_test() should clean them upclass_name conflicts between test and production codedotnet build before running testsGdUnitCmdTool refuses headless by default--ignoreHeadlessMode to the command line — this is safe for non-UI testsres:// and use capital-U casing: res://addons/gdUnit4/bin/GdUnitCmdTool.gd.addons/gdunit4/ will not resolve at all; on Windows it resolves but Godot's class registry will double-register classes under the two casings and emit Class "..." hides a global script class parse errors.GdUnitCmdTool.gd for every supported version (v4.x / v5.x / v6.x).Nonexistent function 'new' in base 'CSharpScript' — this happens when gdUnit4 tries to load C# support but the C# assembly isn't built. Run dotnet build first, or ignore these errors if you only run GDScript tests.--ignoreHeadlessMode| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | fail→fail | 12,950 | 4,403 | -66% | 1 | 1 | 0% | 567 | 4,010 | +607% | 0 | 0 | — |
case-02 | fail→pass | 6,821 | 5,244 | -23% | 1 | 1 | 0% | 388 | 4,428 | +1041% | 0 | 0 | — |
case-03 | fail→fail | 14,409 | 5,414 | -62% | 1 | 1 | 0% | 2,817 | 4,019 | +43% | 0 | 0 | — |
case-04 | fail→pass | 8,842 | 5,590 | -37% | 1 | 1 | 0% | 1,659 | 4,818 | +190% | 0 | 0 | — |
case-05 | pass→pass | 6,428 | 3,606 | -44% | 1 | 1 | 0% | 1,111 | 4,385 | +295% | 0 | 0 | — |
case-06 | pass→pass | 5,685 | 4,151 | -27% | 1 | 1 | 0% | 975 | 4,488 | +360% | 0 | 0 | — |
case-07 | fail→pass | 5,534 | 1,964 | -65% | 1 | 1 | 0% | 888 | 4,077 | +359% | 0 | 0 | — |
case-08 | fail→pass | 15,843 | 3,682 | -77% | 1 | 1 | 0% | 2,771 | 4,396 | +59% | 0 | 0 | — |
case-09 | pass→pass | 11,232 | 2,433 | -78% | 1 | 1 | 0% | 1,674 | 4,080 | +144% | 0 | 0 | — |
case-10 | fail→pass | 14,145 | 4,423 | -69% | 1 | 1 | 0% | 2,301 | 4,419 | +92% | 0 | 0 | — |
case-11 | fail→pass | 15,019 | 4,287 | -71% | 1 | 1 | 0% | 2,583 | 4,373 | +69% | 0 | 0 | — |
case-12 | pass→pass | 7,574 | 4,703 | -38% | 1 | 1 | 0% | 1,210 | 4,639 | +283% | 0 | 0 | — |
case-13 | pass→pass | 11,189 | 4,525 | -60% | 1 | 1 | 0% | 2,010 | 4,513 | +125% | 0 | 0 | — |
case-14 | pass→pass | 7,240 | 1,961 | -73% | 1 | 1 | 0% | 1,160 | 4,046 | +249% | 0 | 0 | — |
case-15 | pass→pass | 9,186 | 5,262 | -43% | 1 | 1 | 0% | 1,605 | 4,681 | +192% | 0 | 0 | — |
case-16 | pass→pass | 8,379 | 2,186 | -74% | 1 | 1 | 0% | 1,505 | 4,166 | +177% | 0 | 0 | — |
case-17 | fail→pass | 30,877 | 4,912 | -84% | 1 | 1 | 0% | 4,770 | 4,546 | -5% | 0 | 0 | — |
case-18 | fail→pass | 13,707 | 2,729 | -80% | 1 | 1 | 0% | 2,205 | 4,264 | +93% | 0 | 0 | — |
case-19 | fail→pass | 10,514 | 2,570 | -76% | 1 | 1 | 0% | 1,768 | 4,185 | +137% | 0 | 0 | — |
case-20 | fail→fail | 4,276 | 4,879 | +14% | 1 | 1 | 0% | 787 | 4,621 | +487% | 0 | 0 | — |
case-21 | pass→pass | 6,204 | 4,528 | -27% | 1 | 1 | 0% | 1,017 | 4,523 | +345% | 0 | 0 | — |
case-22 | pass→pass | 4,514 | 3,040 | -33% | 1 | 1 | 0% | 976 | 4,262 | +337% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted, and 19 counted toward the lift figure. The other 3 produced results that are not comparable between the two arms, so they are excluded from the headline rather than averaged into it. The headline lift of +41 percentage points is the difference between those two pass rates over the 19 comparable cases. 1 case got worse with the skill loaded, and it is included in that figure.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.