456 lines
12 KiB
Markdown
456 lines
12 KiB
Markdown
# Testing Workflow Guide for YeetGeese
|
|
|
|
This document provides a structured approach to testing Godot 4.x games, with specific focus on the tower defense mechanics of YeetGeese. It covers what to test, how to structure tests, and debugging failed tests.
|
|
|
|
---
|
|
|
|
## Table of Contents
|
|
|
|
1. [Testing Philosophy](#testing-philosophy)
|
|
2. [Test Organization](#test-organization)
|
|
3. [Test Categories](#test-categories)
|
|
4. [Writing Tests](#writing-tests)
|
|
5. [Debugging Failed Tests](#debugging-failed-tests)
|
|
6. [CI Integration](#ci-integration)
|
|
|
|
---
|
|
|
|
## Testing Philosophy
|
|
|
|
### Why Test?
|
|
|
|
- **Regression prevention:** Catch unintended side effects from changes
|
|
- **Documentation by example:** Tests serve as usage examples
|
|
- **Design validation:** Ensure architecture matches intent
|
|
- **Confidence for refactoring:** Safe to reorganize code when tested
|
|
|
|
### What AI Can Help With
|
|
|
|
AI can:
|
|
- Generate boilerplate test scaffolding
|
|
- Suggest edge cases and boundary conditions
|
|
- Write mock implementations for complex systems
|
|
- Explain Godot testing framework capabilities
|
|
|
|
### What AI Cannot Handle
|
|
|
|
AI should NOT:
|
|
- Design game mechanics or balance (human domain)
|
|
- Make subjective quality-of-life judgments
|
|
- Replace manual playtesting of actual gameplay feel
|
|
- Generate test data for art assets (no AI-generated art rule applies)
|
|
|
|
---
|
|
|
|
## Test Organization
|
|
|
|
### Directory Structure
|
|
|
|
```
|
|
tests/
|
|
├── core/ # Autoloads and global state tests
|
|
│ ├── wave_progression_test.gd
|
|
│ └── currency_manager_test.gd
|
|
├── towers/ # Tower behavior tests
|
|
│ ├── tower_base_test.gd
|
|
│ ├── projectile_spawner_test.gd
|
|
│ └── target_finder_test.gd
|
|
├── enemies/ # Enemy system tests
|
|
│ ├── enemy_mob_test.gd
|
|
│ ├── navigation_system_test.gd
|
|
│ └── spawner_test.gd
|
|
├── projectiles/ # Projectile handling tests
|
|
│ ├── goose_projectile_test.gd
|
|
│ └── impact_handler_test.gd
|
|
├── ui/ # HUD and menu tests
|
|
│ ├── health_bar_test.gd
|
|
│ └── currency_display_test.gd
|
|
├── utils/ # Utility functions
|
|
│ └── math_utils_test.gd
|
|
└── integration/ # Cross-system tests
|
|
├── wave_complete_sequence_test.gd
|
|
└── tower_placement_integration_test.gd
|
|
```
|
|
|
|
### Naming Conventions
|
|
|
|
- Files: `<system>_test.gd` (e.g., `tower_base_test.gd`)
|
|
- Functions: `func test_<behavior_description>()` (e.g., `func test_fire_rate_scales_with_level()`)
|
|
- Assertions: Use descriptive names like `assert_equal`, `assert_signal_emitted`, `assert_no_error`
|
|
|
|
---
|
|
|
|
## Test Categories
|
|
|
|
### Unit Tests
|
|
|
|
Test individual functions or methods in isolation.
|
|
|
|
**Example:** Testing a tower's fire rate calculation
|
|
|
|
```gdscript
|
|
@tool
|
|
extends Node
|
|
|
|
func test_fire_rate_calculation(tower: Node) -> void:
|
|
assert_equal(tower.fire_rate, 1.0)
|
|
|
|
# Modify tower level
|
|
tower.tower_level = 2
|
|
|
|
# Verify fire rate changed appropriately
|
|
assert_greater(tower.fire_rate, 1.0)
|
|
assert_less(tower.fire_rate, 2.0)
|
|
|
|
func test_damage_scaling_by_tower_level(tower: Node) -> void:
|
|
tower.tower_level = 3
|
|
|
|
var initial_damage = tower.get_damage_at_current_level()
|
|
tower.tower_level = 5
|
|
|
|
# Verify damage increased with level
|
|
assert_greater(tower.get_damage_at_current_level(), initial_damage)
|
|
```
|
|
|
|
**When to use:**
|
|
- Testing pure logic (math, state transitions)
|
|
- Testing Resource loading and property access
|
|
- Testing signal emission patterns
|
|
|
|
---
|
|
|
|
### Integration Tests
|
|
|
|
Test interactions between multiple systems.
|
|
|
|
**Example:** Testing the full projectile launch sequence
|
|
|
|
```gdscript
|
|
@tool
|
|
extends Node
|
|
|
|
func test_projectile_launch_sequence() -> void:
|
|
# Arrange: Create mock tower and target
|
|
var tower := _create_mock_tower()
|
|
var target := _create_mock_target()
|
|
|
|
# Act: Fire at target
|
|
tower.fire_at(target)
|
|
|
|
# Assert: Verify projectile spawned in correct state
|
|
assert_true(tower.projectile_count == 1)
|
|
assert_equal(tower.projectiles[0].source_node, tower.$ProjectileSpawner)
|
|
```
|
|
|
|
**When to use:**
|
|
- Testing scene hierarchies with multiple nodes
|
|
- Verifying signal connections between systems
|
|
- Validating Godot's built-in component behavior (e.g., RigidBody2D movement)
|
|
|
|
---
|
|
|
|
### Regression Tests
|
|
|
|
Tests added when a bug is fixed to ensure it doesn't return.
|
|
|
|
**Example:** Preventing physics tunneling after a collision fix
|
|
|
|
```gdscript
|
|
@tool
|
|
extends Node
|
|
|
|
func test_projectile_collision_prevents_tunneling() -> void:
|
|
# Scenario: Goose projectile moving at high speed
|
|
var projectile := _create_goose_with_velocity(100.0)
|
|
var enemy := _create_enemy_with_small_hitbox()
|
|
|
|
# Act: Move projectile toward enemy's hitbox
|
|
projectile.yeet_from(projectile.position, enemy.position)
|
|
|
|
# Assert: Projectile detected collision on this frame
|
|
assert_signal_emitted(projectile, 'collision_detected')
|
|
```
|
|
|
|
**When to use:**
|
|
- After fixing a critical bug
|
|
- When performance regression occurs
|
|
- To validate edge case handling
|
|
|
|
---
|
|
|
|
## Writing Tests
|
|
|
|
### Test Function Template
|
|
|
|
```gdscript
|
|
@tool
|
|
extends Node
|
|
|
|
func test_<behavior_under_test>() -> void:
|
|
# Arrange: Setup the scenario
|
|
var <subject> := _create_mock_<subject>()
|
|
|
|
# Act: Execute the action being tested
|
|
<subject>.<method_or_event>()
|
|
|
|
# Assert: Verify expected behavior
|
|
assert_true(<condition_1>)
|
|
assert_false(<condition_2>)
|
|
assert_signal_emitted(<signal_source>, '<signal_name>')
|
|
```
|
|
|
|
### Available Assertions
|
|
|
|
| Assertion | Purpose | Example |
|
|
|-----------|---------|---------|
|
|
| `assert_equal(a, b)` | Check exact equality | `assert_equal(tower.level, 3)` |
|
|
| `assert_not_equal(a, b)` | Check inequality | `assert_not_equal(projectile.speed, 0.0)` |
|
|
| `assert_true(condition)` | Verify boolean is true | `assert_true(tower.is_ready())` |
|
|
| `assert_false(condition)` | Verify boolean is false | `assert_false(projectile.is_removed())` |
|
|
| `assert_greater(a, b)` | Check a > b | `assert_greater(damage_dealt, 10)` |
|
|
| `assert_less(a, b)` | Check a < b | `assert_less(enemy.health, 50.0)` |
|
|
| `assert_null(obj)` | Verify object is null | `assert_null(projectile.get_target())` |
|
|
| `assert_not_null(obj)` | Verify object exists | `assert_not_null(projectile)` |
|
|
| `assert_array_size(arr, size)` | Check array length | `assert_array_size(tower.projectiles, 4)` |
|
|
|
|
### Signal Testing Pattern
|
|
|
|
```gdscript
|
|
var _signal_verified = false
|
|
|
|
func test_health_changed_signal_emits_correct_values() -> void:
|
|
var enemy := EnemyMob.new()
|
|
enemy.health_changed.connect(func(new_health) {
|
|
assert_true(_signal_verified == false) # Prevent double-firing
|
|
assert_equal(new_health, 75.0)
|
|
_signal_verified = true
|
|
})
|
|
|
|
enemy.take_damage(25.0)
|
|
|
|
assert_true(_signal_verified)
|
|
```
|
|
|
|
### Mock Object Helpers
|
|
|
|
Use these helper functions to create mock objects for tests:
|
|
|
|
```gdscript
|
|
# Create a mock tower with minimal setup
|
|
func _create_mock_tower() -> Node:
|
|
var tower := TowerBase.new()
|
|
tower.tower_level = 1
|
|
return tower
|
|
|
|
# Create a mock enemy for collision testing
|
|
func _create_mock_enemy() -> EnemyMob:
|
|
var enemy := EnemyMob.new()
|
|
enemy.health = 50.0
|
|
enemy.damage_taken.connect(func() { pass }) # Prevent death on damage
|
|
return enemy
|
|
|
|
# Verify no error occurred during test
|
|
func _assert_no_error(condition: bool) -> void:
|
|
assert_true(condition, "Test completed with errors")
|
|
```
|
|
|
|
---
|
|
|
|
## Debugging Failed Tests
|
|
|
|
### Common Failure Patterns
|
|
|
|
#### 1. Test Completes Before Signal Fires
|
|
|
|
**Problem:** `signal_emitted` verification fails because the test ends too early.
|
|
|
|
**Solution:** Add a yield or use Godot's built-in coroutine system:
|
|
|
|
```gdscript
|
|
func test_signal_fires_after_delay() -> void:
|
|
var subject := _create_subject()
|
|
subject.event.triggered.connect(_on_event_triggered)
|
|
|
|
subject.do_something()
|
|
|
|
# Ensure signal fires before test ends
|
|
get_tree().process_frame.connect(_on_frame_processed)
|
|
yield(get_tree(), "process_frame") # Wait for signal
|
|
|
|
assert_true(_signal_received)
|
|
|
|
func _on_frame_processed() -> void:
|
|
_on_event_triggered.call_deferred()
|
|
```
|
|
|
|
#### 2. Scene Loading in Tests Fails
|
|
|
|
**Problem:** Godot scenes loaded via `load("res://...")` may not find their resources.
|
|
|
|
**Solution:** Use absolute paths or ensure test assets are in proper location:
|
|
|
|
```gdscript
|
|
# Bad: Relative path that breaks in tests
|
|
var mob_scene := load("enemy_mob.tscn").instantiate()
|
|
|
|
# Good: Absolute resource path
|
|
var mob_scene := load("res://src/enemies/enemy_mob.tscn").instantiate()
|
|
```
|
|
|
|
#### 3. Random Behavior Causes Flaky Tests
|
|
|
|
**Problem:** Tests pass sometimes but fail others due to randomness (e.g., particle emission).
|
|
|
|
**Solution:** Disable randomness in test environment:
|
|
|
|
```gdscript
|
|
# At start of test, seed RNG for reproducibility
|
|
Rand.randi = func() -> int: return 42 # Fixed value for testing
|
|
|
|
# Or disable randomness for specific systems
|
|
var projectile := Projectile.new()
|
|
projectile.random_variation_enabled = false
|
|
```
|
|
|
|
#### 4. Physics Tunneling in Collision Tests
|
|
|
|
**Problem:** Fast-moving projectiles miss collisions when tested with static enemies.
|
|
|
|
**Solution:** Use multiple physics frames or smaller time steps:
|
|
|
|
```gdscript
|
|
func test_high_velocity_collision_detection() -> void:
|
|
var projectile := _create_goose_with_velocity(200.0)
|
|
|
|
# Run multiple physics frames for collision detection
|
|
for _ in range(5):
|
|
get_tree().physics_ticks_processed = 1
|
|
get_tree().process_frame()
|
|
|
|
assert_true(_collision_detected)
|
|
```
|
|
|
|
#### 5. Signal Connections Leak Between Tests
|
|
|
|
**Problem:** Test A's signal handlers remain active when Test B runs.
|
|
|
|
**Solution:** Disconnect signals at test end:
|
|
|
|
```gdscript
|
|
func cleanup_test(subject: Node, signal_name: String) -> void:
|
|
subject.signal_removed.connect(_disconnect_signals)
|
|
|
|
# Clear all signals from subject
|
|
var signals = subject.get_signal_list()
|
|
for s in signals:
|
|
if s.is_connected(func):
|
|
s.disconnect(func)
|
|
|
|
# Call cleanup at end of each test function
|
|
```
|
|
|
|
---
|
|
|
|
## CI Integration (Optional for Indie Teams)
|
|
|
|
### GitHub Actions / GitLab CI Setup
|
|
|
|
If using a continuous integration server, add tests to build pipeline.
|
|
|
|
**Example workflow:**
|
|
|
|
```yaml
|
|
name: YeetGeese Tests
|
|
|
|
on: [push, pull_request]
|
|
|
|
jobs:
|
|
test:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@v3
|
|
|
|
- name: Install Godot
|
|
run: |
|
|
# Download and install Godot 4.x stable
|
|
|
|
- name: Run Tests
|
|
run: |
|
|
godot --path . --headless tests/run_all_tests.gd
|
|
```
|
|
|
|
### Local Test Runner Script
|
|
|
|
Create a script to run all tests from command line:
|
|
|
|
```gdscript
|
|
# tests/run_all_tests.gd
|
|
extends Node
|
|
|
|
func _ready():
|
|
var result := 0
|
|
|
|
# Run unit tests
|
|
for file in DirAccess.get_directories() if dir_name == "core" or dir_name == "towers":
|
|
pass # Implementation depends on test runner framework
|
|
|
|
if result == 0:
|
|
print("All tests passed!")
|
|
else:
|
|
error("Some tests failed")
|
|
|
|
func _exit_tree():
|
|
quit(0)
|
|
```
|
|
|
|
---
|
|
|
|
## Testing Checklist
|
|
|
|
### Before Writing Tests for New Feature
|
|
|
|
- [ ] Identify the system(s) being tested
|
|
- [ ] Determine unit vs. integration testing needs
|
|
- [ ] List expected inputs and outputs
|
|
- [ ] Identify edge cases (empty arrays, null objects, etc.)
|
|
|
|
### Test Coverage Goals
|
|
|
|
| System Type | Recommended Test Coverage |
|
|
|-------------|---------------------------|
|
|
| Core game loop | 100% (critical path) |
|
|
| Tower logic | 90%+ |
|
|
| Enemy AI states | 85%+ |
|
|
| UI displays | 80%+ |
|
|
| Utility functions | 95%+ |
|
|
|
|
### Review Checklist for AI-Generated Tests
|
|
|
|
When asking AI to generate tests, verify:
|
|
|
|
- [ ] Uses `@tool` directive for editor-only execution
|
|
- [ ] Sets up proper mock objects for isolation
|
|
- [ ] Disconnects signals at test end to prevent leaks
|
|
- [ ] Uses absolute paths for resource loading
|
|
- [ ] Handles expected error conditions gracefully
|
|
- [ ] Includes assertions for all critical behaviors
|
|
|
|
---
|
|
|
|
## Summary
|
|
|
|
**AI can help with:**
|
|
- Generating boilerplate test scaffolding
|
|
- Suggesting edge cases and boundary conditions
|
|
- Writing mock implementations for complex systems
|
|
- Explaining Godot testing framework capabilities
|
|
|
|
**Remember:**
|
|
- Tests should be runnable in editor (`@tool` functions)
|
|
- Keep tests focused on one behavior per function
|
|
- Disconnect signals to prevent cross-test contamination
|
|
- Use absolute paths for resource loading in tests
|
|
- Document test intent with clear, descriptive names
|
|
|
|
For more details, see `AI_HELP.md` for prompt templates when working with AI assistants on testing tasks.
|