The problem: you add a new settings panel to your game. You hit Escape, click Controls, see an empty tab. You check the code — looks fine. You add debug prints. You restart. You click again. Still empty. Twenty minutes later you realise the container node was never named, and get_node() couldn’t find it. You’ve just wasted half your lunch break on a typo-level bug.
Now imagine catching that bug before you ever touched the mouse. In 2 seconds. From the command line. That’s what an automated UI test harness does.
Here’s the pattern, the bugs it caught on its first real run, and how to adapt it to any framework.
The Pattern: Load It, Poke It, Verify It
The core idea is simple: instead of loading your entire game to test a menu, you create a minimal scene that loads just the component you care about.
- Create a
Noderoot with a test script attached - Load your UI component via
scene.instantiate() - Call its public API methods to open tabs, toggle settings
- Verify the result: check child counts, node names, label text
- Simulate user interaction:
btn.emit_signal("pressed") - Run it headless from the command line
No renderer needed. No mouse involved. Just logic verification against the scene tree. Here’s what it looks like in GDScript (Godot’s scripting language):
extends Node
## Minimal test harness — loads ControlEditor, verifies rows, simulates back button
var _editor: CanvasLayer = null
func _ready() -> void:
# 1. Load the component
var scene = load("res://ui/control_editor.tscn")
_editor = scene.instantiate()
add_child(_editor)
# 2. Connect signals we want to verify
_editor.back_pressed.connect(_on_back_pressed)
# 3. Open the panel
_editor.open_for_slot(0)
await get_tree().process_frame
# 4. Verify structure
var tab_container = _editor.get_node("Panel/TabContainer")
var actions_vbox = _find_node_by_name(tab_container.get_child(0), "ActionsVBox")
print("Action rows found: %d" % actions_vbox.get_child_count())
# 5. Simulate a click
var back_btn = _editor.get_node("Panel/HBoxContainer/BackBtn")
back_btn.emit_signal("pressed")
func _on_back_pressed():
print("Signal: back_pressed received!")
Two Levels of Click Simulation
There are two ways to “click” in a headless test:
| Method | What it tests | When to use |
|---|---|---|
btn.emit_signal("pressed") | Signal wiring only — calls the connected callback directly | Quick check: “does the callback fire?” |
get_viewport().push_input(mouse_event) | Full GUI pipeline — hit testing, mouse_filter, _gui_input, button state changes, then pressed signal | Realistic test: “does the button work in the actual tree?” |
For the realistic version, create a mouse event and push it through the viewport:
# Simulate a real left-click on the Back button
var event := InputEventMouseButton.new()
event.button_index = MOUSE_BUTTON_LEFT
event.pressed = true
event.position = back_btn.get_global_rect().get_center()
get_viewport().push_input(event)
The event.position is the key — it tells the GUI system where the click lands. btn.get_global_rect() returns the button’s screen-space rectangle (calculated from anchors and margins — no renderer needed):
var rect := back_btn.get_global_rect()
# rect.position -> top-left corner in screen pixels (e.g. Vector2(480, 300))
# rect.size -> width x height (e.g. Vector2(80, 32))
# rect.get_center() -> center point (e.g. Vector2(520, 316))
event.position = rect.get_center() # click dead center
event.position = rect.position + Vector2(4, 4) # click near top-left corner
event.position = Vector2(rect.end.x - 1, rect.end.y - 1) # click bottom-right edge
This works in headless mode — Godot resolves anchor math even without a window. Use get_center() for buttons (largest hit area), or edge positions to test boundary behavior.
This catches bugs that emit_signal misses:
Run It Headless — Two Steps
Godot loads whatever scene project.godot points to via run/main_scene. So you temporarily swap it to your harness scene, run the check, and swap back:
Step 1 — Point Godot at your test scene
# Replace level1.tscn with your harness scene
$project = "path/to/project.godot"
$original = Get-Content $project
$test = $original -replace 'level1.tscn', 'ui/test_control_editor.tscn'
Set-Content -Path $project -Value $test
Step 2 — Run headless, non-blocking
# Launch Godot without a window, auto-exit after scene loads
$p = Start-Process -FilePath "C:\Godot\Godot.exe" `
-ArgumentList "--headless", "--check-only", "--path", "project/" `
-NoNewWindow -PassThru
Start-Sleep -Seconds 5
if ($p.HasExited) {
Write-Output "FAILED: exit $($p.ExitCode)"
} else {
Write-Output "PASSED"
$p | Stop-Process -Force
}
# Restore original main scene
Set-Content -Path $project -Value $original
The --check-only flag tells Godot to parse all scripts, load the scene, run _ready(), then exit — no game loop. Your print() calls and _fail() messages appear in stdout/stderr. Two seconds from edit to results.
The 4 Bugs It Caught on First Run
When I first wired this harness to a control editor panel (a tabbed menu for remapping keyboard and gamepad controls), it immediately surfaced four bugs I had been clicking past for an hour:
| Bug | What manual testing showed | What the harness reported |
|---|---|---|
| Unnamed container node | “Why are the tabs empty?” | Action rows found: 0 — instantly |
| Wrong node path (direct child vs descendant) | “It should be finding ActionsVBox…” | Path mismatch: get_node("ActionsVBox") only checks 1 level |
Stale children from queue_free() | “Layout looks fine but wrong data shows” | Child count wrong, old node still at index 0 |
| Header overlay covering tab container | “I can’t click the tabs!” | Wrong child count at wrong nesting depth |
Four bugs. 2 seconds each. Zero clicks. Compare that to the manual cycle: edit code, launch game, wait for level to load, hit Escape, click Controls, visually inspect, close game, repeat. Maybe 60 seconds per iteration. The harness cut iteration time by 30x.
What It Won’t Catch
This pattern is laser-focused on structural and signal-flow bugs. It won’t tell you:
- If your panel is the wrong colour or the font is too small
- If a button is off-screen because of anchor misconfiguration
- If a real mouse click actually triggers (as opposed to a simulated signal)
You still need one manual pass for visual polish. But by the time you do that pass, all the structural bugs should already be dead.
Beyond Godot — Universal Pattern
The beauty of this approach is that it’s framework-agnostic. The same pattern works everywhere:
- React: Use JSDOM +
fireEvent.click()in Jest tests. Load your dialog component, open it, check rendered children, simulate the close button. - Flutter:
WidgetTester.pumpWidget()loads a widget in isolation.tester.tap(find.text('Close'))simulates interaction. - Embedded LCD menus: Mock the display buffer. Feed button events to your menu state machine. Assert the cursor position and highlighted row after each press.
- ESP32 web dashboards: Spin up a headless browser, load your settings page, fill a form field, submit, check the API call was made with the right payload.
The principle is universal: isolate the UI component, don’t start the whole app. Test the logic, not the rendering.
TL;DR
- Isolated test harness = load one UI component, call its API, verify structure
- Simulate clicks at two levels: raw
emit_signal("pressed")for speed, orget_viewport().push_input(event)for full GUI pipeline - Run headless (
--check-only) for 2-second feedback loop - Catches: naming bugs, path errors, signal gaps, layout overlap by proxy
- Works for Godot, React, Flutter, embedded displays — any UI framework
- 30x faster iteration than manual clicking
The next time you find yourself launching a game just to check if a menu button works, stop. Write a harness instead. Your lunch break will thank you.
Leave a Reply