Claude-skill-registry godot-generate-tests
git clone https://github.com/majiayu000/claude-skill-registry
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"
skills/data/godot-generate-tests/SKILL.mdGenerate 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
- Scan - Find all .gd files in project
- Analyze - Identify public methods, signals, and node references
- Generate - Create test files with GUT framework structure
- Mock - Generate stub helpers for dependencies
- Configure - Create .gutconfig.json and CI/CD workflows
- 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