gut-test-writer
Skill2 starsChanged 6 months agoMITView on GitHub
Write GUT (Godot Unit Test) tests for components, scenes, autoloads. Generates test structure, before_each/after_each setup, signal watching, parameterized tests, headless run config, and CI integration. Use after implementing a feature or fixing a bug.
In this repository33 other files it gives its agents.
- CLAUDE.mdCLAUDE.md
- bootstrap-godot-projectSkill
- codebase-surveySkill
- create-autoloadSkill
- create-componentSkill
- create-resourceSkill
- create-sceneSkill
- create-state-machineSkill
- export-configSkill
- feature-planSkill
- feature-specSkill
- game-brainstormingSkill
- gdd-writerSkill
- genre-pack-3d-actionSkill
- genre-pack-platformerSkill
- genre-pack-topdownSkill
- genre-pack-turnbasedSkill
- godot-patternsSkill
- networking-foundationSkill
- performance-auditSkill
- save-schema-migrationSkill
- setup-collision-layersSkill
- setup-git-godotSkill
- setup-input-mapSkill
- setup-localizationSkill
- setup-save-systemSkill
- sfx-generatorSkill
- shader-writerSkill
- subagent-dev-modeSkill
- ui-patterns-godotSkill
- update-docsSkill
- using-godot-superpowersSkill
- writing-game-planSkill
The file
We split this skill into the rules it gives an agent
2 rules. About 0 are common, 1 is distinctive, and 1 is very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈1
Distinctive · estimate
This author's own choices. Worth a look.
1
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 30
Add to .gitignore: test/reports/
- Distinctive: line 240
Don't test private methods directly: Test the observable behavior.
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
GUT Test Writer
Generate GUT tests. For GdUnit4, see the qa-tester agent — same patterns, different API.
Setup (once per project)
- Install GUT via Asset Library or git submodule into
addons/gut/ - Enable in Project Settings → Plugins
- Create
test/.gutconfig.json:
{
"dirs": ["res://test/unit", "res://test/integration"],
"include_subdirs": true,
"log_level": 1,
"should_exit": true,
"should_exit_on_success": true,
"junit_xml_file": "res://test/reports/results.xml",
"junit_xml_timestamp": false
}- Add to
.gitignore:test/reports/
Directory structure
test/
├── unit/
│ ├── test_health_component.gd
│ ├── test_inventory.gd
│ └── test_state_machine.gd
├── integration/
│ ├── test_player_takes_damage.gd
│ └── test_save_load_roundtrip.gd
├── helpers/
│ ├── test_helpers.gd # shared fixtures
│ └── mock_player.gd
├── fixtures/
│ └── test_save_data.tres
├── reports/ # gitignored
└── .gutconfig.jsonFilename convention: test_{thing_under_test}.gd. Class: extends GutTest.
Test template
extends GutTest
## Tests for HealthComponent.
const HealthComponent := preload("res://scripts/components/health_component.gd")
var _hp: HealthComponent
func before_each() -> void:
_hp = HealthComponent.new()
_hp.max_health = 100
add_child_autofree(_hp)
_hp._ready() # _ready is called when added; explicit here for clarity
func test_starts_at_full_health() -> void:
assert_eq(_hp.current_health, 100)
func test_take_damage_reduces_health() -> void:
_hp.take_damage(30)
assert_eq(_hp.current_health, 70)
func test_take_damage_clamps_at_zero() -> void:
_hp.take_damage(9999)
assert_eq(_hp.current_health, 0)
func test_die_signal_emitted_at_zero() -> void:
watch_signals(_hp)
_hp.take_damage(100)
assert_signal_emitted(_hp, "died")
func test_health_changed_payload() -> void:
watch_signals(_hp)
_hp.take_damage(25)
assert_signal_emitted_with_parameters(_hp, "health_changed", [100, 75])
func test_invulnerable_blocks_damage() -> void:
_hp.invulnerable = true
_hp.take_damage(50)
assert_eq(_hp.current_health, 100)
func test_heal_does_not_overshoot_max() -> void:
_hp.take_damage(20)
_hp.heal(50)
assert_eq(_hp.current_health, 100)
func test_revive_after_death() -> void:
_hp.take_damage(100)
watch_signals(_hp)
_hp.revive()
assert_signal_emitted(_hp, "revived")
assert_eq(_hp.current_health, 100)Scene-based tests
extends GutTest
## Player integration test — instantiates the full scene.
const PlayerScene := preload("res://scenes/player/player.tscn")
var _player: Player
func before_each() -> void:
_player = PlayerScene.instantiate()
add_child_autofree(_player)
await get_tree().process_frame # let _ready propagate to children
func test_player_dies_when_hp_zero() -> void:
watch_signals(_player)
_player.get_node("HealthComponent").take_damage(_player.get_node("HealthComponent").max_health)
assert_signal_emitted(_player, "died")Parameterized tests
func test_damage_modifier(params = use_parameters([
[1.0, 100, 10], # full damage
[0.5, 100, 5], # half damage
[2.0, 100, 20], # double damage
[0.0, 100, 0], # immune
])) -> void:
var modifier: float = params[0]
var input_dmg: int = params[1]
var expected: int = params[2]
_hp.damage_modifier = modifier
var before := _hp.current_health
_hp.take_damage(input_dmg)
assert_eq(before - _hp.current_health, expected)Mock / spy patterns
# Spy on a method by replacing the implementation
class MockAudioManager extends Node:
var played: Array[StringName] = []
func play_sfx(name: StringName, _pitch_variation: float = 0.1) -> void:
played.append(name)
func test_player_plays_jump_sfx() -> void:
var mock := MockAudioManager.new()
add_child_autofree(mock)
# inject mock into player (via @export or setter)
_player.audio_manager = mock
_player.jump()
assert_has(mock.played, &"sfx_player_jump_01")For deeper mocking, GUT provides partial_double() and double() — see GUT docs.
Asserting timing / async
func test_dash_cooldown() -> void:
_player.dash()
assert_true(_player.is_dashing())
await get_tree().create_timer(_player.dash_duration + 0.1).timeout
assert_false(_player.is_dashing())For shorter waits use await get_tree().process_frame (one frame) or await get_tree().physics_frame (one physics tick).
Headless run
# all tests, exit on completion
godot --headless --path . -s addons/gut/gut_cmdln.gd -gdir=res://test -gexit
# specific directory
godot --headless --path . -s addons/gut/gut_cmdln.gd -gdir=res://test/unit -gexit
# specific file
godot --headless --path . -s addons/gut/gut_cmdln.gd -gselect=test_health_component.gd -gexit
# with JUnit XML output
godot --headless --path . -s addons/gut/gut_cmdln.gd -gdir=res://test -gjunit_xml_file=res://test/reports/results.xml -gexitExit code: 0 on success, non-zero on test failure.
CI integration (GitHub Actions)
.github/workflows/test.yml:
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: chickensoft-games/setup-godot@v2
with:
version: 4.4.1
use-dotnet: false
- name: Import project
run: godot --headless --import || true
- name: Run GUT tests
run: |
godot --headless --path . \
-s addons/gut/gut_cmdln.gd \
-gdir=res://test \
-gjunit_xml_file=res://test/reports/results.xml \
-gexit
- uses: actions/upload-artifact@v4
if: always()
with:
name: test-reports
path: test/reports/Test design rules
- One assertion per test, ideally. Tests with many asserts hide which one failed.
before_eachsets up;after_eachis rarely needed if you useadd_child_autofree.- No file I/O in unit tests — mock
FileAccessor test against fixtures intest/fixtures/. - No autoloads in unit tests — test the component in isolation. Integration tests can use the real autoload.
- Test edge cases first: zero, negative, max, empty array, missing dependency.
- Test signal payloads, not just emission count — payload bugs are common.
- Don't test private methods directly. Test the observable behavior.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

