Integration Testing in Godot: Testing Scene Interactions and Autoloads

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

Basic 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 = 1

Testing 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 index

Testing 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 frames
func 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_each to prevent test interference
  • Use await wait_frames() and await 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.

Read more