23 KiB
23 KiB
Godot GUT Testing Guide
Overview
GUT (Godot Unit Test) is the standard unit testing framework for Godot. It provides a full-featured testing framework with assertions, mocking, and CI integration.
Installation
Via Asset Library
- Open AssetLib in Godot
- Search for "GUT"
- Download and install
- Enable the plugin in Project Settings
Via Git Submodule
git submodule add https://github.com/bitwes/Gut.git addons/gut
Project Structure
project/
├── addons/
│ └── gut/
├── src/
│ ├── player/
│ │ └── player.gd
│ └── combat/
│ └── damage_calculator.gd
└── tests/
├── unit/
│ └── test_damage_calculator.gd
└── integration/
└── test_player_combat.gd
Basic Test Structure
Simple Test Class
# tests/unit/test_damage_calculator.gd
extends GutTest
var calculator: DamageCalculator
func before_each():
calculator = DamageCalculator.new()
func after_each():
calculator.free()
func test_calculate_base_damage():
var result = calculator.calculate(100.0, 1.0)
assert_eq(result, 100.0, "Base damage should equal input")
func test_calculate_critical_hit():
var result = calculator.calculate(100.0, 2.0)
assert_eq(result, 200.0, "Critical hit should double damage")
func test_calculate_with_zero_multiplier():
var result = calculator.calculate(100.0, 0.0)
assert_eq(result, 0.0, "Zero multiplier should result in zero damage")
Parameterized Tests
func test_damage_scenarios():
var scenarios = [
{"base": 100.0, "mult": 1.0, "expected": 100.0},
{"base": 100.0, "mult": 2.0, "expected": 200.0},
{"base": 50.0, "mult": 1.5, "expected": 75.0},
{"base": 0.0, "mult": 2.0, "expected": 0.0},
]
for scenario in scenarios:
var result = calculator.calculate(scenario.base, scenario.mult)
assert_eq(
result,
scenario.expected,
"Base %s * %s should equal %s" % [
scenario.base, scenario.mult, scenario.expected
]
)
Testing Nodes
Scene Testing
# tests/integration/test_player.gd
extends GutTest
var player: Player
var player_scene = preload("res://src/player/player.tscn")
func before_each():
player = player_scene.instantiate()
add_child(player)
func after_each():
player.queue_free()
func test_player_initial_health():
assert_eq(player.health, 100, "Player should start with 100 health")
func test_player_takes_damage():
player.take_damage(30)
assert_eq(player.health, 70, "Health should be reduced by damage")
func test_player_dies_at_zero_health():
player.take_damage(100)
assert_true(player.is_dead, "Player should be dead at 0 health")
Testing with Signals
func test_damage_emits_signal():
watch_signals(player)
player.take_damage(10)
assert_signal_emitted(player, "health_changed")
assert_signal_emit_count(player, "health_changed", 1)
func test_death_emits_signal():
watch_signals(player)
player.take_damage(100)
assert_signal_emitted(player, "died")
Testing with Await
func test_attack_cooldown():
player.attack()
assert_true(player.is_attacking)
# Wait for cooldown
await get_tree().create_timer(player.attack_cooldown).timeout
assert_false(player.is_attacking)
assert_true(player.can_attack)
Mocking and Doubles
Creating Doubles
func test_enemy_uses_pathfinding():
var mock_pathfinding = double(Pathfinding).new()
stub(mock_pathfinding, "find_path").to_return([Vector2(0, 0), Vector2(10, 10)])
var enemy = Enemy.new()
enemy.pathfinding = mock_pathfinding
enemy.move_to(Vector2(10, 10))
assert_called(mock_pathfinding, "find_path")
Partial Doubles
func test_player_inventory():
var player_double = partial_double(Player).new()
stub(player_double, "save_to_disk").to_do_nothing()
player_double.add_item("sword")
assert_eq(player_double.inventory.size(), 1)
assert_called(player_double, "save_to_disk")
Physics Testing
Testing Collision
func test_projectile_hits_enemy():
var projectile = Projectile.new()
var enemy = Enemy.new()
add_child(projectile)
add_child(enemy)
projectile.global_position = Vector2(0, 0)
enemy.global_position = Vector2(100, 0)
projectile.velocity = Vector2(200, 0)
# Simulate physics frames
for i in range(60):
await get_tree().physics_frame
assert_true(enemy.was_hit, "Enemy should be hit by projectile")
projectile.queue_free()
enemy.queue_free()
Testing Area2D
func test_pickup_collected():
var pickup = Pickup.new()
var player = player_scene.instantiate()
add_child(pickup)
add_child(player)
pickup.global_position = Vector2(50, 50)
player.global_position = Vector2(50, 50)
# Wait for physics to process overlap
await get_tree().physics_frame
await get_tree().physics_frame
assert_true(pickup.is_queued_for_deletion(), "Pickup should be collected")
player.queue_free()
Input Testing
Simulating Input
func test_jump_on_input():
var input_event = InputEventKey.new()
input_event.keycode = KEY_SPACE
input_event.pressed = true
Input.parse_input_event(input_event)
await get_tree().process_frame
player._unhandled_input(input_event)
assert_true(player.is_jumping, "Player should jump on space press")
Testing Input Actions
func test_attack_action():
# Simulate action press
Input.action_press("attack")
await get_tree().process_frame
player._process(0.016)
assert_true(player.is_attacking)
Input.action_release("attack")
Resource Testing
Testing Custom Resources
func test_weapon_stats_resource():
var weapon = WeaponStats.new()
weapon.base_damage = 10.0
weapon.attack_speed = 2.0
assert_eq(weapon.dps, 20.0, "DPS should be damage * speed")
func test_save_load_resource():
var original = PlayerData.new()
original.level = 5
original.gold = 1000
ResourceSaver.save(original, "user://test_save.tres")
var loaded = ResourceLoader.load("user://test_save.tres")
assert_eq(loaded.level, 5)
assert_eq(loaded.gold, 1000)
DirAccess.remove_absolute("user://test_save.tres")
GUT Configuration
gut_config.json
{
"dirs": ["res://tests/"],
"include_subdirs": true,
"prefix": "test_",
"suffix": ".gd",
"should_exit": true,
"should_exit_on_success": true,
"log_level": 1,
"junit_xml_file": "results.xml",
"font_size": 16
}
CI Integration
Command Line Execution
# Run all tests
godot --headless -s addons/gut/gut_cmdln.gd
# Run specific tests
godot --headless -s addons/gut/gut_cmdln.gd \
-gdir=res://tests/unit \
-gprefix=test_
# With JUnit output
godot --headless -s addons/gut/gut_cmdln.gd \
-gjunit_xml_file=results.xml
GitHub Actions
test:
runs-on: ubuntu-latest
container:
image: barichello/godot-ci:4.2
steps:
- uses: actions/checkout@v4
- name: Run Tests
run: |
godot --headless -s addons/gut/gut_cmdln.gd \
-gjunit_xml_file=results.xml
- name: Publish Results
uses: mikepenz/action-junit-report@v4
with:
report_paths: results.xml
Best Practices
DO
- Use
before_each/after_eachfor setup/teardown - Free nodes after tests to prevent leaks
- Use meaningful assertion messages
- Group related tests in the same file
- Use
watch_signalsfor signal testing - Await physics frames when testing physics
DON'T
- Don't test Godot's built-in functionality
- Don't rely on execution order between test files
- Don't leave orphan nodes
- Don't use
yield(useawaitin Godot 4) - Don't test private methods directly
Troubleshooting
| Issue | Cause | Fix |
|---|---|---|
| Tests not found | Wrong prefix/path | Check gut_config.json |
| Orphan nodes warning | Missing cleanup | Add queue_free() in after_each |
| Signal not detected | Signal not watched | Call watch_signals() before action |
| Physics not working | Missing frames | Await physics_frame |
| Flaky tests | Timing issues | Use proper await/signals |
C# Testing in Godot
Godot 4 supports C# via .NET 6+. You can use standard .NET testing frameworks alongside GUT.
Project Setup for C#
project/
├── addons/
│ └── gut/
├── src/
│ ├── Player/
│ │ └── PlayerController.cs
│ └── Combat/
│ └── DamageCalculator.cs
├── tests/
│ ├── gdscript/
│ │ └── test_integration.gd
│ └── csharp/
│ ├── Tests.csproj
│ └── DamageCalculatorTests.cs
└── project.csproj
C# Test Project Setup
Create a separate test project that references your game assembly:
<!-- tests/csharp/Tests.csproj -->
<Project Sdk="Godot.NET.Sdk/4.2.0">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<EnableDynamicLoading>true</EnableDynamicLoading>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.6.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.4" />
<PackageReference Include="NSubstitute" Version="5.1.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../project.csproj" />
</ItemGroup>
</Project>
Basic C# Unit Tests
// tests/csharp/DamageCalculatorTests.cs
using Xunit;
using YourGame.Combat;
public class DamageCalculatorTests
{
private readonly DamageCalculator _calculator;
public DamageCalculatorTests()
{
_calculator = new DamageCalculator();
}
[Fact]
public void Calculate_BaseDamage_ReturnsCorrectValue()
{
var result = _calculator.Calculate(100f, 1f);
Assert.Equal(100f, result);
}
[Fact]
public void Calculate_CriticalHit_DoublesDamage()
{
var result = _calculator.Calculate(100f, 2f);
Assert.Equal(200f, result);
}
[Theory]
[InlineData(100f, 0.5f, 50f)]
[InlineData(100f, 1.5f, 150f)]
[InlineData(50f, 2f, 100f)]
public void Calculate_Parameterized_ReturnsExpected(
float baseDamage, float multiplier, float expected)
{
var result = _calculator.Calculate(baseDamage, multiplier);
Assert.Equal(expected, result);
}
}
Testing Godot Nodes in C#
For tests requiring Godot runtime, use a hybrid approach:
// tests/csharp/PlayerControllerTests.cs
using Godot;
using Xunit;
using YourGame.Player;
public class PlayerControllerTests : IDisposable
{
private readonly SceneTree _sceneTree;
private PlayerController _player;
public PlayerControllerTests()
{
// These tests must run within Godot runtime
// Use GodotXUnit or similar adapter
}
[GodotFact] // Custom attribute for Godot runtime tests
public async Task Player_Move_ChangesPosition()
{
var startPos = _player.GlobalPosition;
_player.SetInput(new Vector2(1, 0));
await ToSignal(GetTree().CreateTimer(0.5f), "timeout");
Assert.True(_player.GlobalPosition.X > startPos.X);
}
public void Dispose()
{
_player?.QueueFree();
}
}
C# Mocking with NSubstitute
using NSubstitute;
using Xunit;
public class EnemyAITests
{
[Fact]
public void Enemy_UsesPathfinding_WhenMoving()
{
var mockPathfinding = Substitute.For<IPathfinding>();
mockPathfinding.FindPath(Arg.Any<Vector2>(), Arg.Any<Vector2>())
.Returns(new[] { Vector2.Zero, new Vector2(10, 10) });
var enemy = new EnemyAI(mockPathfinding);
enemy.MoveTo(new Vector2(10, 10));
mockPathfinding.Received().FindPath(
Arg.Any<Vector2>(),
Arg.Is<Vector2>(v => v == new Vector2(10, 10)));
}
}
Running C# Tests
# Run C# unit tests (no Godot runtime needed)
dotnet test tests/csharp/Tests.csproj
# Run with coverage
dotnet test tests/csharp/Tests.csproj --collect:"XPlat Code Coverage"
# Run specific test
dotnet test tests/csharp/Tests.csproj --filter "FullyQualifiedName~DamageCalculator"
Hybrid Test Strategy
| Test Type | Framework | When to Use |
|---|---|---|
| Pure logic | xUnit/NUnit (C#) | Classes without Godot dependencies |
| Node behavior | GUT (GDScript) | MonoBehaviour-like testing |
| Integration | GUT (GDScript) | Scene and signal testing |
| E2E | GUT (GDScript) | Full gameplay flows |
End-to-End Testing
For comprehensive E2E testing patterns, infrastructure scaffolding, and scenario builders, see knowledge/e2e-testing.md.
E2E Infrastructure for Godot
GameE2ETestFixture (GDScript)
# tests/e2e/infrastructure/game_e2e_test_fixture.gd
extends GutTest
class_name GameE2ETestFixture
var game_state: GameStateManager
var input_sim: InputSimulator
var scenario: ScenarioBuilder
var _scene_instance: Node
## Override to specify a different scene for specific test classes.
func get_scene_path() -> String:
return "res://scenes/game.tscn"
func before_each():
# Load game scene
var scene = load(get_scene_path())
_scene_instance = scene.instantiate()
add_child(_scene_instance)
# Get references
game_state = _scene_instance.get_node("GameStateManager")
assert_not_null(game_state, "GameStateManager not found in scene")
input_sim = InputSimulator.new()
scenario = ScenarioBuilder.new(game_state)
# Wait for ready
await wait_for_game_ready()
func after_each():
if _scene_instance:
_scene_instance.queue_free()
_scene_instance = null
input_sim = null
scenario = null
func wait_for_game_ready(timeout: float = 10.0):
var elapsed = 0.0
while not game_state.is_ready and elapsed < timeout:
await get_tree().process_frame
elapsed += get_process_delta_time()
assert_true(game_state.is_ready, "Game should be ready within timeout")
ScenarioBuilder (GDScript)
# tests/e2e/infrastructure/scenario_builder.gd
extends RefCounted
class_name ScenarioBuilder
var _game_state: GameStateManager
var _setup_actions: Array[Callable] = []
func _init(game_state: GameStateManager):
_game_state = game_state
## Load a pre-configured scenario from a save file.
func from_save_file(file_name: String) -> ScenarioBuilder:
_setup_actions.append(func(): await _load_save_file(file_name))
return self
## Configure the current turn number.
func on_turn(turn_number: int) -> ScenarioBuilder:
_setup_actions.append(func(): _set_turn(turn_number))
return self
## Spawn a unit at position.
func with_unit(faction: int, position: Vector2, movement_points: int = 6) -> ScenarioBuilder:
_setup_actions.append(func(): await _spawn_unit(faction, position, movement_points))
return self
## Execute all configured setup actions.
func build() -> void:
for action in _setup_actions:
await action.call()
_setup_actions.clear()
## Clear pending actions without executing.
func reset() -> void:
_setup_actions.clear()
# Private implementation
func _load_save_file(file_name: String) -> void:
var path = "res://tests/e2e/test_data/%s" % file_name
await _game_state.load_game(path)
func _set_turn(turn: int) -> void:
_game_state.set_turn_number(turn)
func _spawn_unit(faction: int, pos: Vector2, mp: int) -> void:
var unit = _game_state.spawn_unit(faction, pos)
unit.movement_points = mp
InputSimulator (GDScript)
# tests/e2e/infrastructure/input_simulator.gd
extends RefCounted
class_name InputSimulator
## Click at a world position.
func click_world_position(world_pos: Vector2) -> void:
var viewport = Engine.get_main_loop().root.get_viewport()
var camera = viewport.get_camera_2d()
var screen_pos = camera.get_screen_center_position() + (world_pos - camera.global_position)
await click_screen_position(screen_pos)
## Click at a screen position.
func click_screen_position(screen_pos: Vector2) -> void:
var press = InputEventMouseButton.new()
press.button_index = MOUSE_BUTTON_LEFT
press.pressed = true
press.position = screen_pos
var release = InputEventMouseButton.new()
release.button_index = MOUSE_BUTTON_LEFT
release.pressed = false
release.position = screen_pos
Input.parse_input_event(press)
await Engine.get_main_loop().process_frame
Input.parse_input_event(release)
await Engine.get_main_loop().process_frame
## Click a UI button by name.
func click_button(button_name: String) -> void:
var root = Engine.get_main_loop().root
var button = _find_button_recursive(root, button_name)
assert(button != null, "Button '%s' not found in scene tree" % button_name)
if not button.visible:
push_warning("[InputSimulator] Button '%s' is not visible" % button_name)
if button.disabled:
push_warning("[InputSimulator] Button '%s' is disabled" % button_name)
button.pressed.emit()
await Engine.get_main_loop().process_frame
func _find_button_recursive(node: Node, button_name: String) -> Button:
if node is Button and node.name == button_name:
return node
for child in node.get_children():
var found = _find_button_recursive(child, button_name)
if found:
return found
return null
## Press and release a key.
func press_key(keycode: Key) -> void:
var press = InputEventKey.new()
press.keycode = keycode
press.pressed = true
var release = InputEventKey.new()
release.keycode = keycode
release.pressed = false
Input.parse_input_event(press)
await Engine.get_main_loop().process_frame
Input.parse_input_event(release)
await Engine.get_main_loop().process_frame
## Simulate an input action.
func action_press(action_name: String) -> void:
Input.action_press(action_name)
await Engine.get_main_loop().process_frame
func action_release(action_name: String) -> void:
Input.action_release(action_name)
await Engine.get_main_loop().process_frame
## Reset all input state.
func reset() -> void:
Input.flush_buffered_events()
AsyncAssert (GDScript)
# tests/e2e/infrastructure/async_assert.gd
extends RefCounted
class_name AsyncAssert
## Wait until condition is true, or fail after timeout.
static func wait_until(
condition: Callable,
description: String,
timeout: float = 5.0
) -> void:
var elapsed := 0.0
while not condition.call() and elapsed < timeout:
await Engine.get_main_loop().process_frame
elapsed += Engine.get_main_loop().root.get_process_delta_time()
assert(condition.call(),
"Timeout after %.1fs waiting for: %s" % [timeout, description])
## Wait for a value to equal expected.
static func wait_for_value(
getter: Callable,
expected: Variant,
description: String,
timeout: float = 5.0
) -> void:
await wait_until(
func(): return getter.call() == expected,
"%s to equal '%s' (current: '%s')" % [description, expected, getter.call()],
timeout)
## Wait for a float value within tolerance.
static func wait_for_value_approx(
getter: Callable,
expected: float,
description: String,
tolerance: float = 0.0001,
timeout: float = 5.0
) -> void:
await wait_until(
func(): return absf(expected - getter.call()) < tolerance,
"%s to equal ~%s ±%s (current: %s)" % [description, expected, tolerance, getter.call()],
timeout)
## Assert that condition does NOT become true within duration.
static func assert_never_true(
condition: Callable,
description: String,
duration: float = 1.0
) -> void:
var elapsed := 0.0
while elapsed < duration:
assert(not condition.call(),
"Condition unexpectedly became true: %s" % description)
await Engine.get_main_loop().process_frame
elapsed += Engine.get_main_loop().root.get_process_delta_time()
## Wait for specified number of frames.
static func wait_frames(count: int) -> void:
for i in range(count):
await Engine.get_main_loop().process_frame
## Wait for physics to settle.
static func wait_for_physics(frames: int = 3) -> void:
for i in range(frames):
await Engine.get_main_loop().root.get_tree().physics_frame
Example E2E Test (GDScript)
# tests/e2e/scenarios/test_combat_flow.gd
extends GameE2ETestFixture
func test_player_can_attack_enemy():
# GIVEN: Player and enemy in combat range
await scenario \
.with_unit(Faction.PLAYER, Vector2(100, 100)) \
.with_unit(Faction.ENEMY, Vector2(150, 100)) \
.build()
var enemy = game_state.get_units(Faction.ENEMY)[0]
var initial_health = enemy.health
# WHEN: Player attacks
await input_sim.click_world_position(Vector2(100, 100)) # Select player
await AsyncAssert.wait_until(
func(): return game_state.selected_unit != null,
"Unit should be selected")
await input_sim.click_world_position(Vector2(150, 100)) # Attack enemy
# THEN: Enemy takes damage
await AsyncAssert.wait_until(
func(): return enemy.health < initial_health,
"Enemy should take damage")
func test_turn_cycle_completes():
# GIVEN: Game in progress
await scenario.on_turn(1).build()
var starting_turn = game_state.turn_number
# WHEN: Player ends turn
await input_sim.click_button("EndTurnButton")
await AsyncAssert.wait_until(
func(): return game_state.current_faction == Faction.ENEMY,
"Should switch to enemy turn")
# AND: Enemy turn completes
await AsyncAssert.wait_until(
func(): return game_state.current_faction == Faction.PLAYER,
"Should return to player turn",
30.0) # AI might take a while
# THEN: Turn number incremented
assert_eq(game_state.turn_number, starting_turn + 1)
Quick E2E Checklist for Godot
- Create
GameE2ETestFixturebase class extending GutTest - Implement
ScenarioBuilderfor your game's domain - Create
InputSimulatorwrapping Godot Input - Add
AsyncAssertutilities with proper await - Organize E2E tests under
tests/e2e/scenarios/ - Configure GUT to include E2E test directory
- Set up CI with headless Godot execution