Claude-skill-registry godot-generate-tests

install
source · Clone the upstream repo
git clone https://github.com/majiayu000/claude-skill-registry
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/godot-generate-tests" ~/.claude/skills/majiayu000-claude-skill-registry-godot-generate-tests && rm -rf "$T"
manifest: skills/data/godot-generate-tests/SKILL.md
source content

Generate Godot Tests

Core Principle

Every public method should be tested. Tests prove your code works and protect against regressions when refactoring.

What This Skill Does

Generates comprehensive test suites for Godot GDScript code:

  • Unit tests for public methods with assertions
  • Signal connection and emission tests
  • Scene initialization and lifecycle tests
  • Physics/Process loop behavior tests
  • Mock and stub helpers for dependencies
  • GUT framework configuration
  • CI/CD integration for automated testing

Test Generation Patterns

Unit Tests for Public Methods

# Tests for player.gd
extends GutTest

var player: Player

func before_each():
    player = Player.new()
    add_child_autofree(player)

func test_take_damage_reduces_health():
    player.health = 100
    player.take_damage(25)
    assert_eq(player.health, 75, "Health should decrease by damage amount")

func test_take_damage_does_not_go_below_zero():
    player.health = 10
    player.take_damage(25)
    assert_eq(player.health, 0, "Health should not go below zero")

func test_is_alive_returns_true_when_health_positive():
    player.health = 1
    assert_true(player.is_alive(), "Should be alive with positive health")

func test_is_alive_returns_false_when_health_zero():
    player.health = 0
    assert_false(player.is_alive(), "Should not be alive with zero health")

Signal Connection Tests

# Tests for signal connections and emissions
extends GutTest

var player: Player
var signal_received: bool = false
var signal_args: Array = []

func before_each():
    player = Player.new()
    add_child_autofree(player)
    signal_received = false
    signal_args.clear()

func _on_health_changed(new_health: int):
    signal_received = true
    signal_args.append(new_health)

func test_health_changed_signal_emitted_on_damage():
    player.health = 100
    player.health_changed.connect(_on_health_changed)
    player.take_damage(25)
    assert_true(signal_received, "health_changed signal should be emitted")
    assert_eq(signal_args[0], 75, "Signal should pass new health value")

func test_died_signal_emitted_when_health_reaches_zero():
    var died_received = false
    player.died.connect(func(): died_received = true)
    player.health = 10
    player.take_damage(10)
    assert_true(died_received, "died signal should be emitted when health reaches zero")

Scene Initialization Tests

# Tests for scene setup and node references
extends GutTest

var player_scene: PackedScene
var player: Player

func before_all():
    player_scene = load("res://scenes/player.tscn")

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

func test_player_has_required_nodes():
    assert_not_null(player.get_node_or_null("Sprite2D"), "Player should have Sprite2D")
    assert_not_null(player.get_node_or_null("CollisionShape2D"), "Player should have CollisionShape2D")
    assert_not_null(player.get_node_or_null("AnimationPlayer"), "Player should have AnimationPlayer")

func test_player_initializes_with_correct_health():
    assert_eq(player.health, 100, "Health should initialize to 100")
    assert_eq(player.max_health, 100, "Max health should initialize to 100")

func test_player_animation_player_has_idle_animation():
    var anim_player = player.get_node("AnimationPlayer")
    assert_true(anim_player.has_animation("idle"), "Should have idle animation")

Physics/Process Loop Tests

# Tests for _physics_process and _process behavior
extends GutTest

var player: Player

func before_each():
    player = Player.new()
    add_child_autofree(player)

func test_velocity_applies_gravity_in_physics_process():
    player.velocity = Vector2(0, 0)
    player.gravity = 980.0
    
    # Simulate one physics frame (at 60 FPS)
    var delta = 1.0 / 60.0
    player._physics_process(delta)
    
    assert_almost_eq(player.velocity.y, 980.0 * delta, 0.01, "Velocity should increase by gravity * delta")

func test_move_and_slide_is_called_in_physics_process():
    # Mock the move_and_slide method
    var move_and_slide_called = false
    player.move_and_slide = func() -> bool:
        move_and_slide_called = true
        return true
    
    player._physics_process(0.016)
    assert_true(move_and_slide_called, "move_and_slide should be called")

GUT Framework Setup

Test File Structure

project/
├── addons/
│   └── gut/
│       └── ...
├── tests/
│   ├── unit/
│   │   ├── test_player.gd
│   │   ├── test_enemy.gd
│   │   └── test_inventory.gd
│   ├── integration/
│   │   ├── test_combat_system.gd
│   │   └── test_save_load.gd
│   ├── mocks/
│   │   ├── mock_player.gd
│   │   └── mock_enemy.gd
│   └── test_runner.gd
└── .gutconfig.json

GUT Configuration

{
  "dirs": ["res://tests/unit", "res://tests/integration"],
  "prefix": "test_",
  "suffix": ".gd",
  "ignore_subdirs": ["mocks"],
  "log_level": 2,
  "should_exit": true,
  "should_exit_on_success": true,
  "compact_mode": false,
  "double_strategy": "partial",
  "pre_run_script": "",
  "post_run_script": ""
}

Common GUT Assertions

# Equality
assert_eq(actual, expected, "message")
assert_ne(actual, expected, "message")
assert_almost_eq(actual, expected, 0.01, "message")

# Boolean
assert_true(condition, "message")
assert_false(condition, "message")

# Null/Not Null
assert_null(value, "message")
assert_not_null(value, "message")

# Type
assert_is_instance_of(object, Class, "message")
assert_is_not_instance_of(object, Class, "message")

# String
assert_string_contains(string, substring, "message")
assert_string_begins_with(string, prefix, "message")
assert_string_ends_with(string, suffix, "message")

# Signal
assert_signal_emitted(object, "signal_name", "message")
assert_signal_not_emitted(object, "signal_name", "message")
assert_signal_emitted_with_parameters(object, "signal_name", [arg1, arg2])

# File
assert_file_exists("res://path/to/file")
assert_file_does_not_exist("res://path/to/file")

Test Templates

Class Test Template

# tests/unit/test_{class_name}.gd
extends GutTest

var {class_instance}: {ClassName}

func before_all():
    # Runs once before all tests in this file
    pass

func before_each():
    # Runs before each test
    {class_instance} = {ClassName}.new()
    add_child_autofree({class_instance})

func after_each():
    # Runs after each test
    # Autofree handles cleanup
    pass

func after_all():
    # Runs once after all tests
    pass

# Test public methods
func test_{method_name}_{expected_behavior}():
    # Arrange
    {class_instance}.{setup_method}()
    
    # Act
    var result = {class_instance}.{method_name}()
    
    # Assert
    assert_eq(result, expected_value, "message")

# Test signals
func test_{signal_name}_emitted_when_{condition}():
    var signal_received = false
    {class_instance}.{signal_name}.connect(func(): signal_received = true)
    
    # Trigger condition
    {class_instance}.{trigger_method}()
    
    assert_true(signal_received, "message")

Signal Test Template

# Signal-specific tests
test_{signal_name}_connections:
  - Connects to {target_node} on ready
  - Emits with correct parameters
  - Disconnects on exit
  - Can be connected multiple times
  - Callback receives expected data

Scene Test Template

# tests/unit/test_{scene_name}_scene.gd
extends GutTest

var {scene_name}_scene: PackedScene
var {scene_instance}: Node

func before_all():
    {scene_name}_scene = load("res://{path}/{scene_name}.tscn")
    assert_not_null({scene_name}_scene, "Scene should load successfully")

func before_each():
    {scene_instance} = {scene_name}_scene.instantiate()
    add_child_autofree({scene_instance})

func test_scene_has_required_children():
    # Verify node hierarchy
    assert_not_null({scene_instance}.get_node_or_null("{ChildNode}"), "Should have {ChildNode}")

func test_scene_initial_state():
    # Verify initial property values
    assert_eq({scene_instance}.{property}, {expected_value}, "Initial value should be correct")

func test_scene_ready_initializes_components():
    # Simulate ready
    await get_tree().process_frame
    
    # Verify initialization
    assert_true({scene_instance}.{component}.is_initialized, "Component should initialize on ready")

Mock/Stub Generation

Mock Class Template

# tests/mocks/mock_{class_name}.gd
class_name Mock{ClassName}
extends {BaseClass}

# Mock state tracking
var {method_name}_calls: Array = []
var {method_name}_return_value = null

func {method_name}(args):
    {method_name}_calls.append(args)
    return {method_name}_return_value

# Helper to configure return value
func set_{method_name}_return(value):
    {method_name}_return_value = value

# Helper to verify calls
func assert_{method_name}_called(times: int = 1):
    assert_eq({method_name}_calls.size(), times, "Expected {method_name} to be called {times} times")

func assert_{method_name}_called_with(args):
    var found = false
    for call in {method_name}_calls:
        if call == args:
            found = true
            break
    assert_true(found, "Expected {method_name} to be called with {args}")

Stub Helper for Dependencies

# tests/mocks/stub_helpers.gd
class_name StubHelpers

static func stub_player() -> Player:
    var player = Player.new()
    player.health = 100
    player.max_health = 100
    player.speed = 200
    player.damage = 10
    return player

static func stub_enemy(enemy_type: String = "basic") -> Enemy:
    var enemy = Enemy.new()
    enemy.enemy_type = enemy_type
    match enemy_type:
        "basic":
            enemy.health = 50
            enemy.damage = 5
        "boss":
            enemy.health = 500
            enemy.damage = 25
    return enemy

static func stub_weapon(weapon_type: String) -> Weapon:
    var weapon = Weapon.new()
    weapon.weapon_type = weapon_type
    weapon.damage = _get_weapon_damage(weapon_type)
    return weapon

static func _get_weapon_damage(weapon_type: String) -> int:
    match weapon_type:
        "sword": return 15
        "bow": return 10
        "staff": return 20
        _: return 5

Test Double with Partial Mocking

# Using GUT's partial double for selective mocking
extends GutTest

var player: Player

func before_each():
    # Create partial double - only mock specific methods
    player = partial_double(Player).instantiate()
    add_child_autofree(player)

func test_player_uses_real_movement_but_mocked_combat():
    # Real movement
    player.velocity = Vector2(100, 0)
    player._physics_process(0.016)
    
    # Mocked combat - stub the take_damage method
    stub(player, "take_damage").to_return(false)
    
    # Test that combat uses stub
    var result = player.take_damage(100)
    assert_eq(result, false, "Should use stubbed return value")

Example Transformations

Example 1: Player Combat Class

Before (Script):

# player.gd
class_name Player
extends CharacterBody2D

@export var health: int = 100
@export var max_health: int = 100
@export var damage: int = 10

signal health_changed(new_health: int)
signal died

func take_damage(amount: int) -> void:
    health = max(0, health - amount)
    health_changed.emit(health)
    if health == 0:
        died.emit()

func heal(amount: int) -> void:
    health = min(max_health, health + amount)
    health_changed.emit(health)

func is_alive() -> bool:
    return health > 0

func attack(target: Node) -> void:
    if target.has_method("take_damage"):
        target.take_damage(damage)

After (Test File):

# tests/unit/test_player.gd
extends GutTest

var player: Player

func before_each():
    player = Player.new()
    add_child_autofree(player)

func test_take_damage_reduces_health():
    player.health = 100
    player.take_damage(25)
    assert_eq(player.health, 75)

func test_take_damage_clamps_at_zero():
    player.health = 10
    player.take_damage(25)
    assert_eq(player.health, 0)

func test_heal_increases_health():
    player.health = 50
    player.heal(25)
    assert_eq(player.health, 75)

func test_heal_clamps_at_max_health():
    player.health = 90
    player.max_health = 100
    player.heal(25)
    assert_eq(player.health, 100)

func test_is_alive_true_with_health():
    player.health = 1
    assert_true(player.is_alive())

func test_is_alive_false_with_zero_health():
    player.health = 0
    assert_false(player.is_alive())

func test_health_changed_signal_emitted_on_damage():
    watch_signals(player)
    player.take_damage(25)
    assert_signal_emitted(player, "health_changed")

func test_died_signal_emitted_on_fatal_damage():
    watch_signals(player)
    player.health = 25
    player.take_damage(25)
    assert_signal_emitted(player, "died")

func test_attack_calls_take_damage_on_target():
    var mock_target = partial_double(CharacterBody2D).instantiate()
    add_child_autofree(mock_target)
    stub(mock_target, "take_damage").to_do_nothing()
    
    player.attack(mock_target)
    
    assert_called(mock_target, "take_damage")

Example 2: Inventory System

Before (Script):

# inventory.gd
class_name Inventory
extends Node

signal item_added(item: Item, slot: int)
signal item_removed(item: Item, slot: int)
signal inventory_full

const MAX_SLOTS = 20
var items: Array[Item] = []

func add_item(item: Item) -> bool:
    if items.size() >= MAX_SLOTS:
        inventory_full.emit()
        return false
    
    items.append(item)
    item_added.emit(item, items.size() - 1)
    return true

func remove_item(slot: int) -> Item:
    if slot < 0 or slot >= items.size():
        return null
    
    var item = items[slot]
    items.remove_at(slot)
    item_removed.emit(item, slot)
    return item

func has_item(item_name: String) -> bool:
    for item in items:
        if item.name == item_name:
            return true
    return false

func get_item_count() -> int:
    return items.size()

func is_full() -> bool:
    return items.size() >= MAX_SLOTS

After (Test File):

# tests/unit/test_inventory.gd
extends GutTest

var inventory: Inventory

func before_each():
    inventory = Inventory.new()
    add_child_autofree(inventory)

func test_add_item_returns_true_when_space_available():
    var item = Item.new()
    item.name = "Sword"
    assert_true(inventory.add_item(item))

func test_add_item_returns_false_when_full():
    # Fill inventory
    for i in range(Inventory.MAX_SLOTS):
        inventory.add_item(Item.new())
    
    var result = inventory.add_item(Item.new())
    assert_false(result)

func test_add_item_emits_item_added_signal():
    watch_signals(inventory)
    var item = Item.new()
    item.name = "Sword"
    
    inventory.add_item(item)
    
    assert_signal_emitted_with_parameters(inventory, "item_added", [item, 0])

func test_add_item_emits_inventory_full_when_no_space():
    watch_signals(inventory)
    for i in range(Inventory.MAX_SLOTS):
        inventory.add_item(Item.new())
    
    inventory.add_item(Item.new())
    assert_signal_emitted(inventory, "inventory_full")

func test_remove_item_returns_item_at_slot():
    var item = Item.new()
    item.name = "Sword"
    inventory.add_item(item)
    
    var removed = inventory.remove_item(0)
    assert_eq(removed, item)

func test_remove_item_returns_null_for_invalid_slot():
    assert_null(inventory.remove_item(-1))
    assert_null(inventory.remove_item(100))

func test_remove_item_emits_item_removed_signal():
    var item = Item.new()
    item.name = "Sword"
    inventory.add_item(item)
    watch_signals(inventory)
    
    inventory.remove_item(0)
    assert_signal_emitted_with_parameters(inventory, "item_removed", [item, 0])

func test_has_item_returns_true_when_item_present():
    var item = Item.new()
    item.name = "Sword"
    inventory.add_item(item)
    
    assert_true(inventory.has_item("Sword"))

func test_has_item_returns_false_when_item_not_present():
    assert_false(inventory.has_item("NonExistent"))

func test_get_item_count_returns_number_of_items():
    assert_eq(inventory.get_item_count(), 0)
    inventory.add_item(Item.new())
    assert_eq(inventory.get_item_count(), 1)
    inventory.add_item(Item.new())
    assert_eq(inventory.get_item_count(), 2)

func test_is_full_returns_true_at_capacity():
    for i in range(Inventory.MAX_SLOTS):
        inventory.add_item(Item.new())
    assert_true(inventory.is_full())

func test_is_full_returns_false_when_not_full():
    assert_false(inventory.is_full())

Example 3: Integration Test - Combat System

Integration Test:

# tests/integration/test_combat_system.gd
extends GutTest

var player: Player
var enemy: Enemy
var combat_manager: CombatManager

func before_each():
    player = load("res://scenes/player.tscn").instantiate()
    enemy = load("res://scenes/enemy.tscn").instantiate()
    combat_manager = CombatManager.new()
    
    add_child_autofree(player)
    add_child_autofree(enemy)
    add_child_autofree(combat_manager)
    
    combat_manager.player = player
    combat_manager.enemy = enemy

func test_player_attack_damages_enemy():
    enemy.health = 100
    var initial_health = enemy.health
    
    player.attack(enemy)
    
    assert_lt(enemy.health, initial_health, "Enemy should take damage from player attack")

func test_enemy_death_emits_signal():
    watch_signals(enemy)
    enemy.health = 1
    
    player.attack(enemy)
    
    assert_signal_emitted(enemy, "died")

func test_combat_manager_tracks_damage_dealt():
    player.damage = 25
    enemy.health = 100
    
    combat_manager.initiate_combat()
    player.attack(enemy)
    
    assert_eq(combat_manager.damage_dealt, 25, "Combat manager should track damage dealt")

func test_combat_ends_when_enemy_dies():
    enemy.health = 1
    
    combat_manager.initiate_combat()
    player.attack(enemy)
    
    assert_true(combat_manager.is_combat_ended, "Combat should end when enemy dies")

CI/CD Integration

GitHub Actions Workflow

# .github/workflows/godot-tests.yml
name: Godot Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup Godot
        uses: chickensoft-games/setup-godot@v1
        with:
          version: 4.2.1
          use-dotnet: false
      
      - name: Install GUT
        run: |
          git clone https://github.com/bitwes/Gut.git addons/gut
          # Or use your project's specific GUT version
      
      - name: Run Tests
        run: |
          godot --headless --script addons/gut/gut_cmdln.gd -gexit
      
      - name: Upload Test Results
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: test-results
          path: gut_logs/

GitLab CI Configuration

# .gitlab-ci.yml
test:godot:
  image: barichello/godot-ci:4.2.1
  script:
    - git clone https://github.com/bitwes/Gut.git addons/gut
    - godot --headless --script addons/gut/gut_cmdln.gd -gexit
  artifacts:
    when: always
    paths:
      - gut_logs/
    expire_in: 1 week

Test Runner Script

# tests/test_runner.gd
extends SceneTree

func _init():
    var gut = load("res://addons/gut/gut.gd").new()
    gut.connect("tests_finished", _on_tests_finished)
    
    # Configure from .gutconfig.json
    var config = _load_config()
    
    for dir in config.dirs:
        gut.add_directory(dir)
    
    gut.set_yield_between_tests(true)
    gut.set_exit_on_success(config.should_exit_on_success)
    
    root.add_child(gut)
    gut.test_scripts()

func _on_tests_finished():
    quit()

func _load_config() -> Dictionary:
    var file = FileAccess.open("res://.gutconfig.json", FileAccess.READ)
    if file:
        return JSON.parse_string(file.get_as_text())
    return {}

When to Use

You're Adding New Features

Generate tests alongside new code to ensure correctness.

You're Refactoring Legacy Code

Create tests before refactoring to verify behavior doesn't change.

You Need Confidence in Changes

Comprehensive test suite prevents regressions.

You're Setting Up CI/CD

Automated testing ensures code quality on every commit.

You're Practicing TDD

Generate test templates to speed up red-green-refactor cycle.

When NOT to Use

Prototype/Throwaway Code

Don't test code that will be rewritten.

Simple Setters/Getters

Testing trivial property access adds noise without value.

Editor-Only Tools

Code that only runs in editor doesn't need runtime tests.

Visual/Art Code

Purely visual changes are better tested manually.

Safety

  • Generated tests are starting points - review and customize
  • Tests include comments explaining expected behavior
  • Edge cases are documented with specific test cases
  • Auto-rollback available if tests break existing code
  • Git commits track test file generation

Integration

Works with:

  • godot-extract-to-scenes - Generate tests for extracted components
  • godot-split-scripts - Create tests for each split module
  • godot-refactor (orchestrator) - Comprehensive testing after refactoring
  • godot-add-signals - Test signal connections and emissions

Process

  1. Scan - Find all .gd files in project
  2. Analyze - Identify public methods, signals, and node references
  3. Generate - Create test files with GUT framework structure
  4. Mock - Generate stub helpers for dependencies
  5. Configure - Create .gutconfig.json and CI/CD workflows
  6. Commit - Git commit with test suite

Generated Structure

tests/
├── unit/
│   ├── test_{class1}.gd      # Unit tests for each class
│   ├── test_{class2}.gd
│   └── ...
├── integration/
│   ├── test_{system1}.gd     # Integration test suites
│   └── ...
├── mocks/
│   ├── mock_{class1}.gd      # Mock implementations
│   ├── stub_helpers.gd       # Test fixture utilities
│   └── ...
├── scenes/
│   ├── test_{scene1}.gd      # Scene instantiation tests
│   └── ...
└── test_runner.gd            # CLI test runner