Zero Preprocessor Macros
Pure declarative syntax with zero preprocessor macros. Eliminates macro code pollution, identifier collisions, and hidden generated code.
Automated unit tests form the foundation of any resilient and maintainable software project. Yet, in the C++ ecosystem, establishing a testing suite historically requires adopting legacy third-party frameworks conceived over fifteen years ago (GoogleTest, Catch2, doctest, Boost.Test, CppUnit). While mature, these tools suffer from major architectural trade-offs:
TEST, TEST_F, TEST_CASE) and for each assertion (ASSERT_EQ, CHECK, etc.). These macros ignore static typing and namespaces, pollute the global namespace, and impede step-by-step debugging. Yet C++ cleanly achieves both test suite registration and value diagnostics for assertions without any preprocessor trickery.#include), these tools lack native integration with C++20 modules, increasing compilation times.The CppUtils::UnitTest module reimagines unit testing from the ground up for modern C++26:
Zero Preprocessor Macros
Pure declarative syntax with zero preprocessor macros. Eliminates macro code pollution, identifier collisions, and hidden generated code.
Dependency Graph
Deterministic execution via a Directed Acyclic Graph (DAG). Tests run according to their genuine dependencies, isolating the root cause without mocks.
Value Diagnostics
Test conditions directly using standard C++ operators (==, !=, <, etc.). On failure, the exact values compared are automatically displayed in the report without requiring dedicated macros.
Process Isolation
Every test executes inside a dedicated child process (fork()). Absolute resilience against fatal crashes (SIGSEGV) and infinite loops (enforced by a strict watchdog timer with SIGKILL).
Exception Monitoring
Parameterized Testing
Platform-Conditional Skipping
Deterministic Fuzzing
Deterministic pseudo-random generation to stress-test algorithms with unexpected payloads, including configurable pseudo-random string and integer sequences.
Integrated Benchmarks
Built-in performance measurements with comparative ratios and automated console warnings when a candidate implementation regresses against baseline standards.
Asynchronous Assertions
Periodic condition polling (eventually) or waiting on an asynchronous operation (completes).
Compile-Time Constexpr Tests
Validate your constexpr contracts and computations directly during compilation (suite.test<Predicate>). Instant build-time error detection with zero runtime execution overhead.
Universal CI/CD Reports
Standard JUnit XML report generation (--report). Native integration in GitLab CI and GitHub Actions to automatically publish metrics and failure annotations in pull requests.
CppUtils::UnitTest breaks free from the compromises of historical frameworks: zero preprocessor macros, direct expression capture, and automated suite scheduling via a dependency graph.
The interactive tabs below demonstrate an identical testing scenario expressed in each framework:
// Native C++20 module support (zero macros, zero header files)import std;import CppUtils;
namespace MyApp::Tests{ namespace ut = CppUtils::UnitTest;
// Suite declaration with dependencies auto _ = ut::testSuite("Math/Calculator").dependsOn("Logger") = [](ut::TestSuite& suite) { suite.test("Operations") = [] { // Assertions capturing actual runtime values without macros ut::expect(add(20, 22)) == 42; not ut::expect(isNegative(42)); ut::expect([&] { divide(10, 0); }) == ut::throws<std::invalid_argument>; };
// Parameterized test across a collection of values suite.test("Scale factor").forValues(10, 50, 100) = [](int scale) { ut::expect(scale) > 0; }; };}// Native C++20 module support (zero macros, zero header files)import std;import CppUtils;
namespace MyApp::Tests{ using namespace CppUtils::UnitTest;
// Suite declaration with dependencies auto _ = testSuite("Math/Calculator").dependsOn("Logger") = [](TestSuite& suite) { suite.test("Operations") = [] { // Assertions capturing actual runtime values without macros expect(add(20, 22)) == 42; not expect(isNegative(42)); expect([&] { divide(10, 0); }) == throws<std::invalid_argument>; };
// Parameterized test across a collection of values suite.test("Scale factor").forValues(10, 50, 100) = [](int scale) { expect(scale) > 0; }; };}import boost.ut;
int main() { // Recommended practice by Boost.UT documentation (an alias remains possible to avoid name collisions) using namespace boost::ut;
// Test declaration via literal suffix // No dependency management between suites (linear execution) "operations"_test = [] { // Requires literal suffixes (_i) to capture values on failure (limited to basic arithmetic types) expect(add(20, 22) == 42_i); expect(not isNegative(42)); expect(throws<std::invalid_argument>([] { divide(10, 0); })); };
// Parameterized test via overloaded pipe operator | "scale_factor"_test = [](int scale) { expect(scale > 0_i); } | std::vector{10, 50, 100};}// Native C++20 modules not supported (mandatory #include headers)#include <catch2/catch_test_macros.hpp>#include <catch2/generators/catch_generators.hpp>#include <stdexcept>
// Intrusive macros polluting the global namespace// No dependency management between suites (linear execution)TEST_CASE("Math/Calculator Operations", "[math]") { // Mandatory preprocessor macros to decompose operands REQUIRE(add(20, 22) == 42); REQUIRE_FALSE(isNegative(42)); REQUIRE_THROWS_AS(divide(10, 0), std::invalid_argument);}
// Parameterized test: the GENERATE macro re-executes the entire TEST_CASE bodyTEST_CASE("Math/Calculator Scale factor", "[math]") { auto scale = GENERATE(10, 50, 100); REQUIRE(scale > 0);}// Native C++20 modules not supported (mandatory #include headers)#include <gtest/gtest.h>#include <stdexcept>
// Intrusive macros polluting the global namespace// No dependency management between suites (linear execution)TEST(CalculatorTest, Operations) { EXPECT_EQ(add(20, 22), 42); // Distinct macro required for each operator EXPECT_FALSE(isNegative(42)); EXPECT_THROW(divide(10, 0), std::invalid_argument);}
// Parameterized test: requires a dedicated fixture class inheriting TestWithParam + 2 verbose macrosclass ScaleTest : public testing::TestWithParam<int> {};
TEST_P(ScaleTest, PositiveScale) { EXPECT_GT(GetParam(), 0);}
INSTANTIATE_TEST_SUITE_P(Values, ScaleTest, testing::Values(10, 50, 100));// Native C++20 modules not supported (mandatory #include headers)#include <doctest/doctest.h>#include <stdexcept>
// Intrusive macros polluting the global namespace// No dependency management between suites (linear execution)TEST_CASE("Math/Calculator") { SUBCASE("Operations") { CHECK(add(20, 22) == 42); // Macro-based expression decomposition CHECK_FALSE(isNegative(42)); CHECK_THROWS_AS(divide(10, 0), std::invalid_argument); }
// Parameterized test: the GENERATE macro re-executes the entire TEST_CASE body (like Catch2) SUBCASE("Scale factor") { auto scale = GENERATE(10, 50, 100); CHECK(scale > 0); }}// Native C++20 modules not supported (mandatory #include headers)#define BOOST_TEST_MODULE CalculatorTest#include <boost/test/included/unit_test.hpp>#include <boost/test/data/test_case.hpp>#include <stdexcept>
// Intrusive macros polluting the global namespace// No dependency management between suites (linear execution)// Hidden scope (namespace) injected without visible braces, enclosing any intermediate codeBOOST_AUTO_TEST_SUITE(Calculator)
BOOST_AUTO_TEST_CASE(Operations) { BOOST_TEST(add(20, 22) == 42); BOOST_TEST(!isNegative(42)); BOOST_CHECK_THROW(divide(10, 0), std::invalid_argument);}
// Parameterized test: requires heavy and complex dataset macroBOOST_DATA_TEST_CASE(ScaleFactor, boost::unit_test::data::make({10, 50, 100}), scale) { BOOST_TEST(scale > 0);}
BOOST_AUTO_TEST_SUITE_END() // Mandatory closure of the hidden scope opened by the suite macro// Native C++20 modules not supported (mandatory #include headers)#include <QTest>#include <stdexcept>
// Verbose declaration via class inheriting QObject// No dependency management between suites (linear execution)class CalculatorTest : public QObject { Q_OBJECTprivate slots: void testOperations() { QCOMPARE(add(20, 22), 42); // Forces tested types to adapt to the test framework by requiring overloads of the non-standard proprietary QTest::toString API for failure diagnostics QVERIFY(!isNegative(42)); QVERIFY_THROWS_EXCEPTION(std::invalid_argument, divide(10, 0)); }
// Parameterized test: requires 2 distinct methods and a dynamic table void testScale_data() { QTest::addColumn<int>("scale"); QTest::newRow("10") << 10; QTest::newRow("50") << 50; QTest::newRow("100") << 100; }
void testScale() { QFETCH(int, scale); QVERIFY(scale > 0); }};
QTEST_MAIN(CalculatorTest)
// Mandatory inclusion of MOC-generated code: reliance on a proprietary preprocessor injecting invisible code and applying non-standard "magic" ('Q_OBJECT', slots) to introspect tests#include "CalculatorTest.moc"// Native C++20 modules not supported (mandatory #include headers)#include "CppUnitTest.h"#include <stdexcept>
// Microsoft's official practice to access the Assert:: class, deliberately polluting the global namespace with their entire proprietary frameworkusing namespace Microsoft::VisualStudio::CppUnitTestFramework;
// Intrusive macros polluting the global namespace// No dependency management between suites (linear execution)TEST_CLASS(CalculatorTest) {public: TEST_METHOD(TestOperations) { Assert::AreEqual(42, add(20, 22)); Assert::IsFalse(isNegative(42)); Assert::ExpectException<std::invalid_argument>([] { divide(10, 0); }); }
// Parameterized test: no native mechanism, imperative loop without isolation TEST_METHOD(TestScale) { for (int scale : {10, 50, 100}) Assert::IsTrue(scale > 0); }};// Native C++20 modules not supported (mandatory #include headers)#include <cppunit/extensions/HelperMacros.h>#include <stdexcept>
// Intrusive macros and verbose fixture class// No dependency management between suites (linear execution)class CalculatorTest : public CppUnit::TestFixture { CPPUNIT_TEST_SUITE(CalculatorTest); CPPUNIT_TEST(testOperations); CPPUNIT_TEST(testScale); CPPUNIT_TEST_SUITE_END();
public: void testOperations() { CPPUNIT_ASSERT_EQUAL(42, add(20, 22)); // Assertion macros lacking modern decomposition CPPUNIT_ASSERT(!isNegative(42)); CPPUNIT_ASSERT_THROW(divide(10, 0), std::invalid_argument); }
// Parameterized test: no native support, imperative loop without isolation void testScale() { for (int scale : {10, 50, 100}) CPPUNIT_ASSERT(scale > 0); }};
CPPUNIT_TEST_SUITE_REGISTRATION(CalculatorTest);| Feature | CppUtils::UnitTest | Boost.UT | Catch2 (v3) | Google Test | doctest | Boost.Test | Qt Test | MS Test (C++) | CppUnit |
|---|---|---|---|---|---|---|---|---|---|
| Zero preprocessor macros | ✅ | ✅ | ❌ (REQUIRE) | ❌ (EXPECT_EQ) | ❌ (CHECK) | ❌ (BOOST_CHECK) | ❌ (QCOMPARE) | ❌ (TEST_METHOD) | ❌ (CPPUNIT_) |
| Native C++20 modules | ✅ (import) | ✅ (ut.cppm) | ❌ (#include headers) | ❌ (#include headers) | ❌ (#include headers) | ❌ (#include headers) | ❌ (#include headers) | ❌ (#include headers) | ❌ (#include headers) |
Expression decomposition (lhs == rhs) | ✅ (expect(a) == b syntax) | ✅ (1_i == 2 or that % / eq) | ⚠️ Partial (macro decomposition) | ❌ (dedicated macros ASSERT_EQ) | ⚠️ Partial (macro decomposition) | ⚠️ Partial (via BOOST_TEST) | ❌ (QCOMPARE) | ❌ (Assert::AreEqual) | ❌ (CPPUNIT_ASSERT_EQUAL) |
| Mocking | ❌ (learn why) | ❌ | ❌ (via third-party) | ✅ (GoogleMock) | ❌ (via third-party) | ❌ (via third-party) | ⚠️ Partial (QSignalSpy) | ❌ | ❌ |
| Dependency scheduling | ✅ (topological ordering & automatic failure skipping) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
| Integrated benchmarks & comparison | ✅ (benchmark, warnIfSlower) | ❌ | ⚠️ Partial (BENCHMARK) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
Parameterized tests (forValues) | ✅ | ✅ | ✅ (GENERATE) | ✅ (INSTANTIATE_TEST_SUITE_P) | ✅ (GENERATE) | ✅ (datasets) | ⚠️ Partial (QTest::newRow) | ❌ | ❌ |
Typed tests (forTypes) | ✅ (variadic direct) | ✅ | ✅ (TEMPLATE_TEST_CASE) | ✅ (TYPED_TEST) | ⚠️ Partial (TYPE_TO_STRING) | ✅ (template test cases) | ❌ | ❌ | ❌ |
| Deterministic fuzzing engine | ✅ (with repeatable seed) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
Process isolation (fork) | ✅ (SIGSEGV resilience) | ❌ | ❌ | ⚠️ Partial (death_test) | ❌ | ❌ | ❌ | ❌ | ❌ |
Asynchronous verification (eventually, completes) | ✅ (periodic polling and reactive wake) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
| JUnit XML reports (CI/CD) | ✅ (native via --report=junit:...) | ❌ | ✅ (-r junit) | ✅ (--gtest_output=xml) | ✅ (-r=junit) | ✅ (--log_format=JUNIT) | ✅ (-junitxml) | ⚠️ Partial (proprietary .trx, JUnit via third-party plugin) | ✅ (XmlOutputter) |
| Zero external dependencies | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ (Boost) | ❌ (Qt Core & MOC) | ❌ (Visual Studio) | ✅ |
CppUtils::UnitTest preserves that modern spirit while removing the remaining friction: zero requirement for dedicated literal suffixes (_i) to capture any standard type, native dependency graph scheduling, and process-level crash resilience.CppUtils::Container::DependencyGraph), if a foundational module fails, hundreds of dependent high-level tests are automatically skipped instead of overwhelming the terminal with noise.CppUtils::UnitTest consolidates all these pillars into a single, cohesive, and expressive API.A test suite is declared simply:
import std;import CppUtils;
namespace MyApp::Tests{ namespace ut = CppUtils::UnitTest;
auto _ = ut::testSuite("Math/Calculator") = [](ut::TestSuite& suite) { using namespace std::literals;
suite.test("Addition") = [] { const auto result = 20 + 22; ut::expect(result) == 42; };
suite.test("Heavy computation").timeout(30s) = [] { ut::expect(performHeavyComputation()) > 0; }; };}When a test suite requires other test suites to pass before running, declare them with .dependsOn(...):
auto _ = ut::testSuite("Network/Client").dependsOn("Logger", "Network/Socket") = [](ut::TestSuite& suite) { suite.test("Connection") = [] { // This test only executes if "Logger" and "Network/Socket" succeeded };};flowchart TD
subgraph Prerequisites["Prerequisite Suites"]
Logger["Logger"]
Socket["Network/Socket"]
end
subgraph Dependent["Dependent Suite"]
Client["Network/Client"]
end
Logger -->|"Success required"| Client
Socket -->|"Success required"| Client
By declaring these dependencies, the runner structures test execution as a Directed Acyclic Graph (DAG). This graph structure provides three major benefits:
Network/Socket fails, Network/Client is not even executed and is marked as skipped due to unmet prerequisites. The genuine failure is never buried beneath an avalanche of cascading secondary errors caused by a broken upstream dependency.failFast: When failFast is enabled, the runner stops immediately on the first root failure without running dependent suites.The deliberate omission of a mocking library in CppUtils is an intentional design choice. In the C++ ecosystem, mocking introduces two fundamental problems:
A mock replaces genuine components with canned responses. This gives the illusion of testing a component, while the real integration has never actually run:
In production, the system operates in a scenario that has never truly been tested. CppUtils encourages testing against real conditions: launching an actual local server on an ephemeral port or creating genuine temporary files.
Making a class “mockable” often forces developers to compromise production design: imposing object-oriented (OOP) class hierarchies for dependency injection, multiplying virtual interfaces (virtual / vtables), and relying on dynamic allocations (std::unique_ptr<Interface>). These indirections inhibit compiler inlining and add runtime overhead solely to satisfy the testing harness.
The perceived need for mocks often stems from fear that upstream failures will trigger cascading test crashes, making it difficult to pinpoint the root cause of the issue. With CppUtils::Container::DependencyGraph, CppUtils resolves this: foundational modules run first. If a dependency fails, dependent suites are automatically skipped, pinpointing the root cause without resorting to artificial simulations.
Unlike traditional frameworks that force developers to memorize dozens of specialized macros depending on the type of check (ASSERT_EQ, ASSERT_NE, ASSERT_THROW, CHECK_FALSE, etc. (see Comparison)), CppUtils::UnitTest prioritizes simplicity with a single assertion function: expect(...).
expectThe expect function validates that a condition or expression is true:
ut::expect(database.isConnected());ut::expect(not std::ranges::empty(buffer));Writing == true is unnecessary: expect(condition); directly evaluates the condition.
Upon failure, the report pinpoints the exact source file, line number, column, and the evaluated runtime value:
──────────────────────────────────────────────────────────────
Database/Connection/Ping:
🛇 ERROR 🛇
The following test didn't pass:
Database/Connection/Ping
In tests/Database/Connection.mpp
At line 42, column 5
In expect(condition)
with value:
false
Failed ✕Any type convertible to bool (std::optional, raw pointers, std::unique_ptr, std::shared_ptr, types with explicit operator bool()) is automatically converted and evaluated as a boolean by expect:
auto user = std::optional<User>{fetchUser(42)};ut::expect(user);
auto connection = std::unique_ptr<Socket>{connect()};ut::expect(connection);Upon failure on a null pointer or empty std::optional, the report directly outputs with value: nullptr or with value: nullopt in red:
──────────────────────────────────────────────────────────────
Database/Connection/Open:
🛇 ERROR 🛇
The following test didn't pass:
Database/Connection/Open
In tests/Database/Connection.mpp
At line 42, column 2
In expect(condition)
with value:
nullopt
Failed ✕not expect(...) over expect(not ...)To assert that a condition must be false, prefer placing the negation on the outside:
// Recommended:not expect(session.isExpired());
// Equivalent:expect(session.isExpired()) == false;While writing expect(not condition); works in C++, external negation provides significantly richer diagnostics upon failure:
expect(not error);: the logical inversion is resolved before entering expect. The expression is immediately collapsed into a raw bool. Upon failure, the report merely shows with value: false without revealing the object’s contents:auto error = std::optional<std::string>{"Connection refused"};expect(not error);──────────────────────────────────────────────────────────────
Network/Connection:
🛇 ERROR 🛇
The following test didn't pass:
Network/Connection
In tests/Network/Connection.mpp
At line 42, column 5
In expect(condition)
with value:
false
Failed ✕not expect(error);: the framework captures the original object intact. For any dereferenceable type (operator*) whose underlying value is printable, the diagnostic unpacks and displays the actual unexpected element that caused the failure in red:auto error = std::optional<std::string>{"Connection refused"};not expect(error);──────────────────────────────────────────────────────────────
Network/Connection:
🛇 ERROR 🛇
The following test didn't pass:
Network/Connection
In tests/Network/Connection.mpp
At line 42, column 5
In not expect(condition)
with value:
Connection refused
Failed ✕In traditional testing libraries, inspecting operand values forces developers to abandon idiomatic C++ operators in favor of an overwhelming zoo of specialized macros (ASSERT_EQ, ASSERT_NE, ASSERT_LT, ASSERT_LE, ASSERT_GT, ASSERT_GE, EXPECT_FLOAT_EQ, etc.).
With CppUtils::UnitTest, standard C++ comparison operators replace all these macros without verbose wrappers or proprietary literals:
ut::expect(left) == right;ut::expect(left) != right;ut::expect(left) < right;ut::expect(left) <= right;ut::expect(left) > right;ut::expect(left) >= right;namespace ut = CppUtils::UnitTest;
auto _ = ut::testSuite("Auth/Session") = [](ut::TestSuite& suite) { using namespace std::chrono_literals;
suite.test("Validate session") = [] { ut::expect(session.getRemainingTime()) > 0s; ut::expect(session.getUserRole()) == "admin"; ut::expect(session.getFailedAttempts()) <= 3u; ut::expect(std::ranges::size(session.getActiveTokens())) == 2uz; };};REQUIRE(a == b), EXPECT_EQ(a, b)). In pure C++, writing expect(a == b); would pass an already-evaluated bool to expect, making separate inspection of a and b impossible. By writing expect(a) == b;, expect(a) returns a temporary proxy object (ExpectationProxy) that intercepts the subsequent comparison operator, capturing both left-hand (lhs) and right-hand (rhs) operands along with their operator symbol without a single macro.value(...) or that(...) cause major conflicts whenever a test declares a local variable sharing that common name. Writing auto value = calculate(); expect(value(value) == 42); produces a fatal compilation error (error: type does not provide a call operator). With expect(value) == 42;, the variable value is passed directly with no risk of shadowing or ambiguity.42_i == sum()), limited to basic arithmetic types, or via a matcher wrapper (expect(that % 0 == sum());), or via prefixed named functions (expect(eq(a, b));). CppUtils::UnitTest solves this in a unified and generic way without compromises: zero imposed literals, zero verbose wrappers, and zero prefixed functions. Any modern C++ type is naturally captured with standard language operators.ut::expect(user.isConnected());) without requiring comparison operators or a redundant == true suffix.Upon failure (for instance if user.getRole() returns "guest" instead of "admin"), the diagnostic decomposes the expression and prints the exact runtime values of both operands:
──────────────────────────────────────────────────────────────
UnitTest/Auth:
🛇 ERROR 🛇
The following test didn't pass:
Auth/Session/Admin verification
In tests/Auth/Session.mpp
At line 42, column 2
In expect(lhs) == rhs
with lhs:
guest
and rhs:
admin
Diff:
lhs (-): "guest"
rhs (+): "admin"
Failed ✕For isolated differences within long strings, this diff calculation pinpoints the mismatched characters in place:
const auto expectedUrl = "https://api.example.com/v1/users/42/profile";const auto actualUrl = "https://api.example.com/v2/users/42/profile";ut::expect(expectedUrl) == actualUrl;──────────────────────────────────────────────────────────────
Network/Api/EndpointCheck:
🛇 ERROR 🛇
The following test didn't pass:
Network/Api/EndpointCheck
In tests/Network/Api.mpp
At line 18, column 2
In expect(lhs) == rhs
with lhs:
https://api.example.com/v1/users/42/profile
and rhs:
https://api.example.com/v2/users/42/profile
Diff:
lhs (-): "https://api.example.com/v1/users/42/profile"
rhs (+): "https://api.example.com/v2/users/42/profile"
Failed ✕(As shown above, only the 1 appears in red and the 2 in green, while the rest of the URL remains in standard neutral color).
Likewise, when multiple segments differ within a sentence, each mismatch is highlighted individually without merging the unchanged intermediate text:
const auto expectedSentence = "The quick brown fox jumps over the lazy dog";const auto actualSentence = "The fast brown fox leaps over a lazy dog";ut::expect(expectedSentence) == actualSentence;──────────────────────────────────────────────────────────────
UnitTest/VisualColorInspection/Diff visuel complexe (segments multiples):
🛇 ERROR 🛇
The following test didn't pass:
UnitTest/VisualColorInspection/Diff visuel complexe (segments multiples)
In tests/UnitTest/UnitTest.mpp
At line 711, column 5
In expect(lhs) == rhs
with lhs:
The quick brown fox jumps over the lazy dog
and rhs:
The fast brown fox leaps over a lazy dog
Diff:
lhs (-): "The quick brown fox jumps over the lazy dog"
rhs (+): "The fast brown fox leaps over a lazy dog"
Failed ✕The inspection engine relies on generic C++20 concepts, without requiring serialization macros or custom display overloads (unlike, for instance, QTest::toString in Qt Test). Any type that satisfies these concepts (iterable ranges, dereferenceable types, tuple-like structures, etc.), whether from the standard library, a third-party library, or your own code, is automatically analyzed and formatted.
Containers (std::vector, std::span, lists, views) are compared element by element using std::ranges::equal and formatted as readable lists:
const auto headers = std::vector<std::string>{"content-type", "authorization"};ut::expect(headers) == std::vector<std::string>{"content-type", "accept"};──────────────────────────────────────────────────────────────
Network/Headers/Validate:
🛇 ERROR 🛇
The following test didn't pass:
Network/Headers/Validate
In tests/Network/Headers.mpp
At line 32, column 2
In expect(lhs) == rhs
with lhs:
["content-type", "authorization"]
and rhs:
["content-type", "accept"]
Diff:
lhs (-): ["content-type", "authorization"]
rhs (+): ["content-type", "accept"]
Failed ✕Composite std::tuple and std::pair structures are decomposed and formatted recursively field by field:
const auto response = std::make_pair(404, "Not Found");ut::expect(response) == std::make_pair(200, "OK");──────────────────────────────────────────────────────────────
Network/Response/Validate:
🛇 ERROR 🛇
The following test didn't pass:
Network/Response/Validate
In tests/Network/Response.mpp
At line 15, column 2
In expect(lhs) == rhs
with lhs:
(404, "Not Found")
and rhs:
(200, "OK")
Diff:
lhs (-): (404, "Not Found")
rhs (+): (200, "OK")
Failed ✕std::optional, std::unique_ptr, and std::shared_ptr are inspected automatically, explicitly displaying nullopt or nullptr when empty, or the underlying dereferenced value:
const auto cachedUser = std::optional<std::string>{std::nullopt};ut::expect(cachedUser) == std::optional<std::string>{"alice"};──────────────────────────────────────────────────────────────
Auth/Cache/Lookup:
🛇 ERROR 🛇
The following test didn't pass:
Auth/Cache/Lookup
In tests/Auth/Cache.mpp
At line 24, column 2
In expect(lhs) == rhs
with lhs:
nullopt
and rhs:
"alice"
Failed ✕std::byte) and Binary BuffersMemory bytes are formatted in explicit hexadecimal notation (0x2A), including when stored in collections (std::vector<std::byte>, std::span<const std::byte>), ideal for validating binary protocols and network packet headers:
const auto header = std::vector<std::byte>{std::byte{0x7F}, std::byte{0x45}, std::byte{0x4C}, std::byte{0x46}}; // ELF magicut::expect(header) == std::vector<std::byte>{std::byte{0x7F}, std::byte{0x45}, std::byte{0x00}, std::byte{0x00}};──────────────────────────────────────────────────────────────
Binary/Header/Magic:
🛇 ERROR 🛇
The following test didn't pass:
Binary/Header/Magic
In tests/Binary/Header.mpp
At line 12, column 2
In expect(lhs) == rhs
with lhs:
[0x7F, 0x45, 0x4C, 0x46]
and rhs:
[0x7F, 0x45, 0x00, 0x00]
Diff:
lhs (-): [0x7F, 0x45, 0x4C, 0x46]
rhs (+): [0x7F, 0x45, 0x00, 0x00]
Failed ✕std::filesystem::path)Filesystem paths are normalized and displayed in their canonical string format:
──────────────────────────────────────────────────────────────
FileSystem/Config/PathCheck:
🛇 ERROR 🛇
The following test didn't pass:
FileSystem/Config/PathCheck
In tests/FileSystem/Config.mpp
At line 28, column 2
In expect(lhs) == rhs
with lhs:
"/var/log/syslog"
and rhs:
"/var/log/app.log"
Diff:
lhs (-): "/var/log/syslog"
rhs (+): "/var/log/app.log"
Failed ✕Floating-point comparisons (float, double) automatically use relative epsilon tolerance (Math::isEqual), eliminating IEEE 754 precision failures without requiring dedicated macros (EXPECT_NEAR):
ut::expect(0.1 + 0.2) == 0.3; // Passes automatically despite IEEE 754 precision limitsMixed comparisons rely on standard library safe comparison utilities (std::cmp_equal, std::cmp_greater, etc.), avoiding risky implicit conversions and -Wsign-compare compiler warnings:
ut::expect(-1) > 1u; // False in standard C++ due to unsigned promotion; correctly evaluated hereComparing against text literals evaluates character content via std::string_view, eliminating pointer address comparison bugs.
throws, noThrow, crashes, eventually, completesRather than calling verbose and scattered assertion functions or macros (ASSERT_THROW, ASSERT_NO_THROW, ASSERT_DEATH, etc.), CppUtils::UnitTest unifies checks using the standard == operator on expect(action):
throws Matcher: Exception VerificationException type validation:
ut::expect([&] { computeSquareRoot(-1.0);}) == ut::throws<std::invalid_argument>;Validating that an error message contains a substring:
ut::expect([&] { divide(10, 0);}) == ut::throws<std::invalid_argument>("Division by zero");// Passes if exception.what() contains "Division by zero" (e.g. "Math error: Division by zero detected")Boolean predicate validation:
ut::expect([&] { database.connect("invalid_host");}) == ut::throws<DatabaseException>([](const DatabaseException& error) { return error.code == 404 and error.message.contains("unreachable");});Direct value equality matching:
ut::expect([&] { parser.parse("!");}) == ut::throws<ParseError>(ParseError{.line = 1, .code = ErrorCode::UnexpectedToken});Inspector validation with internal assertions:
ut::expect([&] { database.connect("invalid_host");}) == ut::throws<DatabaseException>([&](const DatabaseException& error) { ut::expect(error.code) == 404; ut::expect(error.message.contains("unreachable"));});noThrow Matcher: Exception AbsenceVerifies that no exception is thrown during execution:
ut::expect([&] { socket.receive();}) == ut::noThrow;crashes Matcher: Crash and Critical Signal InterceptionFatal termination validation (any fatal termination signal):
ut::expect([&] { int* nullPointer = nullptr; *nullPointer = 42;}) == ut::crashes;Specific fatal signal validation:
ut::expect([&] { std::abort();}) == ut::crashes(CppUtils::System::Signal::Abort);eventually Matcher: Waiting for an Asynchronous ConditionPeriodically evaluates an asynchronous condition until it becomes true or a deadline is reached.
By default, eventually waits up to 1 second with a 20 ms polling interval:
// Using default settings (timeout: 1s, step: 20ms)ut::expect([&] { return counter.load() == 5uz;}) == ut::eventually;It can also be customized with a custom deadline alone, or alongside a custom polling interval:
// Custom deadline of 500ms (retains default 20ms interval)ut::expect([&] { return isReady.load();}) == ut::eventually(500ms);
// Custom deadline (500ms) and custom polling interval (10ms)ut::expect([&] { return counter.load() == 5uz;}) == ut::eventually(500ms, 10ms);completes Matcher: Reactive Synchronization Without PollingWaits for an asynchronous operation or synchronization primitive to complete before a maximum deadline (1 second by default).
Waiting on a std::future:
auto promise = std::promise<int>{};auto future = promise.get_future();worker.execute([&] { promise.set_value(42); });
// Validate completion onlyut::expect(future) == ut::completes;
// Combined check for completion and returned valueut::expect(future) == ut::completes.with(42);
// Custom timeout durationut::expect(future) == ut::completes(500ms).with(42);With a lambda returning an asynchronous result:
ut::expect([&] { return worker.computeAsync(); }) == ut::completes.with(42);With a std::shared_future:
auto sharedFuture = promise.get_future().share();ut::expect(sharedFuture) == ut::completes.with(42);With a C++20 semaphore (std::binary_semaphore, std::counting_semaphore):
auto signal = std::binary_semaphore{0};scheduler.schedule([&] { signal.release(); }, 50ms);
ut::expect(signal) == ut::completes;skip and skipIfWhen a test is temporarily disabled or requires a specific environment (specific operating system, required hardware resource, remote network connectivity, etc.), you can cleanly skip it without failing the test suite:
// Skip a test unconditionally (e.g. work in progress or pending bugfix)skip("Feature under active refactoring (issue #142)");
// Skip a test based on a conditionskipIf(not isNetworkAvailable(), "Remote network access required");suite.test<Predicate>)To validate constexpr algorithms, template metaprogramming, concepts, or static constants, the test framework provides a direct compile-time overload for suite.test:
suite.test<[] { constexpr auto value = factorial(5); return value == 120;}>("Factorial computation");This mechanism combines the rigor of static_assert with full test runner integration:
static_assert error before any binary execution. You know the test passes as soon as compilation succeeds, without needing to launch the test suite.Passed ✓).fuzzTest and fuzz)The framework incorporates a deterministic pseudo-random fuzzing engine. This determinism guarantees that each test execution replays strictly identical input data sequences, delivering total reproducibility across continuous integration runs:
// Executes 5,000 iterations with pseudo-randomly regenerated values on each runsuite.fuzzTest("Parser fuzzing", 5'000uz) = [](auto& fuzzer) { // Generates a pseudo-random string between 1 and 64 characters in length const auto input = fuzzer.randomString(1uz, 64uz);
// Validate that the parser experiences no undefined behavior or crashes parseData(input);};You can also inject pseudo-random inputs inside standard test cases using fuzz:
suite.test("Integer arithmetic fuzzing") = [] { fuzz("Random arithmetic checks", 1'000uz, [](auto& fuzzer) { const auto a = fuzzer.random(-1000, 1000); // Lower bound at 1 to exclude division by zero const auto b = fuzzer.random(1, 1000); expect((a / b) * b + (a % b)) == a; });};The fuzzer object (an instance of CppUtils::Math::DeterministicRandomEngine) provides a complete set of deterministic, fully constexpr-compatible generation methods:
| Method & Signature | Description |
|---|---|
random<Type>() | Generates a pseudo-random value of the requested type (int, size_t, double, bool, etc.) covering default minimum and maximum type bounds. |
random(min, max) | Generates a bounded pseudo-random value in the range [min, max] with automatic type deduction (integral or floating-point). |
randomSize(max) | Convenient shorthand generating a std::size_t in [0, max] (or [min, max] with 2 arguments). Parameterless overload defaults to PTRDIFF_MAX matching the maximum legal contiguous object size in C++. |
randomFloat(min, max) | Generates a floating-point number in the range [min, max] (defaults: 0.0 to 1.0). |
randomString(minLen, maxLen, alphabet) | Generates a variable-length string (defaults: 0 to 32 characters) from the provided character set. |
randomBytes(count) | Allocates and fills a vector of count bytes (std::vector<std::byte>). |
pickOne(collection) | Selects a random element from a collection. |
For full parameter details and overloads, consult the DeterministicRandomEngine API reference.
The alphabet parameter of randomString enables targeting specific string formats and token types:
// Fixed length of 32 characters (min=32, max=32) drawn from the hexadecimal alphabet (hash, UUID token)auto hexToken = fuzzer.randomString(32uz, 32uz, "0123456789abcdef");
// Variable length from 1 to 10 characters restricted to digits 0-9auto numericId = fuzzer.randomString(1uz, 10uz, "0123456789");
// Variable length from 5 to 20 special characters to stress test a parserauto syntaxPayload = fuzzer.randomString(5uz, 20uz, "{}[]():;\"'\\/<>&=");The framework provides lightweight utilities to benchmark execution times and directly compare the performance of two implementations:
suite.test("FlatHashMap vs std::unordered_map performance") = [] { constexpr auto elementCount = 50'000uz; auto flatMap = CppUtils::Container::FlatHashMap<std::size_t, std::size_t>{}; auto stdMap = std::unordered_map<std::size_t, std::size_t>{};
const auto flatBenchmark = benchmark("FlatHashMap insert", [&] { for (auto index = 0uz; index < elementCount; ++index) flatMap[index] = index * 2uz; });
const auto stdMapBenchmark = benchmark("std::unordered_map insert", [&] { for (auto index = 0uz; index < elementCount; ++index) stdMap[index] = index * 2uz; });
// Compare both benchmarks and report the speedup factor const auto comparison = compareBenchmarks(flatBenchmark, stdMapBenchmark);
// Non-blocking warning: logs an alert to the console if the candidate is slower warnIfSlower(flatBenchmark, stdMapBenchmark);
// If you instead wish to enforce a strict assertion that fails the test: // expect(comparison.isFirstFaster());};forTypes and forValues)The test framework supports iterative execution of identical test logic across a list of types or a set of values. These parameterization helpers apply seamlessly on individual unit tests as well as at the complete suite level.
forTypes)The test body is instantiated and registered as separate test cases (automatically suffixed with [type 0], [type 1], etc.) for each type specified in the template argument list:
suite.test("Vector operations").forTypes<int, double, std::string>() = []<class Type> { auto vector = std::vector<Type>{}; vector.push_back(Type{});};Console report generated during test execution:
──────────────────────────────────────────────────────────────
Container/Vector/Vector operations [type 0]:
Passed ✓
──────────────────────────────────────────────────────────────
Container/Vector/Vector operations [type 1]:
Passed ✓
──────────────────────────────────────────────────────────────
Container/Vector/Vector operations [type 2]:
Passed ✓forValues)The test creates a distinct entry for each supplied value. If the type is formattable via std::format, the value itself is displayed inside square brackets in the test label ([10], [50], etc.):
suite.test("Positive scale factor").forValues(10, 50, 100, 500) = [](int scale) { expect(scale) > 0;};Console report generated during test execution:
──────────────────────────────────────────────────────────────
Math/Scale/Positive scale factor [10]:
Passed ✓
──────────────────────────────────────────────────────────────
Math/Scale/Positive scale factor [50]:
Passed ✓
──────────────────────────────────────────────────────────────
Math/Scale/Positive scale factor [100]:
Passed ✓
──────────────────────────────────────────────────────────────
Math/Scale/Positive scale factor [500]:
Passed ✓std::ranges)You can also pass a standard container or any std::ranges view (such as std::views::iota to test a lazy sequence of numbers without any heap allocations); each element generates an independent test execution:
// Lazy sequence with std::views::iota (zero dynamic allocations):suite.test("Index validation").forValues(std::views::iota(1, 10)) = [](int index) { expect(index) >= 1; expect(index) < 10;};
// Standard sequential container:suite.test("Buffer allocation").forValues(std::vector{64uz, 256uz, 1024uz}) = [](std::size_t size) { auto buffer = std::vector<std::uint8_t>(size, 0); expect(std::ranges::size(buffer)) == size;};
// Associative container (iterates over each std::pair key-value entry):const auto rolePermissions = std::map<std::string, int>{ {"guest", 1}, {"user", 2}, {"admin", 4}};suite.test("Role permissions").forValues(rolePermissions) = [](const auto& entry) { const auto& [role, permissionLevel] = entry; expect(permissionLevel) > 0;};The parameterization modifier also applies to registering entire suites (testSuite(...).forTypes<...>() or testSuite(...).forValues(...)). The runner automatically creates and registers an independent test suite per type (named, for instance, "Container/Vector [type 0]", "Container/Vector [type 1]") or per value (named "Math/Prime [2]", "Math/Prime [3]") :
auto _ = testSuite("Container/Vector").forTypes<int, float>() = []<class Type>(TestSuite& suite) { suite.test("Default initialization") = [] { auto container = std::vector<Type>{}; expect(std::ranges::empty(container)); };};
auto _ = testSuite("Math/Prime").forValues(2, 3, 5, 7) = [](TestSuite& suite, int prime) { suite.test("Is prime") = [prime] { expect(isPrime(prime)); };};CppUtils::UnitTest::EventWatcher)When testing event-driven architectures (CppUtils::Execution::EventQueue, CppUtils::Execution::EventDispatcher), CppUtils::UnitTest::EventWatcher subscribes to an event upon construction and provides tools to record emission history and block until the expected number of occurrences is received without manual loops:
suite.test("Event dispatching") = [] { using namespace std::chrono_literals;
auto queue = CppUtils::Execution::EventQueue{};
// Create a typed event watcher on event "Notification" auto watcher = watch<"Notification", int>(queue);
expect(not watcher.hasOccurred()); expect(watcher.count()) == 0uz;
queue.emit<"Notification">(42); queue.emit<"Notification">(84);
// Wait actively until 2 occurrences arrive within 500ms expect(watcher.waitForOccurrences(2uz, 500ms)); expect(watcher.count()) == 2uz; auto occurrences = watcher.occurrences(); expect(occurrences->count) == 2uz; expect(std::ranges::size(occurrences->payloads)) == 2uz; expect(occurrences->payloads[0]) == 42; expect(occurrences->payloads.back()) == 84;};CppUtils::UnitTest::LifetimeCheckerTo verify the semantic correctness of your objects (containers, RAII wrappers, move-only types, zero-copy optimizations), CppUtils provides CppUtils::UnitTest::LifetimeChecker, an inert object designed to accurately track lifecycle operations (constructions, destructions, copies, and moves) and notify the caller.
The object maintains internal counters recording the number of copy and move operations performed:
import CppUtils;
suite.test("Move semantics in vector") = [&] { // Inert object with a no-op callback auto checker = CppUtils::UnitTest::LifetimeChecker{[](std::string_view) {}, "Widget"};
auto container = std::vector<CppUtils::UnitTest::LifetimeChecker>{}; container.reserve(1);
// Move into container container.push_back(std::move(checker));
expect(container.front().getCopyCount()) == 0uz; expect(container.front().getMoveCount()) == 1uz;};The constructor accepts a callback function and an identifier tag. On every lifecycle operation (construction, copy, move, destruction), this callback is automatically invoked with a string describing the event:
suite.test("Lifecycle notifications") = [&] { auto checker = CppUtils::UnitTest::LifetimeChecker{[](std::string_view event) { std::println("{}", event); }, "Resource"};
auto copy = checker; auto moved = std::move(copy);};The terminal output faithfully reflects the sequence of operations and the invalidation of the moved-from instance:
Resource()Resource(const Resource&)Resource(Resource&&)~[invalid object]()~Resource()~Resource()Invoking all registered suites is executed in the main entry point of your test runner binary via executeTests() :
import std;import CppUtils;
auto main(int argc, char** argv) -> int{ // Parse command-line options (mode, filter, verbose, failFast, chrono) auto settings = CppUtils::UnitTest::executeCommands(argc, argv); if (settings.abort) return CppUtils::exitSuccess;
return CppUtils::UnitTest::executeTests(settings);}CppUtils::UnitTest::TestSettings)| Option | Type | Default Value | Description |
|---|---|---|---|
verbose | bool | true | Displays the name and real-time status of each test. If false, only prints the final summary. |
detail | bool | true | Prints execution dividers and full suite namespaces during progress. |
chrono | bool | false | Measures and logs the precise execution time of each test via CppUtils::Log::ChronoLogger. |
failFast | bool | false | Immediately aborts test execution upon the first encountered failure. |
filter | std::string | "" | Filters test suites to execute by substring while automatically resolving their prerequisites. |
report | std::string | "" | Exports test results to a report file (format junit:path.xml or path.xml). |
The executeCommands() function parses binary arguments (argc, argv) according to POSIX and GNU conventions:
# Standard long options./UnitTests --filter=Network --chrono --report=junit:report.xml
# Equivalent short options./UnitTests -f=Network -c -r=report.xml
# Immediate abort on first failure (fail-fast)./UnitTests --fail-fast| Long option | Short form | Default | Description |
|---|---|---|---|
--help | -h | - | Shows available command-line parameters and terminates the program. |
--mode=<mode> | -m=<mode> | debug | Predefined execution profile: debug (default), test (with timing), prod (silent with fail-fast). |
--verbose[=<bool>] | -v[=<bool>] | true | Enables or disables standard execution logging. |
--detail[=<bool>] | -d[=<bool>] | true | Enables or disables detailed progress output (test names, individual durations, benchmark metrics, separators). |
--fail-fast | false | Aborts test run immediately upon the first failure. | |
--chrono[=<bool>] | -c[=<bool>] | false | Displays individual test execution durations. |
--filter=<string> | -f=<string> | "" | Runs only test suites whose name contains this substring (and automatically resolves their dependencies). |
--report=<path> | -r=<path> | "" | Exports results in JUnit XML format (e.g., -r=report.xml or explicitly --report=junit:report.xml). |
The --report=junit:<path> (or simply -r=<path>) option generates a report in universal JUnit XML format. Continuous integration engines process this standard format to display visual metrics and inject inline failure annotations directly on the offending lines of code:
artifacts:reports:junit: in .gitlab-ci.yml.EnricoMi/publish-unit-test-result-action or dorny/test-reporter).# .gitlab-ci.yml: Zero plugins required, native GitLab CI supporttest: stage: test script: - xmake run CppUtils-UnitTests --report=junit:build/test-results.xml artifacts: when: always reports: junit: build/test-results.xml# .github/workflows/test.yml: Publishing via marketplace action - name: Run unit tests run: | xmake run CppUtils-UnitTests --report=junit:build/test-results.xml
- name: Publish Test Results uses: EnricoMi/publish-unit-test-result-action@v2 if: always() with: files: "build/test-results.xml"Every unit test is executed in a dedicated child process. Memory access violations, fatal crashes, and infinite loops are cleanly isolated without interrupting the main test runner or affecting subsequent suites.
This architecture provides two key guarantees:
SIGSEGV, SIGABRT, etc.), the parent runner captures it. The test is recorded as failed with the signal’s details, while the remaining tests continue execution normally..timeout(2s)), it is terminated immediately, preventing test suite deadlocks.On a successful test run, the report displays structured progress:
2 tests found. Execution:
──────────────────────────────────────────────────────────────
Math/Calculator/Addition:
Passed ✓
──────────────────────────────────────────────────────────────
Network/Socket/TCP/Accept connection:
Passed ✓
──────────────────────────────────────────────────────────────
Test results
All tests passed successfully
- 2 successful testsWhen a foundational component fails, the report clearly isolates the failing line with evaluated values and automatically flags dependent suites as skipped:
──────────────────────────────────────────────────────────────
Network/Socket/Init:
Passed ✓
──────────────────────────────────────────────────────────────
Network/Socket/TCP:
🛇 ERROR 🛇
The following test didn't pass:
Network/Socket/TCP
In tests/Network/Socket.mpp
At line 48, column 3
In expect(lhs) == rhs
with lhs:
500
and rhs:
200
Failed ✕
──────────────────────────────────────────────────────────────
Skipping test suite Network/Client due to failed prerequisites in test suite Network/Socket ⚠
──────────────────────────────────────────────────────────────
Test results
The tests failed:
- 1 successful tests
- 1 failed tests
- Network/Socket
- 1 skipped tests
- Network/Client (due to failed prerequisites)