Learn Simulant
Everything you need to know to build games with Simulant
Testing Framework
Simulant includes a built-in unit testing framework designed specifically for game engine testing. It provides fixtures for Application, Window, and Scene objects so you can test engine features without writing boilerplate.
Related documentation:
1. Overview
The Simulant testing framework is a lightweight, header-only test runner. It uses:
TestCase-- A base class for tests that do not need engine fixtures (math, strings, utility functions).SimulantTestCase-- A subclass ofTestCasethat automatically creates a headlessApplication,Window, andScene. Use this for anything that interacts with the engine (nodes, physics, coroutines, materials, etc.).- Assertion macros --
assert_equal,assert_true,assert_false,assert_close,assert_raises, and more. - Automatic test discovery -- A Python script (
tools/test_generator.py) scans your test headers for classes that inherit fromTestCaseorSimulantTestCaseand generates amain.cppthat registers everytest_*method.
Tests are compiled into a single simulant_tests executable. You run all tests, a subset, or a single test by passing a class name prefix.
2. Test File Structure and Organisation
Test files live in the tests/ directory of your project. By convention each file is named test_<feature>.h and contains one or more test case classes.
tests/
├── test_coroutines.h # Coroutine-related tests
├── test_physics.h # Physics/collider tests
├── test_scene.h # Scene and SceneManager tests
├── test_object.h # StageNode / transform tests
├── test_math.h # Pure math tests (no fixtures)
└── ...
The tests/CMakeLists.txt glob collects all *.h files and passes them to the test generator, which produces a main.cpp in the build directory. You rarely need to touch main.cpp directly -- it is regenerated automatically whenever test headers change.
Naming conventions
- File:
test_<lowercase_feature>.h(e.g.test_physics.h,test_coroutines.h) - Class:
<FeatureName>Testsor<FeatureName>Test(e.g.ColliderTests,MathTest) - Method:
test_<lowercase_description>(e.g.test_create_kinematic_body)
3. SimulantTestCase Base Class
SimulantTestCase is defined in simulant/test.h. It extends TestCase and provides three ready-to-use protected members that are initialised before every test:
| Member | Type | Description |
|---|---|---|
application |
std::shared_ptr<Application> |
The headless application instance |
window |
Window* |
The test window (640x480, windowed) |
scene |
Scene* |
The active scene ("main") |
Lifecycle
set_up()-- Called before each test method.SimulantTestCase::set_up()creates theApplication(once) and resets the scene for every subsequent test.- Your test method runs.
tear_down()-- Called after each test method, even if the test threw an exception.
Always call the parent implementation when you override these methods:
class MyTests : public smlt::test::SimulantTestCase {
public:
void set_up() {
SimulantTestCase::set_up(); // Required
// Your additional setup...
}
void tear_down() {
// Your additional cleanup...
SimulantTestCase::tear_down(); // Required
}
};
Reusing the Application
The Application is created once and reused across all tests in a test run. Between tests the scene is unloaded and reloaded, and all coroutines are stopped. This keeps test startup fast while ensuring test isolation.
4. TestApp and TestScene for Fixtures
When SimulantTestCase creates the application it uses two internal classes:
TestApp
A minimal Application subclass that registers a TestScene under the name "main". The app config sets up:
- 640x480 windowed mode
LOG_LEVEL_WARNlog level- Search paths:
assets/andsample_data/
TestScene
A minimal Scene subclass whose on_load() is empty. It serves as a clean slate for every test.
If you need custom scene behaviour in your tests, simply create your own scene class and use scene methods to populate it:
void test_my_feature() {
auto stage = scene->create_child<smlt::Stage>();
auto camera = scene->create_child<smlt::Camera3D>();
// ... test logic
}
5. Assertion Macros
All assertion macros are defined in simulant/test.h and are available in any class that inherits from TestCase or SimulantTestCase.
| Macro | Purpose | Example |
|---|---|---|
assert_equal(expected, actual) |
Fail if expected != actual |
assert_equal(5, result); |
assert_not_equal(lhs, rhs) |
Fail if values are equal | assert_not_equal(id1, id2); |
assert_true(value) |
Fail if value is falsy | assert_true(node); |
assert_false(value) |
Fail if value is truthy | assert_false(is_active); |
assert_close(expected, actual, tolerance) |
Fail if values differ by more than tolerance |
assert_close(1.0f, result, 0.001f); |
assert_is_null(ptr) |
Fail if pointer is not null | assert_is_null(node); |
assert_is_not_null(ptr) |
Fail if pointer is null | assert_is_not_null(hit); |
assert_raises(ExceptionType, lambda) |
Fail if the lambda does not throw ExceptionType |
assert_raises(std::logic_error, [&]() { risky_call(); }); |
assert_items_equal(containerA, containerB) |
Fail if containers have different sizes or missing items | assert_items_equal(found, expected); |
not_implemented() |
Mark a test as not yet implemented (shows as SKIPPED) | not_implemented(); |
Usage examples
void test_equality() {
auto node = scene->create_child<smlt::Stage>();
assert_equal(0, node->child_count());
assert_true(node->is_visible());
assert_is_not_null(node->transform);
}
void test_close_enough() {
float result = 1.0f / 3.0f;
assert_close(0.333333f, result, 0.00001f);
}
void test_exception() {
assert_raises(std::logic_error, [&]() {
manager->activate("nonexistent");
});
}
void test_container() {
std::vector<int> found = {1, 2, 3};
std::vector<int> expected = {3, 1, 2}; // order does not matter
assert_items_equal(found, expected);
}
6. Writing Your First Test
Here is the minimum test that uses engine fixtures:
// tests/test_hello.h
#pragma once
#include "simulant/test.h"
namespace {
using namespace smlt;
class HelloTests : public smlt::test::SimulantTestCase {
public:
void test_scene_exists() {
// scene and application are provided by SimulantTestCase
assert_is_not_null(scene);
assert_is_not_null(window);
assert_is_not_null(application);
}
void test_create_node() {
auto node = scene->create_child<smlt::Stage>();
assert_is_not_null(node);
assert_equal(0u, node->child_count());
}
};
}
That is it. The test generator will find HelloTests, discover both test_* methods, and register them. Rebuild and run:
simulant build
simulant test HelloTests
Tests that do not need engine fixtures
If you are testing pure logic (math, string utils, etc.), inherit from smlt::test::TestCase instead. This avoids the overhead of creating an Application:
// tests/test_math.h
#pragma once
#include "simulant/test.h"
namespace {
using namespace smlt;
class MathTest : public smlt::test::TestCase {
public:
void test_lerp() {
assert_equal(0.0f, smlt::lerp(100.0f, -100.0f, 0.5f));
assert_equal(50.0f, smlt::lerp(-100.0f, 100.0f, 0.75f));
}
};
}
7. Testing Scenes and StageNodes
Creating and verifying nodes
void test_create_geom() {
auto mesh = scene->assets->create_mesh(smlt::VertexSpecification::DEFAULT);
mesh->create_submesh_as_cube("cube", scene->assets->create_material(), 1.0f);
auto geom = scene->create_child<smlt::Geom>(mesh);
assert_equal(STAGE_NODE_TYPE_GEOM, geom->node_type());
}
Testing parent-child relationships
void test_child_inherits_transform() {
auto parent = scene->create_child<smlt::Stage>();
auto child = scene->create_child<smlt::Stage>();
child->set_parent(parent);
parent->transform->set_translation(Vec3(0, 10, 0));
assert_equal(Vec3(0, 10, 0), child->transform->position());
assert_equal(Vec3(), child->transform->translation()); // relative is zero
}
Testing deferred destruction
Nodes are not destroyed immediately when you call destroy(). They are cleaned up at the end of the next frame. Use application->run_frame() to trigger cleanup:
void test_node_destruction() {
auto count = scene->count_nodes_by_type<smlt::Stage>();
auto node = scene->create_child<smlt::Stage>();
assert_equal(count + 1, scene->count_nodes_by_type<smlt::Stage>(true));
node->destroy();
assert_true(node->is_destroyed());
// Node is still counted until the frame ends
assert_equal(count + 1, scene->count_nodes_by_type<smlt::Stage>(true));
application->run_frame();
// Now it is gone
assert_equal(count, scene->count_nodes_by_type<smlt::Stage>(true));
}
Iterating the scene graph
void test_descendent_iteration() {
auto root = scene->create_child<smlt::Stage>();
auto a = scene->create_child<smlt::Stage>();
auto b = scene->create_child<smlt::Stage>();
a->set_parent(root);
b->set_parent(root);
std::set<smlt::StageNode*> found;
for (auto& node : root->each_child()) {
found.insert(&node);
}
assert_items_equal(found, std::set<smlt::StageNode*>{a, b});
}
8. Testing Physics
Physics tests require the PhysicsService to be started on the scene. A common pattern is to start it in set_up() and stop it in tear_down():
class ColliderTests : public smlt::test::SimulantTestCase {
private:
PhysicsService* physics;
public:
void set_up() {
SimulantTestCase::set_up();
physics = scene->start_service<PhysicsService>();
physics->set_gravity(Vec3()); // disable gravity for predictable tests
}
void tear_down() {
scene->stop_service<PhysicsService>();
SimulantTestCase::tear_down();
}
void test_box_collider() {
auto body = scene->create_child<DynamicBody>();
body->add_box_collider(Vec3(2, 2, 1), PhysicsMaterial::wood());
auto hit = physics->ray_cast(Vec3(0, 2, 0), Vec3(0, -1, 0), 2);
assert_true(hit);
assert_close(1.0f, hit->distance, 0.0001f);
}
};
Collision listeners
Implement CollisionListener and register it on a body:
class TestListener : public CollisionListener {
public:
bool enter_called = false;
bool leave_called = false;
void on_collision_enter(const Collision& c) override { enter_called = true; }
void on_collision_exit(const Collision& c) override { leave_called = true; }
void on_collision_stay() override {}
};
void test_collision_events() {
TestListener listener;
auto body_a = scene->create_child<StaticBody>();
body_a->add_box_collider(Vec3(1, 1, 1), PhysicsMaterial::wood());
body_a->register_collision_listener(&listener);
auto body_b = scene->create_child<DynamicBody>();
body_b->add_box_collider(Vec3(1, 1, 1), PhysicsMaterial::wood());
// Step physics
physics->fixed_update(1.0f / 60.0f);
assert_true(listener.enter_called);
assert_false(listener.leave_called);
}
See Physics Overview for details on body types, colliders, and joints.
9. Testing Coroutines
Coroutines are tested using cr_await, cr_async, and cr_yield. You can test them synchronously in a single test method, or spread across multiple frames.
Synchronous coroutine test
void test_await() {
auto value = cr_await(
cr_async([]() -> int {
int j = 0;
for (int i = 0; i < 100; ++i) {
j++;
cr_yield();
}
return j;
})
);
assert_equal(value, 100);
}
Testing time-based coroutines
Use cr_yield_for() and manually advance time:
void test_yield_for() {
bool called = false;
auto ret = cr_async([&]() -> bool {
cr_yield_for(Seconds(0.1f));
called = true;
return true;
});
application->update_coroutines();
assert_false(called); // not ready yet
thread::sleep(200); // wait real time
application->update_coroutines();
assert_true(called); // now ready
}
Testing coroutine ordering
void test_coroutine_order() {
int counter = 3;
std::vector<int> order;
bool done = false;
std::function<void(int)> cb = [&](int a) {
if (a < 3) {
cr_async([&]() { cb(a + 1); });
}
while (!done) {
order.push_back(a);
++counter;
cr_yield();
}
};
cr_async([&]() { cb(1); });
application->run_frame();
application->run_frame();
application->run_frame();
assert_equal(order.size(), 9u);
assert_equal(order[0], 1);
assert_equal(order[1], 2);
assert_equal(order[2], 3);
done = true;
application->stop_all_coroutines();
}
Connecting to application signals
void test_runs_on_update() {
int update_count = 0;
auto sig = application->signal_update().connect([&](float) {
update_count++;
});
application->run_frame();
assert_equal(1, update_count);
sig.disconnect();
}
See Coroutines for the full coroutine API.
10. Running Tests
Building tests
Tests are built alongside your project when SIMULANT_BUILD_TESTS is enabled (on by default for most platforms):
simulant build
Running all tests
simulant test
This runs the simulant_tests executable. The CLI tool passes the working directory so that asset paths (assets/, sample_data/) resolve correctly.
Running a specific test class
Pass a class name prefix to filter tests:
simulant test CoroutineTests
simulant test ColliderTests
Only tests whose fully-qualified name starts with the given string will run.
Running a single test method
simulant test "CoroutineTests::test_await"
JUnit XML output
Generate a JUnit-compatible XML report for CI integration:
simulant test --junit-xml=report.xml
11. Test Output and Debugging
Coloured output
The test runner uses ANSI colour codes:
| Colour | Status |
|---|---|
Green (OK) |
Test passed |
Yellow (FAILED) |
Assertion failed |
Red (EXCEPT) |
Unexpected exception thrown |
Blue (SKIPPED) |
Test was skipped (not_implemented() or skip_if) |
Failure output
When a test fails, the runner prints:
- The assertion message
- The file and line number
- The source line that failed
CoroutineTests::test_yield_and_wait FAILED
false is not true
/home/user/project/tests/test_coroutines.h:67
assert_false(called);
Debugging a failing test
Run the specific test to isolate the failure:
simulant test "MyTests::test_something"Check asset paths. Tests expect
assets/andsample_data/relative to the working directory. If a test loads assets and fails, verify the paths exist.Use
LOG_LEVEL_WARNor lower. The test app defaults toLOG_LEVEL_WARN. You can change the config intest.hif you need more output.Step through in a debugger. The test executable is a normal binary. Attach GDB or your IDE debugger and set breakpoints on your test methods.
12. Skipping Tests
Skipping with not_implemented()
For tests you have not written the body of yet:
void test_future_feature() {
not_implemented();
}
This displays the test as SKIPPED in blue and does not count as a failure.
Conditional skipping with skip_if()
Skip a test at runtime based on a condition:
void test_platform_specific_feature() {
skip_if(!has_opengl(), "Requires OpenGL");
// ... test code
}
The reason string is displayed in the test output:
MyTests::test_platform_specific_feature SKIPPED
Known issues
void test_collision_stay() {
skip_if(true, "Not yet implemented");
// ... test code
}
This is useful for documenting known gaps in test coverage.
13. Test Best Practices
Keep tests isolated
Each test method should test one thing. Do not chain multiple assertions about different features in one method.
// Good
void test_node_position() { ... }
void test_node_rotation() { ... }
// Bad
void test_node_everything() { ... }
Reset state in set_up / tear_down
If you start services, register signals, or create persistent state, clean it up:
void set_up() {
SimulantTestCase::set_up();
physics = scene->start_service<PhysicsService>();
}
void tear_down() {
scene->stop_service<PhysicsService>();
SimulantTestCase::tear_down();
}
Disconnect signals
Use sig::scoped_connection so signals disconnect automatically when the connection object goes out of scope:
void test_signal() {
bool called = false;
sig::scoped_connection conn = node->signal_destroyed().connect([&]() {
called = true;
});
// conn disconnects automatically when function returns
}
Use application->run_frame() for deferred operations
Many engine operations are deferred (node destruction, pipeline cleanup, scene transitions). Call application->run_frame() to flush the deferred queue:
node->destroy();
application->run_frame(); // actual destruction happens here
assert_equal(0, scene->count_nodes_by_type<smlt::Stage>(true));
Stop coroutines
Always call application->stop_all_coroutines() at the end of tests that spawn coroutines, especially tests that use infinite loops:
void test_my_coroutine() {
bool done = false;
cr_async([&]() {
while (!done) {
cr_yield();
}
});
// ... assertions
done = true;
application->stop_all_coroutines();
}
Prefer SimulantTestCase when touching the engine
If your test creates Stage nodes, Geoms, materials, or anything that needs a Scene, use SimulantTestCase. Use plain TestCase only for pure utility logic.
14. Common Patterns in Existing Tests
Pattern: Physics service with gravity disabled
void set_up() {
SimulantTestCase::set_up();
physics = scene->start_service<PhysicsService>();
physics->set_gravity(Vec3()); // zero gravity for deterministic tests
}
Found in: ColliderTests, DynamicBodyTest
Pattern: Scene with custom scene class for lifecycle testing
class TestScene : public Scene {
public:
TestScene(Window* window) : Scene(window) {}
void on_load() override { load_called = true; }
void on_unload() override { unload_called = true; }
bool load_called = false;
bool unload_called = false;
};
void test_activate() {
manager_->register_scene<TestScene>("main");
TestScene* scr = dynamic_cast<TestScene*>(manager_->resolve_scene("main").get());
manager_->activate("main");
manager_->late_update(1.0f);
assert_true(scr->load_called);
}
Found in: SceneManagerTests
Pattern: Manual SceneManager lifecycle
class SceneManagerTests : public smlt::test::SimulantTestCase {
private:
SceneManager::ptr manager_;
public:
void set_up() {
SimulantTestCase::set_up();
manager_ = std::make_shared<SceneManager>(window);
manager_->init();
}
void tear_down() {
manager_->clean_up();
manager_.reset();
}
};
Pattern: Node counting for cleanup verification
void test_nodes_freed() {
auto count = scene->count_nodes_by_type<smlt::Stage>();
auto node = scene->create_child<smlt::Stage>();
assert_equal(count + 1, scene->count_nodes_by_type<smlt::Stage>(true));
node->destroy();
application->run_frame();
assert_equal(count, scene->count_nodes_by_type<smlt::Stage>(true));
}
Found in: SceneTests (for lights, cameras, geoms, particle systems, sprites, etc.)
Pattern: CollisionListener as a local helper class
class Listener : public CollisionListener {
public:
Listener(bool* enter, uint32_t* stay, bool* leave)
: enter_called(enter), stay_count(stay), leave_called(leave) {}
void on_collision_enter(const Collision& c) override { if(enter_called) *enter_called = true; }
void on_collision_stay() override { if(stay_count) *stay_count += 1; }
void on_collision_exit(const Collision& c) override { if(leave_called) *leave_called = true; }
bool* enter_called = nullptr;
uint32_t* stay_count = nullptr;
bool* leave_called = nullptr;
};
Pattern: Anonymous namespace
All test files wrap their classes in an anonymous namespace to avoid link-time conflicts when the generated main.cpp includes all test headers:
#pragma once
#include "simulant/test.h"
namespace {
using namespace smlt;
class MyTests : public smlt::test::SimulantTestCase {
// ...
};
}
15. Complete Test Example
This example combines multiple patterns into a single comprehensive test file:
// tests/test_my_feature.h
#pragma once
#include "simulant/simulant.h"
#include "simulant/test.h"
namespace {
using namespace smlt;
// --- Helper class for testing collision events ---
class TestCollisionListener : public CollisionListener {
public:
int enter_count = 0;
int stay_count = 0;
int exit_count = 0;
void on_collision_enter(const Collision&) override { enter_count++; }
void on_collision_stay() override { stay_count++; }
void on_collision_exit(const Collision&) override { exit_count++; }
};
// --- Main test class ---
class MyFeatureTests : public smlt::test::SimulantTestCase {
private:
PhysicsService* physics = nullptr;
public:
// --- Lifecycle ---
void set_up() {
SimulantTestCase::set_up();
// Start physics with no gravity
physics = scene->start_service<PhysicsService>();
physics->set_gravity(Vec3());
}
void tear_down() {
scene->stop_service<PhysicsService>();
SimulantTestCase::tear_down();
}
// --- Tests ---
void test_node_creation() {
auto stage = scene->create_child<smlt::Stage>();
stage->set_name("my_stage");
assert_is_not_null(stage);
assert_equal(STAGE_NODE_TYPE_STAGE, stage->node_type());
assert_equal("my_stage", stage->name());
}
void test_parent_child_transform() {
auto parent = scene->create_child<smlt::Stage>();
auto child = scene->create_child<smlt::Stage>();
child->set_parent(parent);
parent->transform->set_translation(Vec3(10, 0, 0));
parent->transform->set_rotation(
Quaternion(Vec3::up(), Degrees(90))
);
// World position follows parent
assert_close(10.0f, child->transform->position().x, 0.0001f);
// Local position is still zero
assert_equal(Vec3(), child->transform->translation());
}
void test_physics_raycast() {
auto body = scene->create_child<DynamicBody>();
body->add_box_collider(Vec3(1, 1, 1), PhysicsMaterial::wood());
auto hit = physics->ray_cast(Vec3(0, 5, 0), Vec3(0, -1, 0), 10);
assert_true(hit);
assert_close(4.0f, hit->distance, 0.001f);
}
void test_collision_events() {
TestCollisionListener listener;
auto body_a = scene->create_child<StaticBody>();
body_a->add_box_collider(Vec3(1, 1, 1), PhysicsMaterial::wood());
body_a->register_collision_listener(&listener);
auto body_b = scene->create_child<DynamicBody>();
body_b->add_box_collider(Vec3(1, 1, 1), PhysicsMaterial::wood());
// Single physics step triggers enter
physics->fixed_update(1.0f / 60.0f);
assert_equal(1, listener.enter_count);
assert_equal(0, listener.exit_count);
}
void test_coroutine_runs() {
int counter = 0;
auto promise = cr_async([&]() -> int {
for (int i = 0; i < 10; ++i) {
counter++;
cr_yield();
}
return counter;
});
int result = cr_await(promise);
assert_equal(10, result);
assert_equal(10, counter);
}
void test_deferred_destruction() {
auto count = scene->count_nodes_by_type<smlt::Stage>();
auto node = scene->create_child<smlt::Stage>();
node->destroy();
assert_true(node->is_destroyed());
// Still counted until frame end
assert_equal(count + 1, scene->count_nodes_by_type<smlt::Stage>(true));
application->run_frame();
// Now removed
assert_equal(count, scene->count_nodes_by_type<smlt::Stage>(true));
}
void test_node_hierarchy_cleanup() {
auto root = scene->create_child<smlt::Stage>();
auto child = scene->create_child<smlt::Stage>();
child->set_parent(root);
// Destroying the parent cascades to children
root->destroy();
application->run_frame();
assert_true(root->is_destroyed());
assert_true(child->is_destroyed());
}
void test_signal_connection() {
bool destroyed = false;
auto node = scene->create_child<smlt::Stage>();
sig::scoped_connection conn = node->signal_destroyed().connect([&]() {
destroyed = true;
});
node->destroy();
assert_true(destroyed);
// conn auto-disconnects when it goes out of scope
}
void test_not_yet_ready() {
skip_if(true, "Waiting for feature X to be merged");
// Future test code here
}
void test_placeholder() {
not_implemented();
}
};
} // anonymous namespace
Quick Reference Card
// Inherit from this for engine tests
class MyTests : public smlt::test::SimulantTestCase { ... };
// Inherit from this for pure-logic tests
class MyTests : public smlt::test::TestCase { ... };
// Assertions
assert_equal(expected, actual)
assert_not_equal(a, b)
assert_true(x)
assert_false(x)
assert_close(expected, actual, tolerance)
assert_is_null(ptr)
assert_is_not_null(ptr)
assert_raises(ExceptionType, lambda)
assert_items_equal(containerA, containerB)
not_implemented()
skip_if(condition, reason)
// Fixture members (SimulantTestCase)
application // std::shared_ptr<Application>
window // Window*
scene // Scene*
// Running
simulant test // all tests
simulant test CoroutineTests // by class
simulant test "Class::method" // single test
simulant test --junit-xml=report.xml // JUnit output