BMAD-METHOD/src/modules/bmgd/gametest/knowledge/godot-testing.md

8.5 KiB

Godot GUT Testing Guide

Overview

GUT (Godot Unit Test) is the standard unit testing framework for Godot. It provides a full-featured testing framework with assertions, mocking, and CI integration.

Installation

Via Asset Library

  1. Open AssetLib in Godot
  2. Search for "GUT"
  3. Download and install
  4. Enable the plugin in Project Settings

Via Git Submodule

git submodule add https://github.com/bitwes/Gut.git addons/gut

Project Structure

project/
├── addons/
│   └── gut/
├── src/
│   ├── player/
│   │   └── player.gd
│   └── combat/
│       └── damage_calculator.gd
└── tests/
    ├── unit/
    │   └── test_damage_calculator.gd
    └── integration/
        └── test_player_combat.gd

Basic Test Structure

Simple Test Class

# tests/unit/test_damage_calculator.gd
extends GutTest

var calculator: DamageCalculator

func before_each():
    calculator = DamageCalculator.new()

func after_each():
    calculator.free()

func test_calculate_base_damage():
    var result = calculator.calculate(100.0, 1.0)
    assert_eq(result, 100.0, "Base damage should equal input")

func test_calculate_critical_hit():
    var result = calculator.calculate(100.0, 2.0)
    assert_eq(result, 200.0, "Critical hit should double damage")

func test_calculate_with_zero_multiplier():
    var result = calculator.calculate(100.0, 0.0)
    assert_eq(result, 0.0, "Zero multiplier should result in zero damage")

Parameterized Tests

func test_damage_scenarios():
    var scenarios = [
        {"base": 100.0, "mult": 1.0, "expected": 100.0},
        {"base": 100.0, "mult": 2.0, "expected": 200.0},
        {"base": 50.0, "mult": 1.5, "expected": 75.0},
        {"base": 0.0, "mult": 2.0, "expected": 0.0},
    ]

    for scenario in scenarios:
        var result = calculator.calculate(scenario.base, scenario.mult)
        assert_eq(
            result,
            scenario.expected,
            "Base %s * %s should equal %s" % [
                scenario.base, scenario.mult, scenario.expected
            ]
        )

Testing Nodes

Scene Testing

# tests/integration/test_player.gd
extends GutTest

var player: Player
var player_scene = preload("res://src/player/player.tscn")

func before_each():
    player = player_scene.instantiate()
    add_child(player)

func after_each():
    player.queue_free()

func test_player_initial_health():
    assert_eq(player.health, 100, "Player should start with 100 health")

func test_player_takes_damage():
    player.take_damage(30)
    assert_eq(player.health, 70, "Health should be reduced by damage")

func test_player_dies_at_zero_health():
    player.take_damage(100)
    assert_true(player.is_dead, "Player should be dead at 0 health")

Testing with Signals

func test_damage_emits_signal():
    watch_signals(player)

    player.take_damage(10)

    assert_signal_emitted(player, "health_changed")
    assert_signal_emit_count(player, "health_changed", 1)

func test_death_emits_signal():
    watch_signals(player)

    player.take_damage(100)

    assert_signal_emitted(player, "died")

Testing with Await

func test_attack_cooldown():
    player.attack()
    assert_true(player.is_attacking)

    # Wait for cooldown
    await get_tree().create_timer(player.attack_cooldown).timeout

    assert_false(player.is_attacking)
    assert_true(player.can_attack)

Mocking and Doubles

Creating Doubles

func test_enemy_uses_pathfinding():
    var mock_pathfinding = double(Pathfinding).new()
    stub(mock_pathfinding, "find_path").to_return([Vector2(0, 0), Vector2(10, 10)])

    var enemy = Enemy.new()
    enemy.pathfinding = mock_pathfinding

    enemy.move_to(Vector2(10, 10))

    assert_called(mock_pathfinding, "find_path")

Partial Doubles

func test_player_inventory():
    var player_double = partial_double(Player).new()
    stub(player_double, "save_to_disk").to_do_nothing()

    player_double.add_item("sword")

    assert_eq(player_double.inventory.size(), 1)
    assert_called(player_double, "save_to_disk")

Physics Testing

Testing Collision

func test_projectile_hits_enemy():
    var projectile = Projectile.new()
    var enemy = Enemy.new()

    add_child(projectile)
    add_child(enemy)

    projectile.global_position = Vector2(0, 0)
    enemy.global_position = Vector2(100, 0)

    projectile.velocity = Vector2(200, 0)

    # Simulate physics frames
    for i in range(60):
        await get_tree().physics_frame

    assert_true(enemy.was_hit, "Enemy should be hit by projectile")

    projectile.queue_free()
    enemy.queue_free()

Testing Area2D

func test_pickup_collected():
    var pickup = Pickup.new()
    var player = player_scene.instantiate()

    add_child(pickup)
    add_child(player)

    pickup.global_position = Vector2(50, 50)
    player.global_position = Vector2(50, 50)

    # Wait for physics to process overlap
    await get_tree().physics_frame
    await get_tree().physics_frame

    assert_true(pickup.is_queued_for_deletion(), "Pickup should be collected")

    player.queue_free()

Input Testing

Simulating Input

func test_jump_on_input():
    var input_event = InputEventKey.new()
    input_event.keycode = KEY_SPACE
    input_event.pressed = true

    Input.parse_input_event(input_event)
    await get_tree().process_frame

    player._unhandled_input(input_event)

    assert_true(player.is_jumping, "Player should jump on space press")

Testing Input Actions

func test_attack_action():
    # Simulate action press
    Input.action_press("attack")
    await get_tree().process_frame

    player._process(0.016)

    assert_true(player.is_attacking)

    Input.action_release("attack")

Resource Testing

Testing Custom Resources

func test_weapon_stats_resource():
    var weapon = WeaponStats.new()
    weapon.base_damage = 10.0
    weapon.attack_speed = 2.0

    assert_eq(weapon.dps, 20.0, "DPS should be damage * speed")

func test_save_load_resource():
    var original = PlayerData.new()
    original.level = 5
    original.gold = 1000

    ResourceSaver.save(original, "user://test_save.tres")
    var loaded = ResourceLoader.load("user://test_save.tres")

    assert_eq(loaded.level, 5)
    assert_eq(loaded.gold, 1000)

    DirAccess.remove_absolute("user://test_save.tres")

GUT Configuration

gut_config.json

{
  "dirs": ["res://tests/"],
  "include_subdirs": true,
  "prefix": "test_",
  "suffix": ".gd",
  "should_exit": true,
  "should_exit_on_success": true,
  "log_level": 1,
  "junit_xml_file": "results.xml",
  "font_size": 16
}

CI Integration

Command Line Execution

# Run all tests
godot --headless -s addons/gut/gut_cmdln.gd

# Run specific tests
godot --headless -s addons/gut/gut_cmdln.gd \
  -gdir=res://tests/unit \
  -gprefix=test_

# With JUnit output
godot --headless -s addons/gut/gut_cmdln.gd \
  -gjunit_xml_file=results.xml

GitHub Actions

test:
  runs-on: ubuntu-latest
  container:
    image: barichello/godot-ci:4.2
  steps:
    - uses: actions/checkout@v4

    - name: Run Tests
      run: |
        godot --headless -s addons/gut/gut_cmdln.gd \
          -gjunit_xml_file=results.xml        

    - name: Publish Results
      uses: mikepenz/action-junit-report@v4
      with:
        report_paths: results.xml

Best Practices

DO

  • Use before_each/after_each for setup/teardown
  • Free nodes after tests to prevent leaks
  • Use meaningful assertion messages
  • Group related tests in the same file
  • Use watch_signals for signal testing
  • Await physics frames when testing physics

DON'T

  • Don't test Godot's built-in functionality
  • Don't rely on execution order between test files
  • Don't leave orphan nodes
  • Don't use yield (use await in Godot 4)
  • Don't test private methods directly

Troubleshooting

Issue Cause Fix
Tests not found Wrong prefix/path Check gut_config.json
Orphan nodes warning Missing cleanup Add queue_free() in after_each
Signal not detected Signal not watched Call watch_signals() before action
Physics not working Missing frames Await physics_frame
Flaky tests Timing issues Use proper await/signals