paint-brush
Testing in Godot: How I Personally Approach Itby@dlowl
730 reads
730 reads

Testing in Godot: How I Personally Approach It

by D. LowlMarch 7th, 2024
Read on Terminal Reader
Read this story w/o Javascript

Too Long; Didn't Read

Godot Unit Test (GUT) can be installed from AssetLib in one click. It provides a class that we can extend for our test scripts. It has a nice UI with the ability to use Godot's debugger and run individual tests. It can be run from CLI and in CI (but I will deal with this later)
featured image - Testing in Godot: How I Personally Approach It
D. Lowl HackerNoon profile picture

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 behavior 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 an expected behavior: 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 starting with test_ and write some assertations – typical unit test structure.


  • It has a nice UI with the ability to use Godot's debugger and 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, and 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 behavior.

The resulting test map for 'repel' mechanic testing

This behavior 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 behavior 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)
	map.load_level(level)
	
	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)):
		map.move_mice()
		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+"/"+case.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

Discussions

Here, I've shown one way to approach the tests in games. Obviously, there are 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: https://github.com/d-lowl/of-mice-and-bad-choices 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.