GDScript Test Automation with GUT: Mocks, Signals, and Scene Tests

GDScript Test Automation with GUT: Mocks, Signals, and Scene Tests

Beyond basic assertions, GUT provides mock objects, spy capabilities, and signal testing that let you isolate and verify complex game behavior. This guide covers GUT's advanced features: creating doubles (mocks/stubs), asserting method calls, testing signal emissions, and testing scene interactions in GDScript.

Key Takeaways

GUT doubles (mocks) use double() — not a separate mock library. double(ClassName) creates a test double of any script class. Methods return null by default; stub them with stub().

Signal testing requires watch_signals() first. Before asserting a signal was emitted, call watch_signals(node). GUT then intercepts all signals from that node for the remainder of the test.

Use stub() to control return values. stub(double_instance, "method_name").to_return(42) makes the method return 42 when called. This is how you isolate dependencies.

assert_called() and assert_not_called() verify method calls. Test that your code calls the right methods on its dependencies without caring about the implementation.

Partial doubles let you spy on real objects. partial_double(instance) wraps a real object — real methods run, but you can assert they were called. Good for observing interactions without changing behavior.

GUT Doubles (Mocks and Stubs)

GUT uses the term "doubles" for what other frameworks call mocks. A double replaces a real class in tests, letting you control its behavior and verify interactions.

Creating a Double

# scripts/audio_manager.gd
extends Node

func play_sfx(sound_name: String) -> void:
    # Plays sound effect
    pass

func play_music(track: String) -> void:
    pass
# test/test_player_with_audio.gd
extends GutTest

var player
var audio_double

func before_each():
    audio_double = double(AudioManager).new()
    player = Player.new()
    player.audio = audio_double
    add_child(player)

func after_each():
    player.queue_free()

func test_player_plays_hurt_sound_on_damage():
    player.take_damage(10)
    assert_called(audio_double, "play_sfx")

func test_player_plays_correct_hurt_sound():
    player.take_damage(10)
    assert_called(audio_double, "play_sfx", ["hurt"])

func test_player_does_not_play_music_on_damage():
    player.take_damage(10)
    assert_not_called(audio_double, "play_music")

Stubbing Return Values

# scripts/save_manager.gd
extends Node

func load_save() -> Dictionary:
    # Reads from disk — expensive in tests
    return {}

func has_save() -> bool:
    return false
extends GutTest

var save_manager_double
var game_manager

func before_each():
    save_manager_double = double(SaveManager).new()
    game_manager = GameManager.new()
    game_manager.save_manager = save_manager_double

func test_continues_from_save_when_save_exists():
    stub(save_manager_double, "has_save").to_return(true)
    stub(save_manager_double, "load_save").to_return({
        "level": 3,
        "health": 75,
        "position": Vector2(100, 200)
    })

    game_manager.start_game()

    assert_eq(game_manager.current_level, 3)
    assert_eq(game_manager.player_health, 75)

func test_starts_new_game_when_no_save():
    stub(save_manager_double, "has_save").to_return(false)

    game_manager.start_game()

    assert_eq(game_manager.current_level, 1)

Call Count Assertions

func test_player_plays_footstep_sound_per_step():
    # Simulate 3 steps
    player.step()
    player.step()
    player.step()

    # Should play footstep exactly 3 times
    assert_call_count(audio_double, "play_sfx", 3)

Signal Testing

Signals are central to Godot's architecture. GUT provides watch_signals() to intercept and assert on signal emissions.

Basic Signal Assertion

# scripts/player.gd
extends CharacterBody2D

signal health_changed(new_health: int)
signal player_died

func take_damage(amount: int) -> void:
    health = max(0, health - amount)
    health_changed.emit(health)
    if health == 0:
        player_died.emit()
extends GutTest

var player

func before_each():
    player = Player.new()
    add_child(player)
    watch_signals(player)  # Must call BEFORE the action that emits the signal

func after_each():
    player.queue_free()

func test_health_changed_signal_emitted_on_damage():
    player.take_damage(20)
    assert_signal_emitted(player, "health_changed")

func test_health_changed_signal_has_correct_value():
    player.take_damage(20)
    assert_signal_emitted_with_parameters(player, "health_changed", [80])

func test_player_died_signal_emitted_at_zero_health():
    player.take_damage(100)
    assert_signal_emitted(player, "player_died")

func test_player_died_not_emitted_while_alive():
    player.take_damage(50)
    assert_signal_not_emitted(player, "player_died")

func test_health_changed_signal_count():
    player.take_damage(10)
    player.take_damage(10)
    player.take_damage(10)
    assert_signal_emit_count(player, "health_changed", 3)

Testing Signals with await

For async signals or signals that require frame processing:

func test_delayed_signal_emitted():
    watch_signals(player)
    player.trigger_delayed_action()  # Emits signal after 1 second

    await wait_seconds(1.1)

    assert_signal_emitted(player, "action_completed")

Partial Doubles (Spies)

Partial doubles wrap real objects — real methods execute, but you can assert they were called:

extends GutTest

func test_inventory_calls_save_on_item_add():
    var inventory = partial_double(Inventory).new()
    inventory.add_item("sword")

    # Real add_item logic ran, but we can verify it called save
    assert_called(inventory, "save_to_disk")

Testing Scene Interactions

For integration-style tests that involve multiple nodes:

extends GutTest

var scene

func before_each():
    scene = load("res://scenes/combat_arena.tscn").instantiate()
    add_child(scene)

func after_each():
    scene.queue_free()

func test_enemy_spawner_creates_enemies_on_start():
    var spawner = scene.get_node("EnemySpawner")
    watch_signals(spawner)

    spawner.start_wave(1)
    await wait_frames(5)

    var enemies = scene.get_tree().get_nodes_in_group("enemies")
    assert_gt(enemies.size(), 0, "Wave 1 should spawn enemies")

func test_player_attacks_reduce_enemy_health():
    var player = scene.get_node("Player")
    var enemy = scene.get_node("EnemySpawner/Enemy")

    var initial_health = enemy.health
    player.attack(enemy)

    assert_lt(enemy.health, initial_health, "Attack should reduce enemy health")

func test_all_enemies_defeated_triggers_wave_complete():
    var spawner = scene.get_node("EnemySpawner")
    watch_signals(spawner)

    # Kill all enemies
    for enemy in scene.get_tree().get_nodes_in_group("enemies"):
        enemy.take_damage(9999)

    await wait_frames(2)

    assert_signal_emitted(spawner, "wave_complete")

Using before_each for Complex Setup

For tests that need multiple collaborators:

extends GutTest

var player
var enemy
var game_world

func before_each():
    game_world = load("res://scenes/test_world.tscn").instantiate()
    add_child(game_world)

    player = game_world.get_node("Player")
    enemy = double(Enemy).new()
    game_world.add_child(enemy)

    watch_signals(player)
    watch_signals(enemy)

func after_each():
    game_world.queue_free()

func test_player_kills_enemy_gains_xp():
    var initial_xp = player.experience
    enemy.health = 1
    player.attack(enemy)

    assert_gt(player.experience, initial_xp, "Killing enemy should grant XP")

Common GUT Advanced Patterns

Testing Autoloads

Autoloads (singletons) are tricky to test because they're global. Use partial doubles:

func test_game_event_calls_analytics():
    # Replace the global autoload with a double for this test
    var analytics_double = double(Analytics).new()
    Engine.set_meta("analytics", analytics_double)

    GameEvents.player_died()

    assert_called(analytics_double, "track_death")

    Engine.remove_meta("analytics")

Testing Input-Driven Behavior

func test_player_jumps_on_jump_input():
    watch_signals(player)

    # Simulate jump input
    var event = InputEventAction.new()
    event.action = "jump"
    event.pressed = true
    Input.parse_input_event(event)

    await wait_frames(2)

    assert_signal_emitted(player, "jumped")

Summary

GUT's advanced features cover the full range of game testing scenarios:

  • Doubles: isolate dependencies, test in controlled environments
  • Stubs: control what dependencies return in specific scenarios
  • Signal testing: verify the event-driven communication in your game
  • Partial doubles: spy on real objects without replacing behavior
  • Scene tests: integration-style tests for multi-node interactions

Signal testing and doubles are the two capabilities teams use most after they outgrow basic assertions. Add watch_signals() calls to your existing tests first — you'll likely find signals that should be tested but aren't.

Read more