Writing ·

Testing a Godot game without opening it

Sixty-one headless suites and 1,740 checks on a colony sim, and the four bugs they found. None of the four produced a visible failure, so no amount of playing would have caught them.

Games get tested by playing them. That works for feel, and feel is most of what matters in a game, so it is a reasonable default. It stops working when the game is a simulation, because the failures that matter stop announcing themselves.

I build a space colony sim in Godot. It has crops, livestock, power and water networks, illness, hauling, mining, a work rota and a fleet, and every one of those touches at least two others. The test suite runs headless, without a renderer, and it now stands at 1,740 checks across 61 suites. This is how it is put together and, more usefully, what it found.

The failures do not look like failures

Here are four real bugs from this project. I have put what a person playing the game would have seen next to what was actually happening.

Four bugs in two columns. The left column, headed what you would have seen, describes a working game in every case. The right column, headed what was actually happening, describes the real fault.

Four real bugs. The left column is what playing the game would have shown you.

Take the first one. Every crop grower cached its configuration in _ready(), but on some build paths the node enters the tree before initialize() has assigned its item type. So growers cached the default item's data, which is a bed, and every data-driven behaviour quietly defaulted off. Soil beds stopped watering themselves. Hydroponic racks lost their grow-speed bonus. Tree plots stopped enforcing tree-only planting.

You would never have noticed, because colonists water crops by hand as a fallback. The fallback did its job perfectly and hid the fault underneath it. The crops grew. The colony was fine. A feature I had written and believed was working had never once run.

The second is worse in a way I find funnier. The animal-harvest logic lived in process_work(), and nothing calls process_work(). It is an unused stub on the base class. A farmer would walk to the pen, enter the working state, play the working animation, and stand there for the rest of the game doing nothing at all. It looks exactly like a colonist working.

None of these four produced an error, a crash, a warning, or a visibly wrong frame. Three of them made the game slightly easier or slightly duller in ways no player would attribute to a bug. You cannot find these by playing, however long you play, because there is nothing to see.

One Godot process per suite

The mechanics are simple. Each suite is a scene with a script that runs checks, prints one summary line in a fixed format, and quits. A bash script runs all 61 and adds up the counts.

The part worth copying is that each suite gets its own Godot process.

Two panels. On the left, three suites share one set of autoloads and a note says suite twelve passes because suite three left a colonist standing there. On the right, each suite has its own state.

Sixty-one process launches is the cost. A failure that means what it says is what you get for it.

Godot autoloads are global singletons, and a colony sim leaves a great deal behind: rooms in the game manager, entries in the resource networks, spawned colonists still walking about. Run two suites in one process and the second one inherits all of it. Then a test passes because an earlier suite happened to leave a colonist in a convenient place, and you have built the thing you were trying to avoid: a check that cannot fail, telling you nothing.

Sixty-one process launches is genuinely slower. A full run takes fourteen minutes and fifty-two seconds on my machine, almost all of it engine startup. That is a fine trade. The suite runs while I do something else, and when it comes back the result means what it says.

One detail that cost me an afternoon: the exit code is not trustworthy here. This project links a Steam GDExtension whose native library is absent under --headless, so Godot returns non-zero even on a clean quit(0). The runner therefore judges each suite by parsing its printed summary line, and treats a missing summary as a failure. If your test runner reads only the exit code, check that a crash and a clean pass actually produce different ones.

Where the checks are

A bar chart of sixty-one bars, one per suite. The first bar, incidents, is more than three times the height of the next. The rest tail off to a long flat run of very small bars.

One bar per suite, from a real run. The chart is linear, so the outlier stays an outlier.

The distribution is lopsided and that is not an accident. The incidents suite holds 266 checks on its own because reactive triggers have the largest surface in the game: every condition that can fire an event, and every gate that stops it firing at the wrong moment. Life support is 71, colony events 63, save and load 56 across two suites.

The long tail matters just as much. A suite with four checks in it is almost always a bug that came back once. That is the rule I hold to and the reason the number climbed from six suites to sixty-one: a bug found is a regression test written, before the fix, every time. Writing the test first is not discipline for its own sake. It is the only way to know the test would have caught it, and a regression test that passes before you fix anything is not a regression test.

What it actually saves

I want to be careful here, because "hours saved" is the kind of claim that gets made without a stopwatch.

The honest version is not that headless tests are faster than playing. It is that for this class of bug, playing does not work at any speed. Three of the four bugs above have no observable symptom. The fourth, the vanishing produce, would show up eventually as a fridge that fills more slowly than it should, at which point you are debugging a statistical impression rather than a fault.

What the suite does buy me, measurably, is the freedom to change things. This is a solo project with 641 commits and a lot of interlocking systems. Refactoring hauling when hauling feeds the feeders, which keep the livestock alive, which the kitchen depends on, is not a change I would make on a Sunday afternoon on the strength of a ten-minute play session. With the suite it is a change I make, run, and either keep or throw away fifteen minutes later.

This is not really about games

The pattern generalises, which is why it is on this site rather than in a devlog.

A fallback that hides the failure it is covering. A method that is never called and looks identical to one that is. A loader that reports success and produces nothing. A resource that is drawn from a global total instead of a live connection, so the check passes whether or not the thing is connected.

Those are all production bugs. I have met every one of them outside a game, wearing different clothes. The reason they survive is the same in both places: the system carried on, nothing went red, and there was no test asking the specific question that would have failed.

Dale Mooney builds AI systems that run unattended, and works out how to tell when they have quietly stopped. Twenty years in infrastructure. I write up what I find, usually because something looked fine and was not.

More writing  ·  hello@dmooney.engineer

Reading