--- name: godot-test-instrumentation description: Automates Godot testing for coding agents via headless execution, method simulation, and stdout validation. --- # Godot Test Instrumentation Enables coding agents to automatically test game changes by running Godot in headless mode, simulating user actions via method calls, and validating results through structured stdout output. ## Use This Skill When - A coding agent needs to verify gameplay changes work correctly - Testing generator purchases, currency gains, prestige mechanics, or buff systems - Running automated validation in CI/CD pipelines - Debugging features without manual GUI interaction ## Core Workflow ``` 1. Agent writes/modifies game code ↓ 2. Agent creates or updates test script in res://tests/ ↓ 3. Agent runs: godot --headless --path . -s res://tests/test_x.gd ↓ 4. Test loads gym scene, simulates actions, prints [TAG] results ↓ 5. Agent parses stdout for [PASS]/[FAIL]/[RESULT] ↓ 6. Exit code 0 = PASS, Exit code 1 = FAIL ``` --- ## Directory Structure ``` res:// ├── tests/ │ ├── test_runner.gd # Cross-platform test discovery & execution │ ├── test_utils.gd # Shared assertion helpers │ ├── test_gold_mine_click.gd # Example: generator click → currency gain │ └── test_prestige.gd # Example: prestige mechanics validation ├── docs/gyms/ # Test scenes (e.g., tiny_sword.tscn) └── core/ # Game logic (LevelGameState, generators, etc.) ``` --- ## 1. Test Utils (res://tests/test_utils.gd) Shared assertion helpers with structured stdout output. ```gdscript class_name TestUtils extends RefCounted static var _test_name: String = "" static var _passed: int = 0 static var _failed: int = 0 static func set_test_name(name: String) -> void: _test_name = name _passed = 0 _failed = 0 static func assert_equals(expected: Variant, actual: Variant, message: String) -> void: if expected == actual: print("[PASS] %s: %s" % [_test_name, message]) _passed += 1 else: print("[FAIL] %s: %s (expected %s, got %s)" % [ _test_name, message, str(expected), str(actual) ]) _failed += 1 static func assert_true(condition: bool, message: String) -> void: if condition: print("[PASS] %s: %s" % [_test_name, message]) _passed += 1 else: print("[FAIL] %s: %s" % [_test_name, message]) _failed += 1 static func assert_greater_than(a: float, b: float, message: String) -> void: if a > b: print("[PASS] %s: %s" % [_test_name, message]) _passed += 1 else: print("[FAIL] %s: %s (expected %s > %s)" % [_test_name, message, str(a), str(b)]) _failed += 1 static func print_result() -> void: print("[SUMMARY] %s: %d passed, %d failed" % [_test_name, _passed, _failed]) if _failed == 0: print("[RESULT] PASS") else: print("[RESULT] FAIL") ``` --- ## 2. Test Runner (res://tests/test_runner.gd) Cross-platform test discovery and execution. Runs all `test_*.gd` scripts in the tests directory. ```gdscript extends SceneTree var test_scripts: Array[String] = [] var test_index: int = 0 var total_passed: int = 0 var total_failed: int = 0 func _init(): print("\n=== GODOT TEST RUNNER ===\n") _discover_tests() if test_scripts.is_empty(): print("[ERROR] No test scripts found in res://tests/") quit(1) return print("Found %d test(s): %s\n" % [test_scripts.size(), str(test_scripts)]) await _run_next_test() print("\n=== TEST SUMMARY ===") print("Total passed: %d" % total_passed) print("Total failed: %d" % total_failed) if total_failed == 0: print("[OVERALL_RESULT] PASS") quit(0) else: print("[OVERALL_RESULT] FAIL") quit(1) func _discover_tests() -> void: var dir = DirAccess.open("res://tests") if dir: dir.list_dir_begin() var file_name = dir.get_next() while file_name != "": if file_name.ends_with(".gd") and file_name.begins_with("test_") and file_name != "test_runner.gd" and file_name != "test_utils.gd": test_scripts.append("res://tests/%s" % file_name) file_name = dir.get_next() dir.list_dir_end() test_scripts.sort() func _run_next_test() -> void: if test_index >= test_scripts.size(): return var test_script = test_scripts[test_index] print("--- Running test %d/%d: %s ---" % [test_index + 1, test_scripts.size(), test_script]) var test_instance = load(test_script).new() await test_instance.run() total_passed += test_instance.passed total_failed += test_instance.failed test_index += 1 await _run_next_test() class TestScript: var passed: int = 0 var failed: int = 0 func run(): pass ``` **Usage:** ```bash # Run all tests via test runner "$GODOT_BIN" --headless --path . -s res://tests/test_runner.gd ``` --- ## 3. Test Script Template Each test extends `SceneTree` and implements a `run()` method. ```gdscript extends SceneTree var passed: int = 0 var failed: int = 0 func _init(): await run() quit(0 if failed == 0 else 1) func run(): # Setup TestUtils.set_test_name("") # Load scene var scene = load("") # e.g., "res://docs/gyms/tiny_sword/tiny_sword.tscn" var root_node = scene.instantiate() add_root_node(root_node) # Wait for scene initialization await _wait() # Get references var game_state: LevelGameState = root_node.find_child("LevelGameState") var generator = root_node.find_child("") as CurrencyGenerator if game_state == null: print("[ERROR] LevelGameState not found") _print_summary() return # Test logic var initial_value = game_state.get_currency_amount_by_id(&"") print("[TARGET] initial_: %s" % initial_value.to_string_suffix(2)) # Simulate action (method call, not mouse click) generator._on_pressed() # Wait for signal propagation await _wait() # Validate var final_value = game_state.get_currency_amount_by_id(&"") print("[TARGET] final_: %s" % final_value.to_string_suffix(2)) TestUtils.assert_greater_than( final_value.mantissa, initial_value.mantissa, " increased after action" ) _print_summary() func _wait() -> void: await get_tree().create_timer(0.5).timeout func _print_summary(): TestUtils.print_result() passed = _get_passed_count() failed = _get_failed_count() func _get_passed_count() -> int: # Extract from TestUtils or track locally return 0 func _get_failed_count() -> int: return 0 ``` --- ## 4. Example Test: Gold Mine Click Tests that clicking a generator grants currency. ```gdscript extends SceneTree var passed: int = 0 var failed: int = 0 var _local_passed: int = 0 var _local_failed: int = 0 func _init(): await run() quit(failed == 0 ? 0 : 1) func run(): print("\n=== TEST: Gold Mine Click ===\n") TestUtils.set_test_name("gold_mine_click") # Load tiny_sword scene var scene = load("res://docs/gyms/tiny_sword/tiny_sword.tscn") var root_node = scene.instantiate() add_root_node(root_node) await _wait() var game_state: LevelGameState = root_node.find_child("LevelGameState") var gold_mine = root_node.find_child("GoldMine") as CurrencyGenerator if game_state == null or gold_mine == null: print("[ERROR] Missing LevelGameState or GoldMine node") _print_summary() return # Record initial gold var initial_gold: BigNumber = game_state.get_currency_amount_by_id(&"gold") print("[TARGET] initial_gold: %s" % initial_gold.to_string_suffix(2)) # Simulate clicking the generator gold_mine._on_pressed() await _wait() # Validate gold increased var final_gold: BigNumber = game_state.get_currency_amount_by_id(&"gold") print("[TARGET] final_gold: %s" % final_gold.to_string_suffix(2)) TestUtils.assert_greater_than( final_gold.mantissa, initial_gold.mantissa, "Gold increased after generator click" ) # Check specific value (adjust based on click_mantissa in data) var expected_min: float = initial_gold.mantissa + 1.0 TestUtils.assert_true( final_gold.mantissa >= expected_min, "Gold increased by at least 1.0" ) _print_summary() func _wait() -> void: await get_tree().create_timer(0.5).timeout func _print_summary(): TestUtils.print_result() # Note: For accurate counts, modify TestUtils to return counts or track locally _local_failed = 0 # Update based on actual assertions ``` --- ## 5. Example Test: Prestige Mechanics Tests prestige threshold and currency gain. ```gdscript extends SceneTree var passed: int = 0 var failed: int = 0 func _init(): await run() quit(failed == 0 ? 0 : 1) func run(): print("\n=== TEST: Prestige Mechanics ===\n") TestUtils.set_test_name("prestige") var scene = load("res://docs/gyms/tiny_sword/tiny_sword.tscn") var root_node = scene.instantiate() add_root_node(root_node) await _wait() var game_state: LevelGameState = root_node.find_child("LevelGameState") var prestige_manager = root_node.find_child("PrestigeManager") as PrestigeManager if game_state == null or prestige_manager == null: print("[ERROR] Missing LevelGameState or PrestigeManager") _print_summary() return # Setup: Accumulate currencies to meet prestige threshold game_state.add_currency_by_id(&"gold", BigNumber.from_float(100000.0)) game_state.add_currency_by_id(&"gems", BigNumber.from_float(100.0)) await _wait() # Check if can prestige var can_prestige: bool = prestige_manager.can_prestige() print("[TARGET] can_prestige: %s" % str(can_prestige)) TestUtils.assert_true(can_prestige, "Should be able to prestige after accumulating currencies") if can_prestige: # Perform prestige prestige_manager.perform_prestige() await _wait() # Check ascension currency var ascension: BigNumber = game_state.get_currency_amount_by_id(&"ascension") print("[TARGET] ascension_currency: %s" % ascension.to_string_suffix(2)) TestUtils.assert_true( ascension.mantissa > 0, "Should receive ascension currency after prestige" ) # Check prestige multiplier increased var total_multiplier = prestige_manager.get_total_multiplier() print("[TARGET] total_multiplier: %s" % str(total_multiplier)) TestUtils.assert_greater_than( total_multiplier if total_multiplier is float else float(total_multiplier), 1.0, "Prestige multiplier should be > 1.0" ) _print_summary() func _wait() -> void: await get_tree().create_timer(0.5).timeout func _print_summary(): TestUtils.print_result() ``` --- ## 6. Shell Scripts ### run_test.sh ```bash #!/bin/bash # Usage: ./run_test.sh # Example: ./run_test.sh res://tests/test_gold_mine_click.gd set -e TEST_SCRIPT="$1" GODOT_BIN="${GODOT_BIN:-godot}" if [ -z "$TEST_SCRIPT" ]; then echo "Usage: $0 " exit 1 fi echo "Running test: $TEST_SCRIPT" echo "" "$GODOT_BIN" --headless --path . -s "$TEST_SCRIPT" EXIT_CODE=$? echo "" if [ $EXIT_CODE -eq 0 ]; then echo "✓ Test passed" else echo "✗ Test failed with exit code: $EXIT_CODE" fi exit $EXIT_CODE ``` --- ### run_all.sh ```bash #!/bin/bash # Usage: ./run_all.sh set -e GODOT_BIN="${GODOT_BIN:-godot}" TEST_DIR="res://tests" echo "=== Running All Tests ===" echo "" # Run test runner (discovers all tests automatically) "$GODOT_BIN" --headless --path . -s "$TEST_DIR/test_runner.gd" EXIT_CODE=$? echo "" if [ $EXIT_CODE -eq 0 ]; then echo "✓ All tests passed" else echo "✗ Some tests failed" fi exit $EXIT_CODE ``` --- ### check_syntax.sh ```bash #!/bin/bash # Usage: ./check_syntax.sh SCRIPT="$1" GODOT_BIN="${GODOT_BIN:-godot}" if [ -z "$SCRIPT" ]; then echo "Usage: $0 " exit 1 fi echo "Checking syntax: $SCRIPT" "$GODOT_BIN" --headless --path . --check-only --script "$SCRIPT" EXIT_CODE=$? if [ $EXIT_CODE -eq 0 ]; then echo "✓ Syntax valid" else echo "✗ Syntax errors found" fi exit $EXIT_CODE ``` --- ## 7. Godot CLI Command Reference | Command | Purpose | |---------|---------| | `"$GODOT_BIN" --headless --path . -s res://tests/test_x.gd` | Run single test in headless mode | | `"$GODOT_BIN" --headless --path . -s res://tests/test_runner.gd` | Run all tests via test runner | | `"$GODOT_BIN" --headless --path . --check-only --script res://test.gd` | Syntax validation only | | `"$GODOT_BIN" --headless --verbose --path . -s res://tests/test_x.gd` | Verbose output for debugging | | `"$GODOT_BIN" --headless --path . --quit-after 100 -s res://tests/test_x.gd` | Run for 100 frames then quit | | `"$GODOT_BIN" --headless --path . -s res://tests/test_x.gd --log-file /tmp/test.log` | Log output to file | **Important:** Always use `--headless` for automated testing. This sets `--display-driver headless --audio-driver Dummy`. --- ## 8. Output Parsing Protocol Agents should parse stdout for these tags: | Tag | Purpose | Example | |-----|---------|---------| | `[TARGET]` | Values to validate | `[TARGET] gold: 100.00` | | `[PASS]` | Assertion passed | `[PASS] gold_mine_click: Gold increased]` | | `[FAIL]` | Assertion failed | `[FAIL] gold_mine_click: Expected gold > 0]` | | `[SUMMARY]` | Test completion stats | `[SUMMARY] gold_mine_click: 2 passed, 0 failed]` | | `[RESULT]` | Overall test result | `[RESULT] PASS` or `[RESULT] FAIL]` | | `[ERROR]` | Setup/runtime error | `[ERROR] Missing LevelGameState node]` | | `[OVERALL_RESULT]` | Test runner summary | `[OVERALL_RESULT] PASS` or `[OVERALL_RESULT] FAIL]` | **Exit Codes:** - `0` = All assertions passed - `1` = One or more assertions failed or error occurred --- ## 9. Best Practices ### Simulating User Actions In headless mode, **do not** simulate mouse clicks with `InputEventMouseButton`. Instead, call methods directly: ```gdscript # ✅ DO: Call the method directly generator._on_pressed() game_state.add_currency_by_id(&"gold", BigNumber.from_float(100.0)) prestige_manager.perform_prestige() # ❌ DON'T: Try to synthesize mouse events (won't work in headless) var event = InputEventMouseButton.new() Input.parse_input_event(event) # Ineffective without GUI ``` ### Async Handling Use standard wait time for signal propagation: ```gdscript await get_tree().create_timer(0.5).timeout ``` Adjust based on test needs: - `0.1s` - Simple state changes - `0.5s` - Signal propagation (recommended default) - `1.0s+` - Complex async operations ### Scene Selection Use gym scenes from `res://docs/gyms/`: - `res://docs/gyms/tiny_sword/tiny_sword.tscn` - Full game with generators, currencies, prestige - Create minimal test scenes if testing isolated features ### Test Naming - Use `test_.gd` naming convention - Test name in `TestUtils.set_test_name()` should match filename (without `.gd`) ### Assertions - One assertion per line for clear parsing - Include descriptive messages - Use `[TARGET]` to log values before/after actions --- ## 10. Quick Start for Agents 1. **Create test file:** ```bash touch res://tests/test_my_feature.gd ``` 2. **Copy template:** ```gdscript extends SceneTree var passed: int = 0 var failed: int = 0 func _init(): await run() quit(failed == 0 ? 0 : 1) func run(): print("\n=== TEST: My Feature ===\n") TestUtils.set_test_name("my_feature") var scene = load("res://docs/gyms/tiny_sword/tiny_sword.tscn") var root = scene.instantiate() add_root_node(root) await get_tree().create_timer(0.5).timeout # Your test logic here TestUtils.print_result() ``` 3. **Run test:** ```bash godot --headless --path . -s res://tests/test_my_feature.gd ``` 4. **Parse output:** - Look for `[PASS]`, `[FAIL]`, `[RESULT]` tags - Check exit code (0 = pass, 1 = fail) --- ## References - [Godot CLI Tutorial](https://docs.godotengine.org/en/latest/tutorials/editor/command_line_tutorial.html) - [Godot Headless Mode](https://docs.godotengine.org/en/latest/tutorials/editor/command_line_tutorial.html#run-options) - [InputEvent Documentation](https://docs.godotengine.org/en/stable/tutorials/inputs/inputevent.html) /home/mikymod/work/jmp/idle/.agents/skills/godot-test-instrumentation/SKILL.md