8.5 KiB
8.5 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 |