Skip to content

Test framework

Basalt includes its own test framework, basalt_test. It is part of the framework rather than a third-party dependency, so testing code built on Basalt needs nothing downloaded, vendored or version-matched.

It is built to stay out of your build’s way. Test.h includes six Basalt headers and no system headers at all — no STL, and no <regex> behind the filtering. Complete, though, in the ways that decide whether a test suite is pleasant to live in: suites, cases and nested sub-cases; the full assertion matrix with both operand values reported on failure; glob filtering at every level; machine-readable output; and a way to test that an assertion fires.

You write a test as a named block, assert with CHECK or REQUIRE, and run the resulting executable. There is no main to write, no fixture class to declare, and no registration call to remember.

tests/core/string_tests.cpp
#include "Test.h"
#include "String.h"
using namespace bs;
TEST_SUITE("strings")
{
TEST_CASE("Str8Trim removes surrounding whitespace")
{
CHECK(Str8Equal(Str8Trim(Str(" hello ")), Str("hello")));
CHECK(Str8IsEmpty(Str8Trim(Str(" "))));
}
}

That is a complete test file. Add it to the test executable, build, and run it:

$ basalt_core_tests --test-case="Str8Trim*"
PASSED — 1 cases (1 passed, 0 failed), 2 checks (0 failed), 0 ms

Change the second assertion to something wrong on purpose, and use the operand form so the values are reported:

CHECK_EQUAL(Str8Trim(Str(" hi ")).size, 3u); // it is 2
$ basalt_core_tests --test-case="Str8Trim*"
CHECK failed [strings/Str8Trim removes surrounding whitespace]
tests/core/string_tests.cpp:13
Str8Trim(Str(" hi ")).size == 3u
lhs = 2, rhs = 3
FAILED — 1 cases (0 passed, 1 failed), 2 checks (1 failed), 0 ms

The report gives you the path to the case, the file and line, the expression as you wrote it, and both operand values. The process exit code is non-zero, so a build system or CI step fails without needing to parse anything.

Three things you did not have to write:

  • No main, and no registration call. The case made itself known to the runner; basalt_test supplies main, argument parsing and the reporters.
  • The failing CHECK did not stop the run. It recorded the failure and execution continued to the next line.
  • Stable output order. Cases are sorted before running, so two runs of the same binary produce the same lines in the same order — diffable, and safe to assert on in CI.

Link basalt_test and compile your test sources into the test executable:

add_executable(my_tests
tests/parser_tests.cpp
tests/geometry_tests.cpp
)
target_link_libraries(my_tests PRIVATE basalt_test my_library)
add_test(NAME my_tests COMMAND my_tests)

basalt_test provides main() and depends only on basalt_core.

One flag matters: your test target needs exceptions enabled (/EHsc, or the absence of -fno-exceptions), because REQUIRE throws internally to abandon a case from arbitrary depth. Basalt itself ships with exceptions off, and the library you are testing keeps its own flags — only the test target changes.

If your project has several modules, give each one its own test executable linking only that module’s dependencies. An accidental dependency then shows up as a link error rather than passing unnoticed.

Every assertion exists in both forms. The choice is whether the rest of the case is still worth running once this one has failed.

On failure Use when
CHECK... Records it, execution continues The following assertions still mean something
REQUIRE... Records it and abandons the current pass Continuing would crash or test nothing
Result<Arena*, MemoryError> created = Arena::Create();
REQUIRE(created.ok); // without an arena, every line below dereferences null
Arena* arena = created.value;

A CHECK on a pointer followed by a dereference is the mistake this distinction prevents.

Both styles work; they differ only in what the failure tells you.

// Expression form: prints the source text.
CHECK(list.size() == 4);
// list.size() == 4
// Operand form: prints the values too.
CHECK_EQUAL(list.size(), 4u);
// list.size() == 4u
// lhs = 7, rhs = 4

What the value actually was is usually the whole question, so use the operand forms for comparisons and keep the expression form for conditions that are not comparisons.

Available with both the CHECK_ and REQUIRE_ prefixes: EQUAL, NE, LT, LE, GT, GE, plus TRUE, FALSE, NULL and NOT_NULL.

A SUB_CASE is a named section inside a case. The case body re-runs once per sub-case, entering exactly one of them each time.

TEST_CASE("arena reuse")
{
Result<Arena*, MemoryError> created = Arena::Create();
REQUIRE(created.ok);
Arena* arena = created.value; // runs for every sub-case
SUB_CASE("push zero-initialises")
{
Span<u32> values = arena->Push<u32>(4);
CHECK_EQUAL(values.size, 4u);
}
SUB_CASE("position is monotonic")
{
// ...
}
arena->Release(); // runs for every sub-case
}

The runner executes this body twice. Each pass creates a fresh arena, enters one sub-case, and releases it. So neither sub-case can be polluted by the other, and three things follow:

  • Shared setup is ordinary straight-line code, written above the sub-cases.
  • Isolation comes from re-execution, so no state survives between passes and nothing needs resetting.
  • Adding a case costs three lines and no new declarations.

Basalt uses BS_ASSERT for contract violations — an out-of-range index, an append to a full Array<T>, a Pop on an empty one. Those paths need testing, and an assertion normally aborts the process.

ExpectAssert arms an expectation for the current thread. While it is alive, a failing BS_ASSERT records itself and returns instead of aborting.

TEST_CASE("Pop on an empty array asserts")
{
u32 storage[4] = {};
Array<u32> array = Array<u32>::FromSpan(Span<u32>(storage, 4));
{
ExpectAssert expected;
array.Pop(); // would normally abort the process
CHECK(expected.Fired());
CHECK_EQUAL(expected.Count(), 1u);
} // disarmed here
}

The expectation is thread-local and bounded by the scope. Arming it in one test does not change assertion behaviour on any other thread, and a test that forgets to check Fired() cannot leave assertions disarmed for the rest of the run.

LastInfo() returns the AssertInfo of the most recent firing, when you need to know which assertion fired.

Filters use a small glob: * matches any run of characters, and | separates alternatives.

Flag Effect
--test-case=<glob> Run only matching cases. Also --test-suite= and --test-subcase=.
--test-case-exclude=<glob> Skip matching cases. Also -suite- and -subcase-.
--list-test-cases Print suite/case names and exit.
--reporter=console|json Output format. console by default.
--no-color Disable colour. Also off automatically when output is not a terminal.

A case runs when it matches the includes — an empty include list means all — and matches none of the excludes.

--reporter=json emits one JSON object per line, which is the mode to use from a script or an agent:

{"type":"test_case_begin","path":"strings/Str8Trim removes surrounding whitespace"}
{"type":"assertion_failed","path":"strings/Str8Trim removes surrounding whitespace","severity":"check","file":"tests/core/string_tests.cpp","line":13,"expr":"Str8Trim(Str(\" hi \")).size == 3u","op":"==","lhs":"2","rhs":"3"}
{"type":"test_case_end","path":"strings/Str8Trim removes surrounding whitespace","status":"fail","checks":2,"failures":1,"duration_ns":41200}

Automated consumers are an expected reader of test output here, not an afterthought.

The active test context is thread-local and installed on the runner thread, so CHECK, REQUIRE and SUB_CASE are valid only on that thread.

A concurrency test spawns its workers, joins them, and asserts afterwards:

TEST_CASE("fetch-add converges")
{
AtomicU32 counter = {};
// ... spawn threads that each add 1000 ...
// ... join them all ...
CHECK_EQUAL(counter.Load(Ordering::Relaxed), 8000u);
}

Assertions from inside a worker thread are not supported.

Macro behaviour and reporting details are documented in basalt/test/Test.h.

Macro Purpose
TEST_SUITE(name) Names the suite for cases defined inside the block.
TEST_CASE(name) A test case. Self-registers; the unit of execution.
SUB_CASE(name) A section. Re-runs the case body, entering one section per pass.
Macro Purpose
CHECK(expr) / CHECK(expr, message) Report on failure and continue.
REQUIRE(expr) / REQUIRE(expr, message) Report and abandon the pass.
CHECK_EQUAL CHECK_NE CHECK_LT CHECK_LE CHECK_GT CHECK_GE Comparisons that print both operands.
REQUIRE_EQUALREQUIRE_GE The same, fatal.
CHECK_TRUE CHECK_FALSE CHECK_NULL CHECK_NOT_NULL Single-operand forms.
REQUIRE_TRUEREQUIRE_NOT_NULL The same, fatal.
Signature Purpose
ExpectAssert RAII: while alive, a failing BS_ASSERT records and returns.
Fired() Whether an assertion fired during the scope.
Count() How many fired.
LastInfo() The AssertInfo of the most recent firing.