17 KiB
name, description
| name | description |
|---|---|
| godot-test-instrumentation | 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.
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.
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:
# 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.
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("<test_name>")
# Load scene
var scene = load("<path_to_gym_scene>") # 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("<GeneratorNodeName>") 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(&"<currency_id>")
print("[TARGET] initial_<currency>: %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(&"<currency_id>")
print("[TARGET] final_<currency>: %s" % final_value.to_string_suffix(2))
TestUtils.assert_greater_than(
final_value.mantissa,
initial_value.mantissa,
"<currency> 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.
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.
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
#!/bin/bash
# Usage: ./run_test.sh <test_script_path>
# 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 <test_script_path>"
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
#!/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
#!/bin/bash
# Usage: ./check_syntax.sh <script_path>
SCRIPT="$1"
GODOT_BIN="${GODOT_BIN:-godot}"
if [ -z "$SCRIPT" ]; then
echo "Usage: $0 <script_path>"
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 passed1= 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:
# ✅ 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:
await get_tree().create_timer(0.5).timeout
Adjust based on test needs:
0.1s- Simple state changes0.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_<feature>.gdnaming 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
-
Create test file:
touch res://tests/test_my_feature.gd -
Copy template:
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() -
Run test:
godot --headless --path . -s res://tests/test_my_feature.gd -
Parse output:
- Look for
[PASS],[FAIL],[RESULT]tags - Check exit code (0 = pass, 1 = fail)
- Look for