Files
YeetGeese/AI Docs/04-TESTING-WORKFLOW.md
letteka c36afc75e5
Validate Project / validate (push) Successful in 16s
Moving documents to their own folder.
2026-09-04 09:24:34 -07:00

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

  1. Testing Philosophy
  2. Test Organization
  3. Test Categories
  4. Writing Tests
  5. Debugging Failed Tests
  6. 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 @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.