GUT Advanced: Mocking, Signals, and Async Testing in Godot 4

GUT Advanced: Mocking, Signals, and Async Testing in Godot 4

GUT (Godot Unit Test) is the de-facto testing framework for Godot projects, and Godot 4 brought significant changes to GDScript that affect how you write and structure tests. If you've gone through the GUT getting-started tutorials and written basic assert_eq tests, this guide covers the advanced layer: mock objects, signal assertions, async/await integration, parametrize, and partial doubles.

GUT 9.x for Godot 4

Make sure you're on GUT 9.x, which supports Godot 4's new type system, lambdas, and await. Install via the Asset Library or add directly to addons/gut/.

res://
  addons/
    gut/
  test/
    unit/
      test_weapon.gd
      test_inventory.gd
    integration/
      test_game_loop.gd
  .gutconfig.json

.gutconfig.json:

{
  "dirs": ["res://test/"],
  "prefix": "test_",
  "suffix": ".gd",
  "include_subdirs": true,
  "log_level": 2,
  "gut_on_ready": true
}

Double Objects

A "double" in GUT is a test-safe copy of a script or scene that you can stub and spy on. Unlike a full mock, a double preserves the original implementation unless you explicitly stub a method.

# test_player_controller.gd
extends GutTest

var player_double

func before_each():
    # Double the script — preserves real implementation
    player_double = double(preload("res://src/player/PlayerController.gd")).new()
    add_child_autofree(player_double)

func test_take_damage_reduces_health():
    player_double.health = 100
    player_double.take_damage(25)
    assert_eq(player_double.health, 75, "Health should reduce by damage amount")

func test_take_damage_calls_play_hurt_animation():
    # Stub the animation player call
    stub(player_double, "play_animation").to_call(func(anim_name): pass)

    player_double.take_damage(10)

    # Verify the stub was called with correct argument
    assert_called(player_double, "play_animation")
    assert_call_count(player_double, "play_animation", 1)

Scene Doubles

For nodes that live in a scene tree, use double_scene:

func before_each():
    var EnemyScene = load("res://src/enemies/Goblin.tscn")
    var doubled_scene = double_scene(EnemyScene)
    enemy = doubled_scene.instantiate()
    add_child_autofree(enemy)

func test_goblin_attacks_when_player_nearby():
    stub(enemy, "get_distance_to_player").to_return(2.0)
    enemy._process(0.1)
    assert_called(enemy, "perform_attack")

Partial Doubles

A partial double wraps a real instance, letting you stub only specific methods while the rest run normally:

func test_inventory_save_calls_file_system():
    var real_inventory = Inventory.new()
    real_inventory.add_item("sword", 1)

    # Partial double — real logic runs except stubbed methods
    var partial = partial_double(real_inventory)
    stub(partial, "write_to_disk").to_call(func(path, data):
        # capture what would be written instead of actually writing
        _captured_save_data = data
    )

    partial.save("user://save.json")

    assert_eq(_captured_save_data["items"]["sword"], 1)
    assert_not_called(partial, "write_to_disk")  # wait — this would fail
    # Use assert_called when you expect the stub WAS called:
    assert_called(partial, "write_to_disk")

Signal Watching

Godot's signal system is central to game architecture. GUT's signal watching lets you assert that signals were (or weren't) emitted during a test.

func test_player_emits_died_signal_at_zero_health():
    var player = Player.new()
    add_child_autofree(player)

    # Start watching before the action
    watch_signals(player)

    player.health = 1
    player.take_damage(100)

    assert_signal_emitted(player, "died")
    assert_signal_emit_count(player, "died", 1)

func test_player_does_not_emit_died_when_alive():
    var player = Player.new()
    add_child_autofree(player)
    watch_signals(player)

    player.health = 100
    player.take_damage(10)

    assert_signal_not_emitted(player, "died")

func test_level_up_emits_with_correct_level():
    var character = Character.new()
    add_child_autofree(character)
    watch_signals(character)

    character.gain_experience(1000) # enough for level 5

    # Assert signal emitted with specific arguments
    assert_signal_emitted_with_parameters(character, "level_changed", [5])

Waiting for Signals in Async Tests

When a signal is emitted asynchronously (after a timer, after an await), combine signal watching with await:

func test_respawn_signal_fires_after_delay():
    var spawner = Spawner.new()
    add_child_autofree(spawner)
    watch_signals(spawner)

    spawner.trigger_respawn()

    # Wait for the signal with a timeout
    await wait_for_signal(spawner.respawn_ready, 3.0)

    assert_signal_emitted(spawner, "respawn_ready")

Async/Await Testing

Godot 4's await keyword enables coroutine-style async code in GDScript. GUT 9.x supports async test functions natively.

func test_async_data_load():
    var loader = DataLoader.new()
    add_child_autofree(loader)

    # await inside tests works in GUT 9.x
    var result = await loader.load_config("res://data/config.json")

    assert_not_null(result)
    assert_eq(result.version, "1.0")

func test_animation_completes():
    var animator = CharacterAnimator.new()
    add_child_autofree(animator)
    watch_signals(animator)

    animator.play("death")

    # Wait up to 2 seconds for the animation to complete
    await wait_for_signal(animator.animation_finished, 2.0)

    assert_signal_emitted(animator, "animation_finished")
    assert_false(animator.is_playing())

Simulating Frames

For code that depends on _process or _physics_process, use simulate:

func test_projectile_moves_forward():
    var projectile = Projectile.new()
    add_child_autofree(projectile)
    projectile.velocity = Vector3(0, 0, -10)
    var start_pos = projectile.position

    # Simulate 10 frames at 60fps delta
    simulate(projectile, 10, 1.0/60.0)

    assert_gt(start_pos.z - projectile.position.z, 1.0, "Projectile should have moved forward")

Parametrize

Avoid copy-paste tests for the same logic with different inputs using use_parameters:

func test_damage_calculation(params = use_parameters([
    [100, 10, 0, 90],    # [health, damage, armor, expected]
    [100, 10, 5, 95],
    [100, 50, 10, 60],
    [10,  20, 0, 0],     # overkill clamps to 0
])):
    var health = params[0]
    var damage = params[1]
    var armor  = params[2]
    var expected = params[3]

    var result = DamageCalculator.calculate(health, damage, armor)
    assert_eq(result, expected,
        "health=%d damage=%d armor=%d should yield %d" % [health, damage, armor, expected])

This generates four separate test cases, each named and reported individually in the GUT output.

Mocking External Dependencies

For systems that have side effects (file I/O, HTTP requests, OS calls), create mock classes in your test directory:

# test/mocks/MockHttpClient.gd
class_name MockHttpClient
extends Node

var _queued_responses = []
var _requests_made = []

func queue_response(status_code: int, body: Dictionary):
    _queued_responses.append({"status": status_code, "body": body})

func request(url: String, headers: Array = [], method: int = 0, body: String = "") -> Dictionary:
    _requests_made.append({"url": url, "method": method, "body": body})
    if _queued_responses.is_empty():
        return {"status": 500, "body": {}}
    return _queued_responses.pop_front()

func get_request_count() -> int:
    return _requests_made.size()

func get_last_request() -> Dictionary:
    return _requests_made.back() if not _requests_made.is_empty() else {}
# Usage in test
func test_leaderboard_fetches_from_api():
    var mock_http = MockHttpClient.new()
    mock_http.queue_response(200, {"scores": [{"player": "Alice", "score": 9999}]})

    var leaderboard = Leaderboard.new()
    leaderboard.http_client = mock_http
    add_child_autofree(leaderboard)

    await leaderboard.refresh()

    assert_eq(leaderboard.get_top_player(), "Alice")
    assert_eq(mock_http.get_request_count(), 1)
    assert_eq(mock_http.get_last_request()["url"], "https://api.mygame.com/leaderboard")

CI Integration

Run GUT from the command line for headless CI:

# Run all tests, output JUnit XML
godot --headless --path . \
  -s addons/gut/gut_cmdln.gd \
  -gdir=res://test/ \
  -gprefix=test_ \
  -gsuffix=.gd \
  -gjunit_xml_file=TestResults/results.xml \
  -gexit

GitHub Actions workflow:

name: Godot Tests
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    container:
      image: barichello/godot-ci:4.2.1
    steps:
      - uses: actions/checkout@v3

      - name: Import project
        run: godot --headless --import

      - name: Run GUT Tests
        run: |
          godot --headless --path . \
            -s addons/gut/gut_cmdln.gd \
            -gdir=res://test/ -gjunit_xml_file=results.xml -gexit
        continue-on-error: true

      - name: Publish Results
        uses: mikepenz/action-junit-report@v3
        if: always()
        with:
          report_paths: results.xml

Common Gotchas in Godot 4

Autoloads in tests. Autoloads are initialized before your test script runs. If your system under test calls GlobalState.something, that autoload exists in tests too — which may cause unexpected behavior. Stub autoload methods on the double if you need isolation.

RefCounted vs Node. RefCounted objects (formerly Reference) don't need free() — they're garbage collected. Node objects need add_child_autofree(obj) or explicit obj.free() in after_each. Leaking nodes accumulates across tests and causes false failures.

Signal connections persist. If a test connects a signal without disconnecting in after_each, the next test may receive that signal unexpectedly. Always disconnect in teardown or use add_child_autofree which handles cleanup automatically.

Conclusion

GUT's advanced features — doubles, partial doubles, signal watching, async test support, and parametrize — cover the full surface area of Godot 4 game logic. The key discipline is keeping tests fast (no real timers, no real file I/O, no real HTTP) and isolated (each test starts with a clean slate). With that foundation and CI running on every push, you turn Godot's signal-driven architecture from a testing challenge into a testable strength.

Read more