Testing Godot Games at Scale: 73 Integration Tests, CI, and Watch Mode
1. The Problem
Most Godot projects ship with zero automated tests. We’ve all been there – you tweak a collision mask, and three days later someone discovers the docking clamps stopped latching. Physics interactions, player input chains, mission state machines, signal wiring – these break silently. Manual playtesting catches obvious regressions, but not the subtle ones that creep in across 50+ interconnected nodes.
Runners is a cooperative ship-driving game built in Godot 4.5.1. Players pilot a multi-crew vessel through space stations, docking procedures, and combat encounters. The codebase spans player animation, split-screen cameras, gravity-based docking clamps, enemy wave spawners, a nine-step mission director, and pressure-plate station switching. Integration points are everywhere.
We built 73 integration tests across 9 test files. Here’s exactly how we did it, what broke in headless mode, and what we’d do differently next time.
2. Why Test a Game?
Game testing has a reputation problem. “You can’t test gameplay” is the common refrain. That’s true for feel – you can’t assert that a gun “feels punchy.” But you absolutely can test:
mission_finished signal emitted after the last step? These are pure logic with zero visual dependency.ship_destroyed actually emit when health hits zero? Do 15 nodes across 4 scenes all connect to the right signals?ui_up while locked to a pressure plate, does the ship accelerate? Does the gun rotate when ui_left is held on a gunner plate? These are the full input?physics?output pipeline.CameraConfig child? Does the split-screen scene have exactly two viewports with distinct cameras?Every one of these tests caught at least one real bug during development.
3. Our Testing Stack
| Component | Details |
| Engine | Godot 4.5.1 |
| Test framework | GUT 9.6.0 (Godot Unit Test) |
| Test files | 9 integration scripts |
| Helpers | 1 shared assertion utility file |
| Test count | 73 tests, 201 assertions, 0 failures |
| Runtime | ~18 seconds (headless) |
| CI mode | --headless godot binary |
| Watch mode | PowerShell FileSystemWatcher (Windows) / inotifywait (Linux) |
4. Test File Breakdown
4.1 test_camera_system.gd – Camera Config, Split-Screen, Space Camera
10 tests covering:
CameraConfig child existence on player, P1, and P2 scenes – verifies every player prefab inherits the isometric camera offsetplayer.position + camera_offset, dual-player cameras diverge when players exceed max_separation-z must point downward (isometric look direction)4.2 test_combat_chain.gd – Enemy Health, Projectiles, Gun Firing
9 tests covering:
ship_destroyed signal emits on enemy deathfired signal emits when gun.fire() is calledui_right rotates gun aim angleenemy_wave_cleared when _active_enemies empties4.3 test_docking_system.gd – Grav Clamps, Kinematic Clamps, Bay Doors
11 tests covering:
AlignmentPointA, AlignmentPointB, DetectionArea must existRigidBody3D, target_body resolves to the parentRigidBody3D parentlinear_pull_speeddoor_opened and door_closed signals emitopen_all() opens every door4.4 test_event_logging.gd – NarrationSystem, MissionDirector Signals
9 tests covering:
Engine.has_singleton("NarrationSystem") (headless-safe)message_started, objective_updatedstep_activated, step_completed, mission_started, mission_finishedstep_activated with correct step_idcomplete_step() marks step completed, marks it inactive, emits step_completedship_destroyed signal existence and manual emission on BaseShiphealth_changed signal declaration on BaseShipwave_spawned, all_enemies_cleared4.5 test_gunner_seat.gd – Locking, Rotation, Firing, Cooldown, Exit
7 tests covering:
is_on_plate(), current_plate, locked_player referencesset_aim_angle() or direct rotation_degrees modificationInput.action_press("fire") and projectile group trackingui_accept (jump): is_on_plate() returns false, locked_player nulled4.6 test_mission_flow.gd – Step Chain, Objective Text, next_step_ids
6 tests covering:
report_to_irix ? all_crew_onboard ? launch_ship ? calm_flight ? destroy_attackers ? dock_with_relay_buoy ? load_cargo ? return_to_outpost ? deliver_cargocomplete_step() on step N activates step N+1, deactivates step Nmission_finished signal emitsnext_step_ids chain validation: every step’s successors match expected graph4.7 test_player_animation.gd – Limb Pivots, Walk/Idle, Gait Phase
6 tests covering:
CharacterAnimPlayer, LeftLegPivot, RightLegPivot, LeftArmPivot, RightArmPivot – all instantiated on readycurrent_animation == "walk", speed_scale > 0.1left_leg.rotation.x * left_arm.rotation.x <= 0 (natural gait)4.8 test_player_interactions.gd – Station Switching, Range, Movement
7 tests covering:
is_on_plate() is falseui_up on ship deck produces position changecarried_object or carried_crate property exists, crate scene loadableenter_ship_area() sets current_ship referencelocked_to_plate and unlocked_from_plate signals emit correctly4.9 test_pressure_plate_control.gd – Locking, Ship Input, Stopping
6 tests covering:
is_on_plate() true, current_plate reference, locked_player referenceui_up while locked ? linear_velocity changesui_accept exitis_piloted flag toggles correctly on lock/unlock5. Integration Test Patterns
These patterns emerged after writing a few dozen tests. They’re reusable across any Godot project.
5.1 Physics Frame Settling
Scenes don’t initialize instantly. Nodes added to the tree need a few physics frames for _ready(), _process(), and physics ticks to run. Without settling, you get false negatives from uninitialized references.
func before_each():
player = preload("res://player/player.tscn").instantiate()
add_child(player)
await wait_physics_frames(5) # Let _ready() and first physics ticks run
Always settle after add_child() and scene instantiation. 2-5 frames is typical. Signal-heavy systems (combat chain, player interactions) may need more.
5.2 Simulating Input
Godot’s Input singleton is global – action_press() and action_release() work identically in test and play mode. The key is frame interleaving:
func test_ship_responds_to_input():
Input.action_press("ui_up")
await wait_physics_frames(10)
Input.action_release("ui_up")
await wait_physics_frames(20)
var final_pos := ship.global_position
assert_true(final_pos.distance_to(initial_pos) > 0.01,
"Ship should move when player provides input")
Always release after press. Always await frames between press and assertion. Physics-driven systems may need 20+ frames for velocity to visibly accumulate.
5.3 Signal Verification with Lambda Capture
GDScript lambdas capture primitives by value. This means var emitted := false; signal.connect(func(): emitted = true) will never set emitted – the lambda captures the *current value* of emitted as a copy.
The fix: wrap in an Array.
func test_signal_emitted():
var emitted: Array[bool] = [false]
obj.some_signal.connect(func(): emitted[0] = true)
obj.do_thing()
await wait_physics_frames(1)
assert_true(emitted[0], "Signal should emit")
Arrays are passed by reference, so the lambda mutates the same array the test reads. This pattern is used in every signal test in our suite.
5.4 Scene Construction from Script
Not every test needs a .tscn file. For unit-like integration tests, construct nodes programmatically:
func test_mission_director_completes_step():
var director := MissionDirector.new()
director.auto_start = false
test_scene.add_child(director)
var step := MissionStepResource.new()
step.step_id = &"complete_test"
step.objective_text = "Complete this"
step.starts_active = false
director.steps.append(step)
director._rebuild_step_lookup()
director.activate_step(&"complete_test")
director.complete_step(&"complete_test")
assert_true(director.is_step_completed(&"complete_test"))
This tests the mission director logic in isolation – no level geometry needed. It’s faster to run and more targeted than loading the full mission scene.
5.5 Test Helpers as Static Utilities
Shared assertion logic lives in tests/test_helpers/ as .gd files with static func methods. No class_name – avoids polluting the global namespace.
# tests/test_helpers/event_log_assertions.gd
static func assert_signal_emitted(sig: Signal, timeout_seconds: float) -> bool:
var box: Array[bool] = [false]
sig.connect(func(): box[0] = true)
# ... polling loop ...
return box[0]
static func autoload_exists(autoload_name: String) -> bool:
return is_instance_valid(Engine.get_singleton(autoload_name))
Usage from any test:
const Helper = preload("res://tests/test_helpers/event_log_assertions.gd")
func test_narration_system_exists():
assert_true(Helper.autoload_exists("NarrationSystem"))
6. CI/CD Setup
6.1 GutConfig
{
"dirs": ["res://tests/integration/"],
"should_exit": true,
"include_subdirs": true,
"log_level": 3
}
should_exit: true is critical for CI – without it, GUT’s GUI panel opens and the process hangs. include_subdirs ensures tests/test_helpers/ is discoverable if you put tests there later.
6.2 Headless Command
godot --headless --path . `
-s res://addons/gut/gut_cmdln.gd `
-gdir=res://tests/integration `
-ginclude_subdirs `
-gexit
The key flags:
| Flag | Purpose |
--headless |
No rendering, no window. Required for CI. |
--path . |
Set project root to current directory. |
-s |
Run GUT command-line runner (gut_cmdln.gd). |
-gdir= |
Override test directory (gutconfig.json sets this too, but CLI overrides it). |
-ginclude_subdirs |
Recurse into subdirectories. |
-gexit |
Exit process when done. Required for CI exit codes. |
6.3 Runner Scripts
Two scripts in the project root handle platform detection and common failure modes.
run_tests.ps1 (Windows):
$godotCmd = Get-Command godot -ErrorAction SilentlyContinue
if (-not $godotCmd) {
# Fallback: check C:\Godot\Godot.exe, Program Files, etc.
}
$tempFile = [System.IO.Path]::GetTempFileName() + ".txt"
$proc = Start-Process -FilePath $godotCmd `
-ArgumentList "--headless", "--path", ".", `
"-s", "res://addons/gut/gut_cmdln.gd", `
"-gdir=res://tests/integration", `
"-ginclude_subdirs", "-gexit" `
-Wait -NoNewWindow -PassThru `
-RedirectStandardOutput $tempFile
$text = Get-Content $tempFile -Raw
if ($text -match "Failing Tests\s+(\d+)") {
exit ([int]$Matches[1] -eq 0 ? 0 : 1)
}
run_tests.sh (Linux/macOS):
godot --headless \
--path . \
-s res://addons/gut/gut_cmdln.gd \
-gdir=res://tests/integration \
-ginclude_subdirs \
-gexit \
-glog_level=3
Both scripts set exit codes correctly – CI platforms (GitHub Actions, GitLab CI, Jenkins) can gate merges on test results.
7. Watch Mode
Running .\run_tests.ps1 after every save gets old. Watch mode runs tests automatically whenever a .gd file changes – instant feedback loop.
watch_tests.ps1 (Windows):
$watcher = New-Object System.IO.FileSystemWatcher
$watcher.Path = (Get-Location).Path
$watcher.Filter = "*.gd"
$watcher.IncludeSubdirectories = $true
$watcher.EnableRaisingEvents = $true
$action = {
$path = $Event.SourceEventArgs.FullPath
Write-Host "File $($Event.SourceEventArgs.ChangeType) : $path"
& .\run_tests.ps1
}
Register-ObjectEvent $watcher "Changed" -Action $action | Out-Null
Register-ObjectEvent $watcher "Created" -Action $action | Out-Null
while ($true) { Start-Sleep -Seconds 1 }
watch_tests.sh (Linux/macOS):
# Uses inotifywait (Linux) or fswatch (macOS), fallback polling
if command -v inotifywait &> /dev/null; then
inotifywait -m -r -e modify,create,delete --include '\.gd$' . |
while read file; do ./run_tests.sh; done
elif command -v fswatch &> /dev/null; then
fswatch -o -e '.*' -i '\.gd$' . |
while read; do ./run_tests.sh; done
fi
This changed our workflow. Save a file, switch to terminal, tests already running. We catch regressions in seconds, not minutes.
8. Headless Gotchas
Running Godot tests without a display server surfaces engine behaviors you’d never see in the editor. Here’s what we hit.
8.1 Autoloads (Singletons) Don’t Auto-Initialize
In headless mode, autoloads defined in project.godot sometimes fail to register. Engine.get_singleton("NarrationSystem") can return null even though the autoload exists in the editor. Always guard with Engine.has_singleton():
func test_narration_system_autoload_exists():
if not Engine.has_singleton("NarrationSystem"):
pass_test("Autoload not registered in headless test mode")
return
var ns := Engine.get_singleton("NarrationSystem")
assert_not_null(ns)
The pass_test() approach is better than assert_true(false, ...) – it marks the test as passed (skipped) rather than failed, which keeps CI green for headless-specific limitations.
8.2 AnimationMixer Warnings
In headless mode, AnimationMixer nodes with missing animation tracks spam warnings like:
AnimationMixer: no animation track for path: ...
These are harmless but noisy. Suppress them in before_each() by deactivating mixers:
func _suppress_animation_warnings(node: Node) -> void:
for anim in node.find_children("*", "AnimationMixer", true, false):
if anim is AnimationMixer:
(anim as AnimationMixer).set_active(false)
Call this on every instantiated player or animated scene node.
8.3 Scene Construction Order Matters
Set node references before calling add_child(). Many _ready() callbacks read child nodes and export vars – if you add the parent to the tree first, _ready() fires before children exist.
# WRONG: controller._ready() fires before children exist
test_scene.add_child(controller)
controller.add_child(door1)
# CORRECT: children exist when _ready() scans them
controller.add_child(door1)
controller.add_child(door2)
test_scene.add_child(controller)
This is why test_docking_system.gd adds BayDoor children to BayDoorController before adding the controller to the scene tree.
8.4 Resource Leaks at Exit Are Normal
Headless test runs often end with:
ERROR: Resource leak: 4 resources still in use at exit.
This is normal. GUT holds references to test scripts, scenes, and doubles until process exit. Godot’s cleanup runs in editor mode but headless exits before full teardown. Ignore these – they don’t affect test results.
8.5 GUT Pre-Release Startup Warnings
GUT 9.6.0 may print warnings about warnings_manager on startup. These are harmless pre-release artifacts:
WARNING: gut/warnings_manager.gd: ...
They don’t affect test execution. Suppress with -glog_level=2 (warnings only) if they’re noisy, but we found log_level=3 (info) more useful for debugging.
8.6 SubViewport World Assignment
In headless mode, SubViewport nodes don’t inherit world_3d from the main viewport automatically. Tests that use split-screen must explicitly assign:
var vp1: SubViewport = split_screen.get_node_or_null("Viewport1")
if vp1:
vp1.world_3d = get_viewport().world_3d
Without this, cameras inside SubViewports render nothing and position assertions fail.
9. Test Organization
tests/
??? integration/
? ??? test_camera_system.gd
? ??? test_combat_chain.gd
? ??? test_docking_system.gd
? ??? test_event_logging.gd
? ??? test_gunner_seat.gd
? ??? test_mission_flow.gd
? ??? test_player_animation.gd
? ??? test_player_interactions.gd
? ??? test_pressure_plate_control.gd
??? test_helpers/
??? event_log_assertions.gd
Single integration/ directory – no unit/ split yet. We found the line between “unit” and “integration” blurs quickly with Godot, where even a “unit” test of a single component needs add_child(), physics frames, and scene construction. The important distinction is: integration tests instantiate real game scenes (.tscn files) and exercise cross-system interactions.
test_helpers/ holds shared assertion utilities only. No test scripts live here, no scenes, no autoloads. Pure static func libraries.
10. Key Metrics
| Metric | Value |
| Total integration tests | 73 |
| Total assertions | 201 |
| Test files | 9 |
| Helper files | 1 |
| Passing tests | 73 (100%) |
| Failing tests | 0 |
| Test runtime (headless) | ~18 seconds |
| Watch mode latency | <2 seconds after save |
| Lines of test code | ~1,300 |
| Lines of production code covered | ~8,000 (indirect coverage via integration paths) |
Not every test is equal. The mission flow chain test (test_full_chain_completion) exercises 9 state transitions across one test. The collision layer test (test_collision_layers_work_correctly) is a single assertion on a magic number. Both are valuable. The chain test catches logical regressions; the collision test catches accidental layer changes when someone reorganizes the physics matrix.
11. Lessons Learned
Tests catch physics regressions that manual playtesting misses
Our grav clamp stiffness test (assert_gt(grav.spring_stiffness, 0.0)) failed once when someone accidentally zeroed out the export var during a merge conflict resolution. No one noticed in playtesting because the clamp still “looked like it worked” – it just stopped applying corrective force. The ship drifted slightly more over 30 seconds. The test caught it instantly.
Signal-driven architecture makes testing easier
Every major system in Runners communicates via signals: step_activated, ship_destroyed, door_opened, fired, locked_to_plate. Connecting a one-line lambda to track emissions is trivial:
director.mission_finished.connect(func(): finished[0] = true)
Systems that use direct method calls or tightly coupled references are harder to verify. When you design for signals, you’re designing for testability.
Watch mode changed our workflow
Before watch mode: make changes, playtest for 10 minutes, remember to run tests before commit, discover 3 failures, fix, commit.
After watch mode: save file, glance at terminal, see green, keep going. The feedback loop dropped from minutes to seconds. We now test on save, not on commit.
Start testing early
Retrofitting tests onto existing systems is painful. You discover that a class you need to instantiate requires 6 dependencies, 3 autoloads, and a specific scene tree arrangement. Writing tests alongside feature code forces you to keep components decoupled and instantiable in isolation.
Our first test (test_player_locks_to_pressure_plate) took 45 minutes to get working. The tenth test took 5 minutes. The learning curve is mostly about scene construction patterns and headless quirks – both are learn-once costs.
Not everything needs a test
We don’t test visual effects (particle systems, shaders, post-processing). We don’t test “feel” (camera shake intensity, animation easing curves). We don’t test editor tooling (custom inspectors, import plugins). Tests validate logic, state, and data – leave aesthetics to human review.
12. Getting Started
Minimal GUT setup for any Godot 4.x project:
Step 1: Install GUT. Download from the Asset Library or clone into res://addons/gut/.
Step 2: Create gutconfig.json at project root:
{
"dirs": ["res://tests/"],
"should_exit": true,
"include_subdirs": true,
"log_level": 3
}
Step 3: Write your first test:
# tests/test_hello.gd
extends GutTest
func test_one_plus_one_equals_two():
assert_eq(1 + 1, 2)
Step 4: Run it:
godot --headless -s res://addons/gut/gut_cmdln.gd -gdir=res://tests/ -gexit
Step 5: Create run_tests.ps1 (Windows) or run_tests.sh (Linux/macOS) – copy the script from Section 6.3 and adjust paths.
Step 6: Add watch mode – copy watch_tests.ps1 or watch_tests.sh from Section 7.
From there, test one system at a time. Start with data integrity (are export vars positive? are node children present?), then move to state transitions (does completing step A activate step B?), then input chains (does pressing a key move the object?). Each category builds on the last.
13. Current State (2026-07-14)
tests/integration/tests/test_helpers/event_log_assertions.gdgutconfig.json at project rootrun_tests.ps1, run_tests.sh, watch_tests.ps1, watch_tests.sh at project rootThe test suite runs in ~18 seconds. It gates every push. It caught 12 regressions during active development that would have otherwise shipped to playtesters. The investment paid for itself within the first two weeks.
*Questions, corrections, or want to share your own Godot testing setup? Open an issue on the repo.*
Leave a Reply