Godot-SFT is a extremely simple testing library for Godot 4 GDExtension C++ that allows you to easily test your extension code with C++ macros.
After a lot of searching I couldn't find a single testing framework for C++ that wasn't either a massive pain to get working with gdextension or just way too complicated for no good reason. So here is my stupid simple testing "library" that does everything I will ever need it to do.
// Test.cpp
#include "godot_cpp/classes/packed_scene.hpp"
#include "godot_cpp/variant/utility_functions.hpp"
#include "godot_cpp/classes/resource_loader.hpp"
#include "SFT.hpp"
#include "CustomObject.hpp"
#ifdef TESTS_ENABLED
// clang-format off
void test_dictionary() {
godot::Dictionary map;
map["Hello"] = 0;
map["Hey"] = 999;
map["this_test_will_fail"] = -1;
TESTS(
"dictionary_test",
map["Hello"] == godot::Variant(0),
map["Hey"] == godot::Variant(999),
map["this_test_will_fail"] == godot::Variant(999),
map.has("Howdy"),
map.size() == 3
)
NAMED_TESTS(
"Dictionary Variant Test",
"Check equal to 0", VAR_CHECK(map["Hello"], 0),
"Check equal to 999", VAR_CHECK(map["Hey"], 999),
"This will always fail why even test it?", VAR_CHECK(map["this_test_will_fail"], 999),
"Check for non-existent member", map.has("Howdy"),
)
}
void test_custom_object() {
TEST_OBJECT(CustomObject, custom_object) // TEST_OBJECT safely tests an object in a way that will never skip any other tests (unless your test code causes a crash), see SFT.hpp for more info.
NAMED_TESTS(
"CustomObject",
"get_name", STRING_CHECK(custom_object->get_name(), "WrongName"),
"get_custom_function", VAR_CHECK(custom_object->get_custom_function(), "CustomFunctionReturn")
)
TEST_OBJECT_END(custom_object) // TEST_OBJECT_END must be put at the end of the thing TEST_OBJECT is testing so it can clean itself up.
}
void test_custom_scene() {
Control *root_node;
TEST_SCENE("res://scenes/main_menu.tscn", Control, root_node) // Same API as TEST_OBJECT but it instantiates the scene and from it's path.
NAMED_TESTS(
"MainMenu Tests",
"visibility", root_node->is_visible()
)
TEST_SCENE_END(root_node) // TEST_SCENE_END must be put at the end of the thing TEST_SCENE is testing so it can clean itself up, see SFT.hpp for more info.
// Additional checks are optional, if you don't pass in any more it will still test if the scene is possible to instantiate.
Control *broken_root_node
TEST_SCENE("res://scenes/broken_scene.tscn", Control, broken_root_node)
TEST_SCENE_END(broken_root_node)
}
// clang-format on
void do_tests() {
test_dictionary();
test_custom_object();
test_custom_scene();
}
#endif // TESTS_ENABLED
// Somewhere else...
#ifdef TESTS_ENABLED
do_tests()
#endif
You can test any condition with a named test suite as the first argument and then all other arguments will be the conditions to check for the test. The conditions can be any code that returns a bool.
The TESTS
macro can be used to check any number of conditions.
The NAMED_TESTS
macro can be used to check any number of conditions and also give a name to each check, the output of the test_dictionary
and test_custom_object
functions in the above example looks like this:
------------------------------------------------------------------------------------------------------------------------------------------------------
dictionary_assignment
1 | Passed
2 | Passed
3 | Failed [Tests.cpp:16] - map["this_test_will_fail"] == godot::Variant(999)
4 | Failed [Tests.cpp:16] - map.has("Howdy")
5 | Passed
------------------------------------------------------------------------------------------------------------------------------------------------------
Dictionary Variant Test
Check equal to 0 | Passed
Check equal to 999 | Passed
This will always fail why even test it? | Failed [Tests.cpp:24] - map["this_test_will_fail"] == godot::Variant(999)
Check for non-existent member | Failed [Tests.cpp:24] - map.has("Howdy")
Check if size is 3 | Passed
------------------------------------------------------------------------------------------------------------------------------------------------------
custom_object_tests
CustomObject nullptr test | Passed
CustomObject get_name | Failed [Tests.cpp:41] - custom_object->get_name() = StringName("WrongName")
CustomObject get_custom_function | Passed
------------------------------------------------------------------------------------------------------------------------------------------------------
MainMenu Tests
visibility | Passed
Scene Test: scenes/main_menu.tscn | Passed
Scene Test: scenes/main_menu.tscn | Failed [Tests.cpp:64] - res://scenes/broken_scene.tscn could not be instantiated.
Note that the DEBUG_ENABLED
define must be enabled at compile time to use the macros to prevent you from accidentally shipping test code. You can enable it by using the debug template for the godot-cpp.
This way when you compile with the debug template the tests will also be compiled.
I highly recommend you use the clangd LSP or the macros might be kind of awful to debug if you do something wrong. The clangd preprocessor handling is so awesome it's almost impossible to make mistakes with macros.
IMPORTANT NOTE: SFT uses __VA_OPT__
for some of the macro magic, which means it requires C++20 so you'll need to add the following to your SConstruct file to compile your GDExtension with C++20
# Generate build environment
def generate(env):
# Enable C++20.
if env.get("is_msvc", False):
env["CXXFLAGS"].remove("/std:c++17")
env["CXXFLAGS"].insert(0, "/std:c++20")
else:
env["CXXFLAGS"].remove("-std=c++17")
env["CXXFLAGS"].insert(0, "-std=c++20")
generate(env)
See the demo/ directory for a simple example of SFT usage. To setup the demo project first run to initialize godot-cpp git submodule update --init --recursive
To build the gdextension cd demo
and then scons target=template_debug
Once the demo is built you will be able to open the demo/game/project.godot
file and then run the project to see the test results in the console.
The tests can be run from anywhere in your gdextension code, running them from the initialize function in register_types.cpp has been working for me. Some tests I run from the _enter_tree
function of the root node in my project and only in the editor because instantiating certain things in register_types can sometimes lead to crashes if your test code needs to allocate something the engine can't allocate yet. This will only print the output to stdout though so if you want to have your test results print in the godot editor console you'll have to run them from somewhere else in your code that gets initialized later in the startup process.
Setting up automated testing is easy and you should do it. It only takes a few minutes to setup and can go a long way in preventing regressions in your code. If your tests print to stdout (they will unless you change SFT.hpp) you can call godot with godot -e --headless --quit
in your project's root to get godot to print your test results and then immediately quit.
I have provided 2 simple scripts (you only need 1) automated_testing.gd
and automated_testing.sh
that can be run with the following commands to automate tests using something like pre-commit or github actions:
With the gdscript:
touch output.txt && godot -e --headless --quit > output.txt && godot --headless --script ./automated_testing.gd
With the bash script:
touch output.txt && godot -e --headless --quit > output.txt && ./automated_testing.sh
Or if you don't want to save a seperate script you can do it with this totally not disgusting bash one liner:
bash -c 'touch output.txt && /home/dm/Documents/GameDev/godot/bin/godot.linuxbsd.editor.x86_64 -e --headless --quit &> output.txt && output_file_path="output.txt"; grep "Failed" "$output_file_path" > failed_tests.txt; rm $output_file_path; if [[ -s failed_tests.txt ]]; then echo "Failed tests:"; cat failed_tests.txt; rm failed_tests.txt; exit 1; else rm failed_tests.txt; exit 0; fi;'
I also provide a .pre-commit-config.yaml
file that you can use as a template to quickly setup pre-commit. It will format and lint all the common components of any godot project in these steps:
- ruff to lint and format all your scons/python code.
- gdscript linter and formatter to lint and format all your gdscript code.
- clang-format to format all your C++ code (you will need to change the
find
command in the pre-commit config unless you somehow organize your code exactly like I do) - Finally it will run all your SFT tests.
pre-commit will run all this before you make any commits so any code you commit will have to be formatted and tested before it even gets committed.
To add this to your project:
- Install pre-commit with
sudo pacman -S pre-commit
- Copy the
.pre-commit-config.yaml
from this repo into the root of your repository - Run
pre-commit install
. Now pre-commit will run all the checks before you make any new commits! You can test if it is working withpre-commit run --all-files
This setup for pre-commit I've provided will only work on Linux, if you are on Windows you'll have to figure out how to setup the automated testing on your own.
Testing is stupid
I hate stupid fucking tests
Why must I test it