Bump export_presets.cfg version to 2.3.5. Update CHANGELOG_DRAFT.md. Refactor lobby.gd into LobbyChat, LobbyMainMenu, LobbyRoomList, LobbyRoom. Move Nakama config to environment variables in nakama_manager.gd. Derive auth_manager.gd encryption key from OS.get_unique_id().sha256_text(). Remove Steam email auth fallback. Require auth ticket. Make GachaManager.pull() async in gacha_panel.gd. Remove dummy wallet seeding. Add store_type to IAP payload. Validate IAP receipts server-side in economy.lua. Register gacha module in main.lua. Clean backend_service.gd stubs. Fix featured_banners type safety in gacha_manager.gd. Guards non-array responses. Move tiles_armagedon_a1.res to assets/models/meshes/. Fix import fallback_path.
9.5 KiB
GUT (Godot Unit Testing) - Setup & Usage Guide
Overview
GUT is a unit testing framework for Godot that allows you to write and run automated tests for your GDScript code. It's already installed in your project at addons/gut/.
Version: 9.6.0
Author: Butch Wesley
Documentation: https://gut.readthedocs.io
Quick Start
1. Enable GUT Plugin
- Open your Godot project
- Go to Project → Project Settings → Plugins
- Find "Gut" and enable it
- Restart the editor
2. Create Your First Test
Create a new GDScript file in a tests/ folder:
# tests/test_example.gd
extends GutTest
func test_addition():
assert_eq(2 + 2, 4, "2 + 2 should equal 4")
func test_string_comparison():
assert_eq("hello", "hello", "Strings should match")
3. Run Tests
- In Editor: Go to Tools → GUT → Run Tests
- From Command Line:
godot --headless -s addons/gut/gut_cmdln.gd
GUT Test Structure
Lifecycle Methods
extends GutTest
# Runs once before all tests in this script
func before_all():
pass
# Runs before each test
func before_each():
pass
# Runs after each test
func after_each():
pass
# Runs once after all tests in this script
func after_all():
pass
# Test methods must start with "test_"
func test_something():
pass
Test Naming Convention
- Test methods must start with
test_ - Use descriptive names:
test_player_takes_damage(),test_invalid_input_rejected() - Each test should test ONE thing
Common Assertions
Equality Assertions
# Assert equal
assert_eq(actual, expected, "Optional message")
assert_eq(2 + 2, 4)
# Assert not equal
assert_ne(actual, expected, "Optional message")
assert_ne(1, 2)
# Assert true/false
assert_true(condition, "Optional message")
assert_false(condition, "Optional message")
Type Assertions
# Assert is instance of type
assert_is(obj, Node, "Should be a Node")
# Assert has method
assert_has_method(obj, "method_name")
# Assert has property
assert_has_property(obj, "property_name")
Collection Assertions
# Assert array contains value
assert_has(array, value, "Array should contain value")
# Assert array does not contain value
assert_does_not_have(array, value)
# Assert array is empty
assert_is_empty(array)
# Assert array is not empty
assert_is_not_empty(array)
String Assertions
# Assert string contains substring
assert_string_contains(string, substring)
# Assert string starts with
assert_string_starts_with(string, prefix)
# Assert string ends with
assert_string_ends_with(string, suffix)
Signal Assertions
# Watch a signal
watch_signals(object)
# Assert signal was emitted
assert_signal_emitted(object, "signal_name")
# Assert signal was emitted with arguments
assert_signal_emitted_with_arguments(object, "signal_name", [arg1, arg2])
# Assert signal was not emitted
assert_signal_not_emitted(object, "signal_name")
Project Structure for Tests
Recommended folder structure:
tekton-enet/
├── addons/
│ └── gut/
├── scripts/
│ ├── player.gd
│ ├── enemy.gd
│ └── game_manager.gd
├── tests/
│ ├── test_player.gd
│ ├── test_enemy.gd
│ ├── test_game_manager.gd
│ └── unit/
│ ├── test_math_utils.gd
│ └── test_string_utils.gd
└── project.godot
Example Test Files
Testing a Simple Class
# tests/test_player.gd
extends GutTest
var player: Node
func before_each():
# Create a fresh player instance for each test
player = preload("res://scripts/player.gd").new()
func after_each():
# Clean up
if player:
player.queue_free()
func test_player_starts_with_100_health():
assert_eq(player.health, 100, "Player should start with 100 health")
func test_player_takes_damage():
player.take_damage(25)
assert_eq(player.health, 75, "Player health should be 75 after taking 25 damage")
func test_player_dies_at_zero_health():
player.health = 0
assert_true(player.is_dead(), "Player should be dead at 0 health")
func test_player_cannot_have_negative_health():
player.take_damage(200)
assert_true(player.health >= 0, "Player health should never be negative")
Testing with Signals
# tests/test_game_manager.gd
extends GutTest
var game_manager: Node
func before_each():
game_manager = preload("res://scripts/game_manager.gd").new()
add_child(game_manager)
func after_each():
game_manager.queue_free()
func test_game_start_emits_signal():
watch_signals(game_manager)
game_manager.start_game()
assert_signal_emitted(game_manager, "game_started")
func test_game_over_emits_with_winner():
watch_signals(game_manager)
game_manager.end_game("Player1")
assert_signal_emitted_with_arguments(game_manager, "game_ended", ["Player1"])
Testing with Mocking/Stubbing
# tests/test_enemy.gd
extends GutTest
var enemy: Node
func before_each():
enemy = preload("res://scripts/enemy.gd").new()
func test_enemy_calls_attack_on_player():
# Create a mock player
var mock_player = double(preload("res://scripts/player.gd"))
# Have enemy attack the mock player
enemy.attack(mock_player)
# Verify the mock player's take_damage was called
assert_called(mock_player, "take_damage")
GUT Configuration
gutconfig.json
Create gutconfig.json in your project root to configure GUT:
{
"dirs": ["res://tests"],
"should_exit": true,
"log_level": 1,
"ignore_pause_before_teardown": false,
"should_print_debug": false,
"double_strategy": "FULL"
}
Configuration Options:
dirs: Directories to search for test filesshould_exit: Exit after tests complete (useful for CI/CD)log_level: 0 (failures only), 1 (tests + failures), 2 (all asserts)ignore_pause_before_teardown: Ignore pause calls in batch modeshould_print_debug: Print debug outputdouble_strategy: "FULL" or "PARTIAL" for mocking
Running Tests
In Editor
- Tools → GUT → Run Tests - Runs all tests
- Tools → GUT → Run Tests (with options) - Configure before running
- Tools → GUT → Run a specific test - Select a test file
Command Line
# Run all tests
godot --headless -s addons/gut/gut_cmdln.gd
# Run specific test directory
godot --headless -s addons/gut/gut_cmdln.gd -d res://tests
# Run with specific log level
godot --headless -s addons/gut/gut_cmdln.gd -l 2
# Export results to JUnit XML (for CI/CD)
godot --headless -s addons/gut/gut_cmdln.gd -o results.xml
CI/CD Integration
For GitHub Actions:
- name: Run GUT Tests
run: |
godot --headless -s addons/gut/gut_cmdln.gd \
-d res://tests \
-o test-results.xml
Best Practices
1. One Assertion Per Test (When Possible)
# GOOD - Each test tests one thing
func test_player_health_decreases():
player.take_damage(10)
assert_eq(player.health, 90)
func test_player_dies_at_zero():
player.health = 0
assert_true(player.is_dead())
# AVOID - Multiple assertions in one test
func test_player_damage():
player.take_damage(10)
assert_eq(player.health, 90)
assert_false(player.is_dead())
player.take_damage(100)
assert_true(player.is_dead())
2. Use Descriptive Test Names
# GOOD
func test_player_cannot_move_while_stunned():
pass
# AVOID
func test_stun():
pass
3. Clean Up Resources
func after_each():
# Always clean up nodes
if player:
player.queue_free()
# Clear any global state
GameManager.reset()
4. Test Edge Cases
func test_negative_damage_heals_player():
player.take_damage(-10)
assert_eq(player.health, 110)
func test_damage_exceeding_max_health():
player.take_damage(-200)
assert_eq(player.health, 100) # Should cap at max
5. Use before_each for Setup
func before_each():
# Create fresh instances for each test
player = preload("res://scripts/player.gd").new()
enemy = preload("res://scripts/enemy.gd").new()
add_child(player)
add_child(enemy)
Debugging Tests
Print Debug Info
func test_something():
gut.p("This is a debug message")
gut.p("Player health: " + str(player.health))
Pause Before Teardown
func test_something():
assert_eq(1, 1)
gut.pause_before_teardown("Pausing to inspect state")
Run Single Test
In the GUT GUI, right-click a test and select "Run" to run just that test.
Common Issues & Solutions
Issue: Tests not found
Solution: Ensure test files are in directories specified in gutconfig.json and file names start with test_.
Issue: Signals not being detected
Solution: Call watch_signals(object) before emitting the signal.
Issue: Nodes not being freed
Solution: Always call queue_free() in after_each() or use add_child() so GUT can track them.
Issue: Tests pass individually but fail together
Solution: Check for shared state. Use before_each() to reset state between tests.
Resources
- Official Docs: https://gut.readthedocs.io
- GitHub: https://github.com/bitwes/Gut
- Examples: Check
addons/gut/examples/for sample tests
Next Steps
- Create
tests/folder in your project - Write your first test file
- Run tests via Tools → GUT → Run Tests
- Integrate into your development workflow
- Add tests to CI/CD pipeline