How I approach testing in Godot

How I approach testing in Godot

For a change of pace, I'd like to do a bit of a dev log. Some time ago I participated in a game jam, and made this game – Of Mice and Bad Choices – a short puzzle game, where you place cheese around the maze to lure the mice out. It was fun, but there were evidently some shortcomings. One of the major ones is the behaviour of mice is unintuitive. The players mentioned that they would expect mice to be repelled by a disliked cheese, not just freeze. Plus, implementing this mechanic would allow for a much more rich puzzle design. So I think this is a good opportunity how automated testing can be done in Godot.

An illustration of a expected behaviour: the mouse should move away from blue cheese

Testing tools

There are a few testing frameworks that are available for Godot 4, but the one that caught my I is Godot Unit Test (GUT). GUT is pretty simple:

  • It can be installed from AssetLib in one click
  • It provides a class that we can extend for our test scripts: just add functions startings with test_ and write some assertations – typical unit test structure
  • It has a nice UI with ability to use Godot's debugger and to run individual tests
  • It can be run from CLI and in CI (but I will deal with this later)

My testing framework

For this particular case, I wanted to have a way of defining complex scenarios, the same way I define levels for the game – in engine editor rather than in code (this way the tests would be closer to reality). Hence I want to do these things:

  • Have a single runner function that takes a map in, and runs the tests
  • Have a collection of maps, each having a set of scenarios (test cases) to execute
  • Have a way to define test cases in a drag-n-drop way: place a mouse, set where it should be in N turns

So, let's unwrap this.

Test case definitions

Let's define a new class `MouseTestCase`. We want it to inherit Node2D (as we want to place it on a scene. And we want it to find to of its children (that we will place on a scene ourselves): a mouse, and its expected final position (as a Marker)

extends Node2D
class_name MouseTestCase

@export var steps_left = 0 # How many steps to simulate
@export var done = false

@onready var mouse: Mouse = $Mouse
@onready var expected_position = SnapUtils.get_tile_map_position($TestMarker.position)

Now we can put it on a scene, and we are good! We know where a mouse starts, we know where it should end up, and in how many steps.

A node tree to define a test case
This is how it looks on the map: the mouse to be tested in green, the target marker in red

Test maps

Now, let's make a bunch more of them, and make a map to test our repellent behaviour.

The resulting test map for 'repel' mechanic testing

This behaviour is somewhat complex, hence we want to cover many slightly different cases:

  • A mouse wants to move away from the disliked cheese
  • A mouse wants to keep the direction of movement (i.e. avoids turns)
  • A mouse prefers left turns to right and u-turns

The resulting map defining 12 test cases to cover this behaviours is shown above (imagine how tedious it could be to hard code all those coordinates in code).

Test runner

The only thing left to do is the test runner function. The function needs to:

  • Load the map we've defined above
  • Simulate game steps forward until all test cases are done
  • On each step, iterate over all test cases, and if they are done, check whether the expected position is reached

The code is quite simple

func run_level_with_mouse_test_cases(map_path: String):
	var level = load(map_path)
	var cases = MouseTestCase.cast_all_cases(get_tree().get_nodes_in_group(MouseTestCase.MTC_GROUP_NAME))
	while (cases.any(func(case): return not case.done)):
		for case in cases:
			if not case.done:
				case.steps_left -= 1
				if case.steps_left == 0:
					case.done = true
					assert_eq(case.get_mouse_position(), case.expected_position, case.get_parent().name+"/"

I imagine this will evolve, but the current implementation is good enough for now. I've written the tests, implemented the mechanic, and the tests actually confirm that the mechanic is implemented correctly!

The pane of GUT showing the successful test run


Here, I've shown one way to approach the tests in games. Obviously, there many more things to improve here, and I encourage readers to take the code and the framework and adapt it to their needs. As always, the code is available on github: You can also have a look at the specific PR that introduces testing. For bonus points if someone can make them work in CI, that'd be brilliant. Cheers.

Subscribe to D. Lowl's personal site

Sign up now to get access to the library of members-only issues.
Jamie Larson