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 falseextends 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.