Integration Testing in Godot: Testing Scene Interactions and Autoloads
Integration tests in Godot verify that multiple nodes, scenes, and autoloads work together correctly. Unlike unit tests that test a single script, integration tests instantiate real scenes, trigger real signals, and assert on the resulting state. GUT supports this with add_child_autoqfree(), scene instantiation helpers, and frame-await utilities.
Key Takeaways
Integration tests in Godot instantiate real scenes. You're testing the fully assembled node tree, not individual scripts. This catches configuration mistakes that unit tests miss.
Use add_child_autoqfree() instead of add_child(). GUT's add_child_autoqfree(node) automatically frees the node after the test — no manual queue_free() needed.
Autoloads are global — test carefully. Testing autoloads (singletons) in integration tests means tests can affect each other through shared global state. Reset autoload state in before_each.
await wait_frames(n) processes deferred calls. Many Godot signals and state changes are deferred to the next frame. Use await wait_frames(1) or await wait_seconds(t) to let them process.
Test the event bus pattern explicitly. If your game uses an EventBus autoload for decoupled communication, integration tests that verify pub/sub behavior across nodes are the most valuable tests you can write.
What Makes a Godot Test "Integration"?
In Godot, the distinction between unit and integration tests is practical:
- Unit test: tests a single GDScript class in isolation, no scene instantiation, no real node tree
- Integration test: instantiates a scene or multiple nodes, tests their interactions through real signals, methods, and shared state
Integration tests catch bugs that unit tests miss: incorrect node paths, missing signal connections in the scene editor, autoload interactions, and timing issues with deferred calls.
Setting Up Integration Tests
Separate integration tests into their own directory:
test/
├── unit/ # Fast, no scenes
└── integration/ # Slower, instantiates scenes
├── test_combat.gd
└── test_inventory_ui.gdBasic Scene Integration Test
# test/integration/test_player_combat.gd
extends GutTest
var arena_scene
func before_each():
# Load and instantiate the full scene
arena_scene = load("res://scenes/arena.tscn").instantiate()
add_child_autoqfree(arena_scene)
await wait_frames(2) # Allow _ready() to complete
func test_player_takes_damage_from_enemy_attack():
var player = arena_scene.get_node("Player")
var enemy = arena_scene.get_node("Enemy")
var initial_health = player.health
enemy.attack(player)
assert_lt(player.health, initial_health, "Player should lose health from enemy attack")
func test_enemy_dies_when_health_reaches_zero():
var enemy = arena_scene.get_node("Enemy")
watch_signals(arena_scene)
enemy.take_damage(enemy.max_health)
await wait_frames(1)
assert_false(is_instance_valid(enemy), "Enemy should be removed from scene when dead")
func test_player_gains_xp_when_enemy_dies():
var player = arena_scene.get_node("Player")
var enemy = arena_scene.get_node("Enemy")
var initial_xp = player.experience_points
enemy.take_damage(enemy.max_health)
await wait_frames(2) # Wait for death + XP grant signal chain
assert_gt(player.experience_points, initial_xp, "Player should gain XP for killing enemy")Testing Autoloads (Singletons)
Autoloads are global — their state persists between tests. Always reset them in before_each:
# Game uses an autoload: GameState.gd
# Accessible via GameState singleton
extends GutTest
func before_each():
# Reset global state before each test
GameState.reset() # Call your reset method
func test_score_increments_on_collect():
GameState.add_score(100)
assert_eq(GameState.score, 100, "Score should be 100")
func test_score_persists_across_actions():
GameState.add_score(50)
GameState.add_score(75)
assert_eq(GameState.score, 125, "Scores should accumulate")
func test_level_up_when_score_threshold_reached():
watch_signals(GameState)
GameState.add_score(GameState.LEVEL_UP_SCORE)
assert_signal_emitted(GameState, "level_up")
assert_eq(GameState.current_level, 2, "Should advance to level 2")If your autoload doesn't have a reset() method, add one specifically for testing:
# GameState.gd autoload
var score: int = 0
var current_level: int = 1
func reset() -> void:
score = 0
current_level = 1Testing the Event Bus Pattern
Many Godot games use an EventBus autoload for decoupled communication between nodes. This is one of the most important integration tests to write:
# EventBus.gd autoload
signal player_died(player_id: String)
signal item_collected(item_id: String, player_id: String)
signal level_completed(level_id: int)# test/integration/test_event_bus.gd
extends GutTest
func before_each():
watch_signals(EventBus)
func test_player_death_triggers_event_bus_signal():
var player = Player.new()
add_child_autoqfree(player)
player.take_damage(player.max_health)
await wait_frames(1)
assert_signal_emitted(EventBus, "player_died")
assert_signal_emitted_with_parameters(EventBus, "player_died", [player.player_id])
func test_ui_responds_to_event_bus_player_death():
var ui = load("res://ui/game_ui.tscn").instantiate()
add_child_autoqfree(ui)
EventBus.player_died.emit("player_1")
await wait_frames(1)
var game_over_screen = ui.get_node("GameOverScreen")
assert_true(game_over_screen.visible, "Game over screen should appear on player death")
func test_score_ui_updates_on_item_collect():
var ui = load("res://ui/game_ui.tscn").instantiate()
add_child_autoqfree(ui)
await wait_frames(1)
EventBus.item_collected.emit("gold_coin", "player_1")
await wait_frames(1)
var score_label = ui.get_node("ScoreLabel")
assert_ne(score_label.text, "0", "Score label should update after item collected")Testing Multi-Scene Workflows
Sometimes you need to test transitions between scenes:
extends GutTest
func test_game_over_transitions_to_main_menu():
var game_scene = load("res://scenes/game.tscn").instantiate()
add_child_autoqfree(game_scene)
await wait_frames(2)
watch_signals(game_scene)
# Trigger game over condition
game_scene.get_node("Player").take_damage(9999)
await wait_frames(5)
assert_signal_emitted(game_scene, "game_over")
func test_level_complete_loads_next_level():
var level = load("res://levels/level_1.tscn").instantiate()
add_child_autoqfree(level)
await wait_frames(2)
watch_signals(level)
level.complete()
assert_signal_emitted(level, "level_completed")
assert_signal_emitted_with_parameters(level, "level_completed", [2]) # Next level indexTesting Node Relationships
Verify scene structure and node configuration:
extends GutTest
func test_combat_scene_has_required_nodes():
var scene = load("res://scenes/combat.tscn").instantiate()
add_child_autoqfree(scene)
# Verify required node paths exist
assert_not_null(scene.get_node_or_null("Player"), "Scene must have Player node")
assert_not_null(scene.get_node_or_null("EnemySpawner"), "Scene must have EnemySpawner node")
assert_not_null(scene.get_node_or_null("UI/HealthBar"), "Scene must have HealthBar UI")
func test_player_health_bar_connected_to_player_signals():
var scene = load("res://scenes/combat.tscn").instantiate()
add_child_autoqfree(scene)
await wait_frames(1)
var player = scene.get_node("Player")
var health_bar = scene.get_node("UI/HealthBar")
# Trigger health change and verify UI updates
player.take_damage(20)
await wait_frames(1)
# Health bar value should reflect player's current health
assert_eq(health_bar.value, player.health)Timing and Frame Management
Deferred calls, physics, and animations require frame processing:
# Different waiting strategies:
# Wait a fixed number of frames
await wait_frames(3)
# Wait a fixed duration
await wait_seconds(0.5)
# Wait for a specific signal (with timeout)
await wait_for_signal(some_node.some_signal, 5.0)
# Wait for physics frame (for CharacterBody2D, RigidBody3D)
await get_tree().physics_frame
await get_tree().physics_frame # Wait 2 physics framesfunc test_physics_body_falls_under_gravity():
var body = load("res://scenes/falling_object.tscn").instantiate()
add_child_autoqfree(body)
await wait_frames(1)
var initial_y = body.position.y
await wait_seconds(0.5) # Let gravity work
assert_gt(body.position.y, initial_y, "Object should fall under gravity")Performance: Keeping Integration Tests Fast
Integration tests are slower by nature, but you can control the overhead:
# SLOW: Load scene in every test
func before_each():
scene = load("res://scenes/complex.tscn").instantiate()
# FASTER: Load resource once, instantiate per test
var _scene_resource = preload("res://scenes/complex.tscn")
func before_each():
scene = _scene_resource.instantiate()
add_child_autoqfree(scene)For very expensive scenes, use a class-level fixture:
# Note: shared state can cause test interference — use carefully
var _shared_scene # Class level, not reset per test
func before_all():
_shared_scene = load("res://scenes/expensive.tscn").instantiate()
add_child(_shared_scene)
func after_all():
_shared_scene.queue_free()
func before_each():
# Reset shared scene state instead of recreating it
_shared_scene.reset()Summary
Integration tests in Godot verify the assembled game, not individual scripts:
- Instantiate real scenes with
add_child_autoqfree() - Reset autoload state in
before_eachto prevent test interference - Use
await wait_frames()andawait wait_seconds()for deferred behavior - Test the event bus — it's the nervous system of your game architecture
- Verify scene structure with
get_node_or_null()assertions
Start integration tests with your most complex scene — the one with the most signal connections and node interactions. That's where integration bugs hide.