Tranch uses three layers of testing, from cheap-and-fast to expensive-and-thorough.
Tools: gdformat (formatter) + gdlint (linter) from gdtoolkit.
What it catches: style violations, unused variables, naming issues, formatting drift.
# Install locally
pip install gdtoolkit==4.*
# Check formatting (CI fails if this returns non-zero)
gdformat --check $(find . -name "*.gd" -not -path "./addons/*")
# Auto-fix formatting
gdformat $(find . -name "*.gd" -not -path "./addons/*")
# Lint (non-blocking in CI for now)
gdlint $(find . -name "*.gd" -not -path "./addons/*")Scene reference check: A Python script in .github/workflows/lint.yml opens every .tscn file and verifies that every ext_resource path="..." actually exists on disk. This catches the most common Godot bug: renaming a .gd file but forgetting to update scenes that reference it.
Tool: GUT (Godot Unit Test) — the standard unit test framework for Godot 4.
What it tests: pure logic — sanity drain rates, inventory stacking rules, game state transitions, puzzle state machines. No rendering, no physics, no audio.
# One-time: install GUT addon
bash scripts/install_gut.sh
# Run all unit tests
godot --headless --path . \
-s addons/gut/gut_cmdln.gd \
-gdir=res://test/unit \
-gexit- Create
test/unit/test_<system>.gd extends GutTest- Use
before_each()to reset state - Use
assert_eq(actual, expected, "message"),assert_true(),assert_false(),assert_lt(), etc. - Each
func test_*()is one test case
Example (from test/unit/test_sanity_system.gd):
extends GutTest
func before_each():
SanitySystem.sanity = 100.0
func test_drain_dark_reduces_sanity():
var start := SanitySystem.sanity
SanitySystem.drain_dark(1.0)
assert_lt(SanitySystem.sanity, start, "Sanity should drop in the dark")| System | Tests | Assertions |
|---|---|---|
| SanitySystem | 9 | ~15 |
| GameState | 9 | ~12 |
| InventorySystem | 7 | ~20 |
| Total | 25 | ~47 |
M2 target: 100+ unit tests covering all 7 puzzles, save/load round-trips, all 4 enemy AIs.
Tool: qa/test_playthrough.gd + scenes/test_runner.tscn, run via Godot headless.
What it tests: that the project actually boots, the test scene loads, the player spawns, autoloads initialize, and no script throws an unhandled error in the first 10 seconds.
godot --headless --path . \
--main-scene res://scenes/test_runner.tscn \
--quit-after 10CI scans the output for:
SCRIPT ERROR— any uncaught script error → failCannot— Godot's "cannot find resource" messages → failFAIL:orSOME TESTS FAILED— from the playthrough script's own assertions → fail- Missing
=== TRANCH QA TEST SUITE ===banner → scene failed to load → fail
qa/test_playthrough.gd is a hand-written integration test runner. To add a check:
func _test_my_new_thing():
_start_test("My New Thing")
var actual = SomeSystem.compute_something()
if actual == expected_value:
_pass("compute_something returned correct value")
else:
_fail("compute_something returned %s, expected %s" % [actual, expected_value])Then add _test_my_new_thing() to the _run_all_tests() function.
See qa/MANUAL_QA_CHECKLIST.md. A human must play through the game and tick every box before tagging a release. This catches everything automation can't: "does this feel scary?", "is the puzzle fair?", "does the music transition feel right?".
.github/workflows/build.yml exports Windows and Android builds and verifies the export process succeeds and produces a non-zero-byte binary. It does NOT launch the binary in CI (that would require GPU + display). The user downloads the artifact and launches it locally.
| Layer | When | Runtime | Failure = block merge? |
|---|---|---|---|
| Lint (gdformat) | every PR | ~30s | Yes |
| Lint (gdlint) | every PR | ~30s | No (advisory) |
| Scene ref check | every PR | ~10s | Yes |
| GUT unit tests | every PR | ~1min | Yes |
| Headless playthrough | every PR | ~30s | Yes |
| Build (Win+Android) | every tag v* |
~10min | Yes (release blocker) |
| Manual QA | before tag | ~2hrs | Yes (sign-off required) |