Unit Testing - Grisgram/gml-raptor GitHub Wiki
Unit testing is one of the most important things you can do with your classes.
You may think, it's not so important in game development. If you do, you are wrong.
Read, why unit testing is important (click to expand)
"When you have eliminated the impossible, whatever remains, however improbable, must be the truth."
This principle of eliminating the impossible is a central element in Holmes' deductive method and can also be applied to software development, particularly to unit testing.
Unit tests allow us to test individual units of code independently and ensure they function correctly. When a unit of code is thoroughly tested and verified, we can consider it a βBlack Box of Working Code.β This means that we can rely on this part of the code to work correctly, allowing us to focus on other areas that might be causing issues when troubleshooting.
Imagine a situation in your game, where your character moves along somewhere, an enemy (controlled by the server) also moves, and for some reason, your character dies unexpectedly.
Why did this happen?
- Was it, because the collision detection is wrong?
- Did the distance-calculation fail?
- Was there a malformed json-response from the server?
- Was the response correct, but my parser failed?
- Did I misinterpret the contents of the response?
For such a simple situation, we have easily a large group of possible reasons. Debugging this is a mess, especially when you might not have a way to reproduce this exact issue and timing again.
This is, where unit testing comes in (or integration testing, to be more correct in the definition of terms, if you use external data in your test).
- If you have stable, reproducible tests for your collision code, you know, "this code is clean"
- If you have stable, reproducible tests for your distance calculation, you know, "this code is clean"
- ...and so on... for each and every mini-system inside your game, you can test this code isolated from any external interferences and create a black box of working code.
By using the process of elimination, we can more quickly identify the root cause of the problem, rather than having to go through all the code. This makes unit testing a valuable tool for increasing the efficiency of software development and reducing maintenance costs.
When you now start the bug hunt, you can look at your unit tests and say "ok... this and this and this and this is unit tested, it's 'green', I skip that". And then you will find, there's only so much code left, that must the source of the problem, because everything else can't be. And then you hunt a bug, instead of wasting hours in debugging code, that is not the root cause of the troubles.
In the project template you will find a configuration unit_testing
(in the top right corner of GameMaker where you choose the platform, VM/YYC and so on, you also find the available configurations).
If you activate it, your game will behave differently, while it is active:
- It redirects your start room to the unit test room (don't panic! It does not change your project! This redirect is only active, while this configuration is active!)
- When you hit F5 now, the game will run all unit tests it can find in the current project, as long as the name of the unit test function matches the
UNIT_TEST_FUNCTION_PREFIX
that is set in theUnitTest_Configuration
script of your_GAME_SETUP_
folder. - The room will show you a list of tests and their result, just watch and look, if everything is
OK
and green.
Running the _unit_testing_
will also launch all of raptor's internal unit tests, unless you change the prefix to something else than the default of unit_test_
.
I recommend, that you create one test script for each component of your game. As an example, see the _unit_tests_
folder in the gml_raptor folder:
This way, you will never lose control, where to look for a specific test.
In terms of UTE, each of these files is called a Test Suite.
The best way to learn, how to write a unit test file is, to look into existing files.
We'll go through one of them step-by-step now.
- Make sure, the function is declared only when you have the
unit_testing
configuration active.
This should be the first line in your script file:
if (!CONFIGURATION_UNIT_TESTING) exit;
- Create the unit test function and make sure, the name of the function matches the
UNIT_TEST_FUNCTION_PREFIX
:
function unit_test_Strings() {
- Create a unit test instance (I name mine always
ut
, short for unit test, because it's quick to write):
// The argument "Strings" is just the name of the test suite.
// You will see this name when running the test, so give it a senseful name
var ut = new UnitTest("Strings");
- Write your tests (more details about the functionality here a bit below, after the step-by-step explanation)
ut.tests.skip_end_ok = function(test, data) {
var t = string_skip_end("1234567890", 1);
test.assert_equals("123456789", t, "skip 1");
t = string_skip_end("1234567890", 3);
test.assert_equals("1234567", t, "skip 3");
};
- Run the test suite (this is the last line of your function)
ut.run();
}
Click here to see the whole script as one block
if (!CONFIGURATION_UNIT_TESTING) exit;
function unit_test_Strings() {
var ut = new UnitTest("Strings");
ut.tests.skip_end_ok = function(test, data) {
var t = string_skip_end("1234567890", 1);
test.assert_equals("123456789", t, "skip 1");
t = string_skip_end("1234567890", 3);
test.assert_equals("1234567", t, "skip 3");
};
ut.run();
}
You create a new test by adding a function to ut.tests.
:
ut.tests.skip_end_ok = function(test, data) {
Naming convention: In the industry you will find many test projects, that follow this scheme for naming a unit test:
<tested_function>[_situation]_<expected_result>
This means: A test is named as the function it tests, followed by the situation of the test (optional), followed by the expected result, like in:
skip_start_ok
skip_start_throws_exception
skip_start_negative_index_ok
skip_start_longer_than_string_ok
... and so on ...
You do not have to follow this rule, but it makes the test way better readable, if you do.
The test function will receive two arguments from the test engine:
-
test
- The instance of the test (=ut
). You need this toassert_*
your results -
data
- A custom data object you have defined for each of your tests (more on that later)
A unit test should follow this scheme:
- Create the instance you want to test
- Execute the method you want to test
- Check the results (assert errors)
This scheme is also referred to as ICE
test: I
nstanciate-C
all-E
xamine.
In GameMaker, we also have global functions, so there's not always an instance involved, but at least the Call-Examine part is true here too.
Having this information, let's take a look again at the unit test above:
ut.tests.skip_end_ok = function(test, data) {
var t = string_skip_end("1234567890", 1);
test.assert_equals("123456789", t, "skip 1");
t = string_skip_end("1234567890", 3);
test.assert_equals("1234567", t, "skip 3");
};
- The test is named
skip_end_ok
. So we know, it will test the functionskip_end
and we expect a positive result. - In the function, you see a
var t = string_skip_end(...)
. This is theC
, the Call. -
test.assert_equals
will check, whether the contents oft
, our result of the function call, matches the expected result. This is theE
xamine.
Note
We ALWAYS spend the call a variable for the result and examine the result.
We NEVER call the function directly in an assert statement!
This here would not be correct (although it would work), when you follow a clean testing scheme:
test.assert_equals("123456789", string_skip_end("1234567890", 1), "skip 1");
- This test is harder to read
- You have no clear variable, where the result is stored.
- If you want to assert multiple things on the result you have to call the method again and again and again. It shall run only once.
Tip
Avoid writing your test like this. Always make a clear call to the tested function and keep the result in a variable!
The test engine offers a series of assert
functions, which will report the result to the test engine, so it can print the outcome in the test report to the log and the unit test room.
/// @func assert_equals(expected, actual, message = "")
/// @desc performs an equality value check. test fails, if "expected != actual"
static assert_equals = function(expected, actual, message = "") {
/// @func assert_not_equals(expected, actual, message = "")
/// @desc performs an non-equality value check. test fails, if "expected == actual"
static assert_not_equals = function(expected, actual, message = "") {
/// @func assert_true(actual, message = "")
/// @desc performs a value check for true. test fails, if actual == false
static assert_true = function(actual, message = "") {
/// @func assert_false(actual, message = "")
/// @desc performs a value check for false. test fails, if actual == true
static assert_false = function(actual, message = "") {
/// @func assert_null(actual, message = "")
/// @desc performs a value check against "undefined" and "noone". test fails, if actual is neither.
static assert_null = function(actual, message = "") {
/// @func assert_not_null(actual, message = "")
/// @desc performs a value check against "undefined" and "noone". test fails, if actual is any of them.
static assert_not_null = function(actual, message = "") {
/// @func fail(message = "")
/// @desc fails the test immediately. Use this, if you expected an exception and your test
/// should never reach this line.
static fail = function(message = "") {
/// @func success()
/// @desc Sets the test as being successful. Use this, if you expect an exception, in your CATCH.
static success = function() {
/// @func expect_exception(_error_message_contains = "")
/// @desc Instead of writing your own try/catch in a test, you can tell the engine, that you
/// expect an exception with a given error text. If it occurs, the test is successful.
static expect_exception = function(_error_message_contains = "") {
As mentioned above, there is a second argument delivered to each test function, the data
.
UTE allows you to define global test data for each test, so you can avoid copying lots of code in each test. Instead, you define it once as suite-global and have it delivered to each unit test within the suite.
For this, there are four functions that work on suite- or test-level to aid you with common data to be used across tests.
Define these in your suite, and they will get called:
suite_start
gets invoked before the first test runs. You receive a data
object. Anything you put into this data
, will be delivered to each test of the suite and can be picked up there. See this example:
ut.suite_start = function(data) {
data.test_server_url = "https://my.cool.server/unit_testing/";
data.transmit_buffer = buffer_create(16384, buffer_grow, 1);
}
suite_finish
gets invoked after the last test of the suite. It is meant to clean up resources allocated, like buffers or any ds_*
lists/maps.
ut.suite_finish = function(data) {
buffer_delete(data.transmit_buffer);
}
Similar to the two suite-functions, there's also a start/finish pair for each test:
test_start
gets invoked before each test. You will receive the name of the test, that is about to start (this is the name of the test function, like "skip_end_ok"
).
ut.test_start = function(current_test_name, data) {
buffer_fill(data.transmit_buffer, 0, buffer_u8, 0, -1);
}
test_finish
gets invoked after each test. As with test_start, you will receive the name of the test, that just finished.
ut.test_finish = function(current_test_name, data) {
}
In GameMaker, there are several situations, where you have to deal with async code. Be it async file access or network traffic or any other situation available through the async
events.
To allow testing of async code, UTE offers two functions, to tell the engine, that the test is not finished, when it reaches the end of the code. It tells the engine to wait until the callback arrives.
/// @func start_async(_timeout_frames = 60)
/// @desc Tell the engine to wait for "finish_async" or a timeout
static start_async = function(_timeout_frames = 60) {
/// @func finish_async()
/// @desc Tell the engine, that the async operation is complete
static finish_async = function() {
This is explained best through an example of one of the async-file unit tests in raptor:
(I shortened the test code to the relevant parts, look for // <--
hints, to understand, what's going on)
ut.tests.file_text_plain_async_ok = function(test, data) {
test.start_async(); // <-- Tell the engine to switch to async mode
file_write_text_file_async(testfile, testcontent, testkey)
.on_finished(function(res) { // <-- This is the async callback, arriving... "later"
global.test.assert_not_null(...);
global.test.assert_true(...);
global.test.finish_async(); // <-- Tell the engine, async is complete.
}).start();
}
If you examined the code above closely, you have recognized, that the assert_
calls are not done through test.assert_*
, but through global.test.assert_*
.
Due to the nature of async testing, your main thread has left the test function some time ago, so test is no longer an active and not even a known pointer in the async callback. That's why UTE provides you a global variable, global.test
to access the currently (async) running test. It points to the same test instance, the test
argument of the unit test function had.
This means, you can even assert anything as usual, you just use global.test
while in the async callback.
I know, writing unit tests takes a bit of time, but the more experience you gain in this topic, the faster you get. And in the long run, this is a fact:
UNIT TESTS SAVE TIME
Yes, the immediate progress is a bit slower but you get this "lost" time back multiple times, when you start polishing your game and start hunting all the day-1-glitches and inconsistencies. You will never face most of them, if your background code is already thoroughly tested with unit tests!