decdb74ade
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.
439 lines
9.5 KiB
Markdown
439 lines
9.5 KiB
Markdown
# 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
|
|
|
|
1. Open your Godot project
|
|
2. Go to **Project → Project Settings → Plugins**
|
|
3. Find "Gut" and enable it
|
|
4. Restart the editor
|
|
|
|
### 2. Create Your First Test
|
|
|
|
Create a new GDScript file in a `tests/` folder:
|
|
|
|
```gdscript
|
|
# 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
|
|
|
|
```gdscript
|
|
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
|
|
|
|
```gdscript
|
|
# 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
|
|
|
|
```gdscript
|
|
# 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
|
|
|
|
```gdscript
|
|
# 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
|
|
|
|
```gdscript
|
|
# 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
|
|
|
|
```gdscript
|
|
# 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
|
|
|
|
```gdscript
|
|
# 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
|
|
|
|
```gdscript
|
|
# 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
|
|
|
|
```gdscript
|
|
# 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:
|
|
|
|
```json
|
|
{
|
|
"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 files
|
|
- `should_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 mode
|
|
- `should_print_debug`: Print debug output
|
|
- `double_strategy`: "FULL" or "PARTIAL" for mocking
|
|
|
|
## Running Tests
|
|
|
|
### In Editor
|
|
|
|
1. **Tools → GUT → Run Tests** - Runs all tests
|
|
2. **Tools → GUT → Run Tests (with options)** - Configure before running
|
|
3. **Tools → GUT → Run a specific test** - Select a test file
|
|
|
|
### Command Line
|
|
|
|
```bash
|
|
# 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:
|
|
|
|
```yaml
|
|
- 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)
|
|
|
|
```gdscript
|
|
# 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
|
|
|
|
```gdscript
|
|
# GOOD
|
|
func test_player_cannot_move_while_stunned():
|
|
pass
|
|
|
|
# AVOID
|
|
func test_stun():
|
|
pass
|
|
```
|
|
|
|
### 3. Clean Up Resources
|
|
|
|
```gdscript
|
|
func after_each():
|
|
# Always clean up nodes
|
|
if player:
|
|
player.queue_free()
|
|
|
|
# Clear any global state
|
|
GameManager.reset()
|
|
```
|
|
|
|
### 4. Test Edge Cases
|
|
|
|
```gdscript
|
|
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
|
|
|
|
```gdscript
|
|
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
|
|
|
|
```gdscript
|
|
func test_something():
|
|
gut.p("This is a debug message")
|
|
gut.p("Player health: " + str(player.health))
|
|
```
|
|
|
|
### Pause Before Teardown
|
|
|
|
```gdscript
|
|
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
|
|
|
|
1. Create `tests/` folder in your project
|
|
2. Write your first test file
|
|
3. Run tests via **Tools → GUT → Run Tests**
|
|
4. Integrate into your development workflow
|
|
5. Add tests to CI/CD pipeline
|