12 KiB
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
- Testing Philosophy
- Test Organization
- Test Categories
- Writing Tests
- Debugging Failed Tests
- 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
@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
@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
@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
@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
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:
# 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:
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:
# 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:
# 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:
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:
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:
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:
# 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
@tooldirective 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 (
@toolfunctions) - 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.