UI Testing at Light Speed: Automated Harnesses That Catch Bugs in Seconds

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.

  1. Create a Node root with a test script attached
  2. Load your UI component via scene.instantiate()
  3. Call its public API methods to open tabs, toggle settings
  4. Verify the result: check child counts, node names, label text
  5. Simulate user interaction: btn.emit_signal("pressed")
  6. 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:

MethodWhat it testsWhen to use
btn.emit_signal("pressed")Signal wiring only — calls the connected callback directlyQuick 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 signalRealistic 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:

BugWhat manual testing showedWhat 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, or get_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.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *