Testing Godot Games at Scale: 73 Integration Tests, CI, and Watch Mode

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:

  • State machines. Mission flow: if step A completes, does step B activate? Is the mission_finished signal emitted after the last step? These are pure logic with zero visual dependency.
  • Signal wiring. Does ship_destroyed actually emit when health hits zero? Do 15 nodes across 4 scenes all connect to the right signals?
  • Physics parameters. Are grav clamp spring stiffness values positive? Does the camera offset put the camera above the player? These are data integrity checks that prevent silent misconfiguration.
  • Input chains. If the player presses 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.
  • Scene structure. Does every player variant (P1, P2) inherit a 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 offset
  • Isometric offset validation: camera Y must be > 0 (above player), offset must be non-zero
  • Raycast visibility from camera position to player body center
  • Dynamic split-screen: single-player camera follows at player.position + camera_offset, dual-player cameras diverge when players exceed max_separation
  • Space camera activate/deactivate lifecycle – starts inactive, snaps near target on activation
  • Camera basis computation: forward vector -z must point downward (isometric look direction)
  • 4.2 test_combat_chain.gd – Enemy Health, Projectiles, Gun Firing

    9 tests covering:

  • Enemy health initialization (positive health, equals max health at spawn)
  • Damage reduces health by exact amount
  • Lethal damage triggers zero-or-below health
  • ship_destroyed signal emits on enemy death
  • Projectile instantiation and collision against enemy hitbox
  • fired signal emits when gun.fire() is called
  • Gunner plate input forwarding: ui_right rotates gun aim angle
  • FirstLiftCoordinator tracks active enemies and emits enemy_wave_cleared when _active_enemies empties
  • 4.3 test_docking_system.gd – Grav Clamps, Kinematic Clamps, Bay Doors

    11 tests covering:

  • Grav clamp child structure: AlignmentPointA, AlignmentPointB, DetectionArea must exist
  • Spring stiffness, damping coefficient, detection distance, max force – all must be > 0
  • Target body resolution: when parented under a RigidBody3D, target_body resolves to the parent
  • Detection area monitoring enabled and monitorable
  • Force application lifecycle on RigidBody3D parent
  • Kinematic clamp alignment points and valid linear_pull_speed
  • Bay door open/close lifecycle: starts closed, door_opened and door_closed signals emit
  • Bay door toggle: open?closed?open transitions
  • BayDoorController finds children and open_all() opens every door
  • 4.4 test_event_logging.gd – NarrationSystem, MissionDirector Signals

    9 tests covering:

  • Autoload detection via Engine.has_singleton("NarrationSystem") (headless-safe)
  • NarrationSystem signals: message_started, objective_updated
  • MissionDirector signals: step_activated, step_completed, mission_started, mission_finished
  • Step activation emits step_activated with correct step_id
  • complete_step() marks step completed, marks it inactive, emits step_completed
  • ship_destroyed signal existence and manual emission on BaseShip
  • health_changed signal declaration on BaseShip
  • SpaceEnemySpawner signals: wave_spawned, all_enemies_cleared
  • 4.5 test_gunner_seat.gd – Locking, Rotation, Firing, Cooldown, Exit

    7 tests covering:

  • Player lock to gunner plate: is_on_plate(), current_plate, locked_player references
  • Gun rotation responds to set_aim_angle() or direct rotation_degrees modification
  • Fire input processed via Input.action_press("fire") and projectile group tracking
  • Rapid fire cooldown doesn’t crash (system stability test)
  • Player exit via ui_accept (jump): is_on_plate() returns false, locked_player nulled
  • Gun stops rotating when player exits mid-rotation
  • Highlight system active when player is on plate
  • 4.6 test_mission_flow.gd – Step Chain, Objective Text, next_step_ids

    6 tests covering:

  • 9 mission steps loaded: report_to_irix ? all_crew_onboard ? launch_ship ? calm_flight ? destroy_attackers ? dock_with_relay_buoy ? load_cargo ? return_to_outpost ? deliver_cargo
  • First step starts active, not completed
  • complete_step() on step N activates step N+1, deactivates step N
  • Full chain completion: all 9 steps complete in order, mission_finished signal emits
  • Out-of-order completion: completing an inactive step still marks it completed, doesn’t disrupt active step
  • Objective text differs between steps
  • next_step_ids chain validation: every step’s successors match expected graph
  • 4.7 test_player_animation.gd – Limb Pivots, Walk/Idle, Gait Phase

    6 tests covering:

  • Animation node existence: CharacterAnimPlayer, LeftLegPivot, RightLegPivot, LeftArmPivot, RightArmPivot – all instantiated on ready
  • Idle animation locks all four limbs at rotation.x ? 0
  • Walk animation plays during forward input: current_animation == "walk", speed_scale > 0.1
  • Legs oscillate out of phase during walk (at least one limb has non-zero rotation)
  • Arms swing opposite to legs: left_leg.rotation.x * left_arm.rotation.x <= 0 (natural gait)
  • Returns to idle when movement stops: animation resets, all limbs return to zero
  • 4.8 test_player_interactions.gd – Station Switching, Range, Movement

    7 tests covering:

  • Station infrastructure: pressure plate and gunner plate both exist in test scene
  • Interaction range: player far from plates ? is_on_plate() is false
  • Movement input: ui_up on ship deck produces position change
  • Collision layer verification: player on layer 4 (Players), mask includes layer 16 (Interactables)
  • Crate carrying infrastructure: carried_object or carried_crate property exists, crate scene loadable
  • Ship area detection: enter_ship_area() sets current_ship reference
  • Signal wiring: locked_to_plate and unlocked_from_plate signals emit correctly
  • 4.9 test_pressure_plate_control.gd – Locking, Ship Input, Stopping

    6 tests covering:

  • Player locks to pressure plate: is_on_plate() true, current_plate reference, locked_player reference
  • Ship responds to input: ui_up while locked ? linear_velocity changes
  • Ship stops when player jumps off: velocity decreases after ui_accept exit
  • Player position tracks plate during control
  • Multiple plates on same ship: plate 2 has no player while plate 1 is occupied, switching works
  • is_piloted flag toggles correctly on lock/unlock

  • 5. 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)

  • Godot 4.5.1 + GUT 9.6.0
  • 73 tests, 201 assertions, 0 failures
  • 9 test scripts in tests/integration/
  • 1 helper in tests/test_helpers/event_log_assertions.gd
  • gutconfig.json at project root
  • run_tests.ps1, run_tests.sh, watch_tests.ps1, watch_tests.sh at project root
  • All tests verified passing headless on Windows and Linux
  • The 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.*


    Posted

    in

    by

    Tags:

    Comments

    Leave a Reply

    Your email address will not be published. Required fields are marked *