Test automation has long been business as usual in web and mobile development, but automated testing in games gets written about far less often. So a tester who wants to learn it can be completely unsure where to even begin. In my case, the thing under test was my own pet project, Taveronen: The Beginning, a small cozy idle game (a genre where resources pile up on their own) about a tavern, made with the Godot engine. That's where I learned to write automated tests from scratch, and my own game turned out to be a perfect training ground: I know every system in it, I can read any script, and if a test finds a bug, I can fix it myself.
A few words about the engine for those who haven't met it yet. Godot is a free, open-source engine for both 2D and 3D games. It's lightweight and especially popular with indie developers. A Godot game is built from scenes, and scenes are built from nodes: a button, an image, a sound or a timer is each a separate node, and you can attach a script with logic to it. It's a great engine to start making games with.
In this article, I'll explain in plain words what an automated test is, where a beginner can find answers, and how to go through the full cycle of writing a test, even if you've never done it before. I'll also show the plan my tests followed from there: from data checks to real scenes and mouse clicks. All of it on a working example, with every pitfall I ran into along the way. This article is only about tests inside the engine; running them automatically and shipping the game are a story for another time.
If you don't have a project of your own to practice on, no problem: Taveronen's code is open to everyone. The link is at the end of the article, and you can repeat everything described here on the same game.
And let me admit it right away: writing automated tests turned out to be genuinely fun. There's a special pleasure in watching the work get done by itself, but getting there takes a fair bit of sweat.
The Godot editor: a test file at the top, the GUT panel with a green run summary at the bottomWhat is an automated test?
Let's start with the main question: why would a tester automate checks at all? Manual testing shines where you need a living person: to feel the pacing, to notice a janky animation or an awkward line of text. But every project also has checks of a different kind: repetitive, boring and still mandatory. Did the economy break after a price change? Does an old save still load? Does every line have a translation? These are exactly the checks that get handed over to automated tests.
Every tester knows what a test case is: preconditions, steps and an expected result. An automated test is the same test case, only a program runs it. It sets up the preconditions itself, performs the steps itself, and instead of eyeballing the result, it compares it with an expected value we wrote into the test in advance. If they match, the test is green; if not, it's red, and the report shows exactly what went wrong.
It's easy to get lost in the terms here: automated tests, unit tests, integration tests. In fact, they answer two different questions. "Automated" says who does the checking: a program rather than a person. "Unit" and "integration" are about scale, that is, how big a piece of the game is being checked.
A unit test checks one small piece of code in isolation from everything else, for example the function that spends gold. An integration test checks how the pieces work together: it opens the real tavern scene, presses a shop button and checks that the gold was deducted, the item appeared in the tavern hall and the purchase was written to the save file. To put it in bicycle terms: unit tests check each part separately, while an integration test is taking the bike for a ride around the yard.
So a unit test is simply the smallest kind of automated test. And it can do something a manual check can't: a tester only sees the screen, while a unit test looks right into the code, calls a function and checks what it returned.
It's also important to understand the limits of automated tests from the start: they check exactly what you told them to check, and nothing more. I have a test that proves the font contains every letter of every language in the game. It's green, and yet the Ukrainian letter Ї was drawn too small in that font, and that was only caught by eye. The test knows the letter is there, but not what it looks like on screen.
For a program to run our test cases, it needs a tool, and in Godot you have to install that tool yourself.
The tool and where to find answers
This section is about preparation. First we'll install the tool that will run the tests, and then we'll figure out where to find answers to the questions that are bound to come up along the way.
Installing GUT
Godot has no built-in testing system; you install one as a separate plugin. The most popular ones are GUT (Godot Unit Test) and gdUnit4. I chose GUT 9.7.1 for a very practical reason: at the time, official gdUnit4 support only went up to Godot 4.7.1, and my project runs on 4.7.2.
GUT tests are written in the same language as the game itself: GDScript. It's Godot's own language, with Python-like syntax: code blocks are marked by indentation rather than curly braces, and simple code reads almost like plain text. If you know even a little Python, you'll feel at home very quickly.
Installation takes a few minutes:
- In the AssetLib tab (the built-in add-on catalog), find GUT, download and install it.
- Enable the plugin: Project → Project Settings → Plugins, tick the box next to Gut. The GUT panel will appear at the bottom of the editor.
- Create a folder for tests, for example
res://test/unit(res://is the project root, short for resources). - In the GUT panel, add this folder to Test Directories and turn on Include Subdirs so that tests in subfolders are found too.
If you don't specify a folder, GUT will honestly tell you "Cannot run tests". That's the first error almost everyone runs into.
GUT panel settings: test folders in Test Directories and the Include Subdirs box tickedBefore the first test, there's one thing worth doing that's easy to forget: excluding the tests from the game build. In the export settings (Project → Export, Resources tab), the exclusion field gets test/*, addons/gut/*. In my project, that field turned out to be empty, and the next build would have shipped the tests, along with GUT, straight to players.
Where to find answers
As soon as you sit down to write tests, questions start pouring in: what's the right check called, how do I wait for a scene to load, how do I read a file? The good news is that almost any question like that is answered by one of two sources (though, of course, you can always ask an AI). As for the game itself, its code tells you the most, and we'll come back to it when we get to the first test.
The first source is the GUT documentation at gut.readthedocs.io. It covers everything about the tests themselves. Three pages are enough to get started: Quick Start explains how to write and run your first test, Awaiting covers how to wait for a scene to load, and GutTest in the Class Reference section has the full list of assert_* checks with descriptions. That last page is the one I kept coming back to the most. The other sections will come in handy as your tests grow.
GUT itself also gives hints. When I used the deprecated check assert_call_count, the test passed, but the report showed a [DEPRECATED] note with the name of the new function. And when the documentation falls short, you can open the source code: it sits right in the project, in the addons/gut folder, and all the checks are collected in test.gd.
The second source is the Godot documentation at docs.godotengine.org. This covers everything about the engine itself: how to read a file (the FileAccess class), walk through a folder (DirAccess), parse JSON (JavaScript Object Notation, a text data format), find text by a pattern (RegEx, regular expressions), get the game's language (TranslationServer) or the sound volume (AudioServer). The same documentation is built into the editor: F1 opens a search through the help, and no internet is needed for that.
Here's how it works in practice. I wanted to check that the game's font contains every character from the translation table. In the Godot docs, the Font class has a has_char() function that tells you whether the font can draw a character with a given code, and strings have unicode_at(), which returns that code. Those two functions plus a loop over the table were all it took to build a test that checks every character in every language of the game.
That covers the sources. One last question remains before the code: where to start.
The plan: where to start
Before writing tests, it's worth deciding what to check first. Without a plan, it's easy to spend your effort on trifles and miss what matters. So I first worked out how the game's main systems are built and drew up a coverage plan. Coverage is how much of the code or the game the tests check. The plan rested on three principles.
Risk-based coverage, not percentages. In Java, there are tools like JaCoCo that measure coverage as a percentage: they count what share of the code lines the tests executed. GDScript had no mature tool of that kind at the time, but for a small project something else matters more: first cover what would hurt the most if it broke. For Taveronen, that's the economy, saves and translations.
From simple to complex. Data checks came first, and I left integration tests with real scenes for last. In the project, tests are sorted into folders by level: unit, integration, smoke (a quick check of the game's main paths) and e2e (end-to-end, checking the whole game in a browser).
One new technique per stage. Each stage added a little something new, so the new knowledge never piled up all at once. In the end, the plan looked like this:
- Shop and translation data: the number of items and their prices, translation keys (the game uses them to find the right text), fonts, the order of languages. What we learn: reading tables, dictionaries and files right from a test.
- Gold, its signals and click bonuses. What we learn: preparing fresh data with
before_each, a function GUT calls before every test, and catching signals, which the game uses to announce that something has happened:watch_signalsrecords them, andassert_signal_emittedchecks that the right signal arrived. - Saves. Tests run from the same editor as the game, so they can easily wipe out real progress. What we learn: working with a test file without touching the real one: the save path is swapped before each test, and
after_each, a function GUT calls after every test, cleans up after it. - Timed income. Income depends on the tavern scene, which doesn't exist in a test. What we learn: replacing parts of the game with test doubles, that is, stand-ins for real objects. There are three kinds: a stub returns a canned answer, a spy remembers how it was called, and a mock knows in advance which calls to expect and checks them itself. For a game this small, spies and mocks may be overkill, but I deliberately tried them for the experience.
- Real scenes: a purchase, the game's ending, a mouse click. What we learn: launching scenes, waiting for them to load with
await, and pressing buttons, including with the mouse, usingGutInputSender, GUT's helper for clicks. - Music, audio buses and volume sliders. What we learn: testing sound without sound: volume and audio buses, that is, separate channels for music and effects, are read as ordinary data.
The plan paid off before the first line of test code: a bug turned up while I was going through the code. "New Game" didn't reset the flag that marks a playthrough as started, so if you quit the game right during the intro, the "Continue" button in the menu stayed active even though there was nothing to continue. Reading code carefully is testing too.
I've already completed every item of this plan, and the tests are in a public repository: they're organized into folders, and each one has comments explaining what it checks and why. So you can open any technique from the list and take it apart on working code. In this article, though, we'll go through one test from the second item in detail, the one about gold and its signals: it shows nicely how a test grows out of the game's code.
The first test: from idea to a green run
There are two parts here. First we'll read the game function we're going to test, and then we'll write a test for it from scratch and go all the way to a green run, step by step.
Reading the game code
Every test starts with understanding the code you're about to test. It's convenient to read it right in the Godot editor: Ctrl-clicking a function name jumps to where it's declared, and the top of a script usually gathers signals, constants and variables, that is, everything the game remembers and uses to announce changes. Let's take spending gold in Taveronen as an example (I added the code comments for this article):
func spend_gold(amount: int) -> bool: # takes an amount (whole number), answers yes or no if gold >= amount: # is there enough gold? gold -= amount # take the price away gold_changed.emit(gold) # tell the rest of the game: gold changed, here is the new balance return true # answer "yes, spent" return false # not enough gold: answer "no" and change nothing These lines tell us almost everything a test needs. The function takes an amount (amount: int, a whole number) and answers yes or no (-> bool). If there's enough gold, it subtracts the amount, emits the gold_changed signal with the new balance and returns true. If not, it returns false right away and changes nothing. A signal in Godot is a way to tell other parts of the game that something has happened: an object emits a signal, and everything connected to it finds out and reacts. Three tests follow naturally from this: there's enough gold, there isn't enough gold, and the balance-change signal arrives only when gold was actually spent.
Writing our own test
Let's take the last one: we'll check that when there isn't enough gold, the refused purchase doesn't emit gold_changed. You can repeat this test yourself on the same project.
Step 1. Pick a risk. As in the plan, start not with what's easiest to write, but with what would hurt the most if it broke. It helps to think of the gold_changed signal as a phone call: GameManager (the game's main script, which holds gold, purchases and saves) "calls" whenever gold changes, and the shop listens for these calls and turns the buy buttons on or off after each one. If a call goes missing or rings at the wrong moment, the gold stays correct, but the shop buttons show an outdated state. That's a silent failure: the numbers are right, no gold test will notice it, but the player will.
Step 2. Read the code. We've already gone through the function itself: the signal is emitted only inside the "enough gold" branch, so when gold is short, there should be no call. What's left is to find out who listens to that call. The place where the shop connects to the signal is easy to find with a project-wide search. Connecting is done with the connect function (essentially a subscription to the signal), and the shop panel script has the line GameManager.gold_changed.connect(...): from that moment on, the shop hears about every change in gold.
Step 3. Write down the test case. Precondition: the player has 50 gold. Action: spend 60. Expected result: gold_changed never arrives.
Step 4. Find the tools. The GutTest page has everything you need for signals: watch_signals(object) starts recording an object's signals, and assert_signal_not_emitted checks that a given signal never happened. The same page has an important detail: watching must be turned on before the action, because GUT only records what happens after that line.
Step 5. Write the test. Gold tests live in their own file: one file covers one topic. Here's the beginning of that file and the new test in it. I comment almost every line, but here the comments are trimmed a little:
extends GutTest # "I am a test file" - GUT can now find and run me var game_manager: Node # Declared once, so before_each and every test can see it func before_each() -> void: # GUT calls it before every test in this file game_manager = autofree(load("res://scripts/autoload/game_manager.gd").new()) # A fresh GameManager in memory # No signal when spending is refused ## WHAT: gives the player only 50 gold by hand, starts watching the signals, then tries to spend 60. ## WHY: a refused purchase changes nothing, so the game must not be told that gold changed. ## PASSES when: gold_changed was NOT sent at all. FAILS when the signal comes even once. func test_refused_spend_does_not_emit_gold_changed() -> void: # Name starts with test_ so GUT runs it game_manager.gold = 50 # Put only 50 gold in by hand watch_signals(game_manager) # Start watching signals BEFORE the action game_manager.spend_gold(60) # Try to spend more than the player has # Check 1: a refused spend does not send the gold_changed signal at all assert_signal_not_emitted(game_manager, "gold_changed", "A refused spend should not send gold_changed") extends GutTest tells GUT "I'm a test file" and also gives the file all the assert_* checks, so you can call them just by name. GUT calls the before_each function itself before every test in the file. Here it creates a fresh GameManager, the game's main script we've already met. The load(...).new() line creates it from the script file, and autofree asks GUT to delete the object after the test. An object like this lives only in memory: its _ready() function (Godot calls it when an object enters the game) doesn't run, so there are no timers, no music, no reading of the real save, and it has exactly zero gold. That way every test starts with a clean slate.
func test_... is the test itself: GUT only runs functions whose names start with test_. I write the name as a sentence, even if it's long: when a test fails, its name appears in the report, and it should tell you what broke. Inside the test there are three parts separated by blank lines: setup (put in 50 gold and start watching signals), action (try to spend 60) and the check. They're commonly called Arrange, Act, Assert, and for a tester they're the same old precondition, step and expected result.
assert_signal_not_emitted is the check itself. The line reads almost like a sentence: "I assert that the gold_changed signal was not emitted." The last argument is the message that appears in the report if the check fails. And the most common check is assert_eq: it compares a value from the game with the expected one, and the actual value always comes first, the expected one second.
Above the test there are three comment lines: WHAT (what we do), WHY (why it's worth checking) and PASSES when (when the test is green), and above the check there's a Check label describing the game's behavior. I write them not for GUT but for myself six months from now: the label should answer the question "what broke if this check is red?" That's why "a refused spend does not send the gold_changed signal" is far more useful than a faceless "check the signal".
Step 6. Run it. First save the file (Ctrl+S), otherwise GUT will run the old version, and then press Run All in the GUT panel. A green run shows "All tests passed!" and the counters: how many files, tests and checks were run.
Running all the tests: the game's scenes open one after another, and at the end the summary says "All tests passed!"Step 7. Break the test on purpose. A green test proves nothing on its own: maybe it simply can't turn red. So I deliberately break every new test, make sure it really fails, and then put everything back.
You need to break exactly the value being checked. Once I added an extra letter only to the message text, and of course the test stayed green: the message doesn't affect anything. And when a test checks that something did not happen, it's easier to break the input data. Here I changed 50 gold to 100: the purchase went through, the signal arrived, and the test turned red. Then I set it back to 50, and everything was green again. One more rule learned the hard way: replace the line for the red check instead of adding a new one next to it, otherwise the report will show an extra check, and the extra line is easy to forget to delete.
Here's what GUT showed in the report when the test broke (copied from the GUT panel):
res://test/unit/game_manager/test_game_manager_gold.gd - test_refused_spend_does_not_emit_gold_changed [Failed]: Expected object <Node#155659012458>(game_manager.gd) to NOT emit signal [gold_changed]: A refused spend should not send gold_changed at line 108 - The first two lines are the file and the name of the failed test.
- Failed means the check didn't pass.
- Expected object ... to NOT emit signal [gold_changed] is what was expected:
game_manager.gdshould not have emittedgold_changed, but it did. - A refused spend should not send gold_changed is the message we wrote in the check ourselves.
- at line 108 is the line in the test file where the failed check sits. In the real file there are other gold tests above this one, so the line number is higher than in the snippet in this article.
Step 8. Run everything and save. After putting the value back, I run all the tests, not just the new one: the change might have affected its neighbors. Then the changes go to the repository on GitHub, where the tests run once more on their own, but that's a separate story.
While writing your first tests, you'll almost certainly hit the same pitfalls I did. Here are the most common ones we haven't covered yet:
- A test crashes on a dictionary (a set of key-value pairs): the value is read with
["key"], but the key isn't there. With.get("key", -1), the test fails with a clear message instead of an error and doesn't hide the checks that follow. - Function "not found": a typo in the name, like
Timerinstead ofTimeordublicateinstead ofduplicate. GDScript cares about every letter, so check names against the code or the documentation. - The counters suddenly doubled: a copy of a file like
test_save_load-1.gdended up in the tests folder, and GUT dutifully ran it as one more set of tests. - Lines "fell out" of a function after pasting code: the indentation got messed up. In GDScript, indentation is part of the syntax.
- An expected engine error fails the test: if a test deliberately feeds the game a corrupted file, the engine logs an error, and GUT counts it as a failure. An expected error has to be declared with
assert_engine_error.
That's the whole cycle: pick a risk, read the code, write down the test case, find the tools in the documentation, write, run, break, restore and save. Writing one test isn't hard; after that, it's a matter of repeating this cycle for every item of the plan.
What the tests found and how the game changed
The main job of tests is to confirm that the game works as intended and doesn't break after changes. But along the way they also find things nobody expected, and I had a few finds like that.
A real bug in the game. A test fed the game a save file with language number 99. The game took the number from the file without checking it, and there was no such number in the language list, so a save like that could crash the settings screen. The test turned red, and in update 1.0.2 I fixed the loading: an invalid number is now replaced with English. Then came a small watchdog test that makes sure the number of languages in the save code matches the actual language list. If you add a new language and forget about that number, the test turns red.
A bug in the test itself. The first run of the font test produced 12 red checks on the characters in the name of the Chinese language. The culprit wasn't the game but a rule in the test: a language's name is drawn in the font of the language it names, while the test looked for those characters in the font of a different column of the translation table. Hence the rule: if a test unexpectedly turns red, either the game or the test itself may be at fault, so check both.
Tests need maintenance. For example, the localization checks take the languages straight from the translation table, so when four new languages were added to the game, the tests handled them with almost no changes. That's convenient but deceptive: the new languages also brought new risks (a font missing the right letters, broken formatting tags) that the old tests simply can't see. So when the game changes, keeping the old tests green isn't enough: new risks need new tests.
Besides the bugs that got fixed, the tests also made me change the project itself. Players won't notice these changes, but the game became more reliable:
- Saving through a temporary file. While studying how saves work, I spotted a weak point: if the game gets cut off right in the middle of writing, the file can end up empty or truncated, and the player loses their progress. Now the game first writes the progress to a temporary file and only after the write is complete replaces the real save with it.
- Scene changes in separate functions. Buttons like "Return to Main Menu" used to switch the screen right away, and in a test such a switch also destroys GUT's own results window. Now the scene change lives in a separate function: the test replaces it with a stub and calmly checks the rest of the button's logic, while the player notices no difference.
- Tests don't reach players. The export exclusions mentioned at the beginning make sure nothing extra gets into the build.
This is probably the most unexpected conclusion for me. The value of automated tests isn't only in the bugs they find: to make a game easy to test, you have to make its code clearer and tidier, and the game itself benefits from that.
What about Unity and Unreal?
Many games, especially at big studios, are made with Unity and Unreal, so a fair question is: will this experience be useful if tomorrow I have to test a game on another engine? Almost entirely, yes: the language and the names change, but the ideas stay the same.
In Godot, tests are written in GDScript, in Unity in C# (the framework is built on NUnit), and in Unreal in C++. In Unity, a test without a scene is called an Edit Mode test, and a test with a real scene is a Play Mode test; in Unreal, these are simple and functional tests. Setup and cleanup exist everywhere: before_each in GUT, [SetUp] in Unity, BeforeEach in Unreal.
Oddly enough, the most noticeable difference when switching is a small one: the arguments of the equality check come in a different order. In GUT you write assert_eq(actual, expected), and in Unity Assert.AreEqual(expected, actual), the other way around.
All the problems I had to solve carry over too: protecting real saves, waiting for a scene to load, and making sure a test can turn red. These are problems of the game, not the engine, so they'll come up again in another project, only solved with different tools.
What's more, I think learning was actually easier on Godot:
- Short loop. Unit tests finish in seconds, and when there's so little time between a change and its result, you can make dozens of attempts in one evening. On top of that, Godot is distributed under the free MIT license, so you can simply download it onto a server that runs the tests automatically and launch it there, with no licenses or keys.
- Everything in one box. Test doubles are built right into GUT, while in Unity and Unreal people reach for third-party libraries such as Moq and GoogleMock. So I was learning the ideas themselves rather than someone else's tool on top of them.
- Everything in plain sight. Godot stores scenes in a text format you can read with your own eyes, the engine is open source, and GUT's source code sits right in the project. When the documentation wasn't enough, I could open the framework code and see how everything works.
You'll still have to learn each engine's language and editor separately. But that's syntax and tooling, while understanding what to check and why carries over from engine to engine without any loss.
Wrapping up
In short, the tests have covered the most painful spots in the game, and it has become noticeably more reliable: every change is now checked right away for whether it broke something that used to work, and bugs are caught before players see them. All of this happens on its own after every change.
The pleasure I mentioned at the beginning looks like this: you press Run All and just watch. Scenes open one after another, buttons get pressed without your help, the check counters climb, and a few seconds later the green "All tests passed!" appears. Everything you'd otherwise have to play through by hand after every change now fits into a single click, and the feeling that your back is covered is hard to beat.
The main thing I've learned: an automated test is a precise question to the game, and the hardest part isn't the syntax but the question itself. What exactly can break, how do you notice it, and how do you make sure the test can turn red at all? The GUT and Godot docs will help with the syntax, but you have to come up with the question yourself, and that's where manual testing experience helps the most.
That said, automation doesn't replace a living tester; it frees up their time. How the game looks, how it feels and the quality of its translations are still checked by people.
If you've never written automated tests, here's my advice: take the project you know best (your own game is ideal, and if you don't have one, Taveronen will do), start with the simplest data test, and add one new technique at a time. And keep a log: what you did, what broke and why. I recorded every step in a devlog, and that's what eventually grew into this article.
That's exactly why Taveronen is open: it's a small, finished game with everything worth practicing on, and the ready-made tests sit right next to it, showing what this can look like. Download the project from GitHub and open it in Godot 4.7.2. GUT 9.7.1 is already inside: just enable the plugin and press Run All. From there, it's up to you: write your own tests, break the game and see what they catch. When I was starting out, I really missed a working example I could open and take apart piece by piece, and I'll be glad if Taveronen becomes that example for someone else.
How these tests became part of a pipeline that builds the game, checks it in a real browser and publishes it to itch.io all by itself is a story for next time.
Links to the project, the game and my contacts:
- The practice project (game and automated tests) on GitHub
- Taveronen: The Beginning on itch.io
- The article about making the game
- LinkedIn: Denis Kurchikov
- Telegram: @Voljulan
Originally published in Russian on DTF.



