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 \
-gexitGitHub 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.xmlCommon 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.