Here are the main features of the fcfTest unit testing library.
1 macros in all cases.
The library's main feature is the FCF_TEST macro. It doesn't just test a logical expression; it works as an intelligent debugger. When a test fails, the library automatically extracts variable names and their current values.
Example: Comparison with the traditional approach
Traditional approach (without fcfTest)
int expected = 100;
int actual = calculate_value();
if (actual != expected) {
std::cout << "Error: expected " << expected << " but got " << actual << std::endl;
throw std::runtime_error("Test failed");
}
Approach with fcfTest
int expected = 100;
int actual = calculate_value();
FCF_TEST(actual == expected, expected, actual);
If actual is 95, you will get an instant and clear report:
Test error: actual == expected [FILE: main.cpp:12]
Values:
expected: 100
actual: 95
Zero-Configuration: One File and You're Up and Running
fcfTest is a header-only library. You don't need to configure CMake, download binaries, or mess with linking. This makes it an ideal choice for microservices, small utilities, or embedded systems.
Just add the file to the project:
// In your main.cpp file
#define FCF_TEST_IMPLEMENTATION
#include <fcfTest/test.hpp>
FCF_TEST_DEFINE("Core", "Math", "AdditionTest") {
FCF_TEST(2 + 2 == 4);
}
int main(int a_argc, char* a_argv[]) {
bool error;
// We run all tests declared in the application
fcf::NTest::cmdRun(a_argc, a_argv, fcf::NTest::CRM_RUN, &error);
return error ? 1 : 0;
}
Output:
Performing the test: "Core" -> "Math" -> "AdditionTest" ...
[SUCCESS] Test completed successfully (0.000`000`276 sec)
[SUCCESS] All tests were completed.
Tests: 1 passed, 0 failed, 0 skipped, 1 total
Duration: 0.000`000`276 sec
Hierarchy and Order: Complete Control
In large projects, tests often depend on system state. fcfTest offers a three-level structure: Part -> Group -> Test. You can run only the required sections or specify a strict execution order.
Example: Controlling Execution Order
// Declare tests
FCF_TEST_DEFINE("Database", "Connection", "InitTest") { /* ... */ }
FCF_TEST_DEFINE("Database", "Query", "SelectTest") { /* ... */ }
// Ensure that initialization occurs first, then queries
FCF_TEST_PART_ORDER("Database", 1);
FCF_TEST_GROUP_ORDER("Connection", 1);
FCF_TEST_GROUP_ORDER("Query", 2);
Running via the command line allows you to filter tests without recompiling:
// Run only tests from the "Query" group
./my_tests --test-group Query
// Run everything except the "Legacy" group
./my_tests --test-ignore-group Legacy
Fixture Support
Fixtures can be attached to any level of the test hierarchy (Part -> Group -> Test). You have full control over when they are executed, allowing common setup and cleanup logic to be shared efficiently.
FCF_TEST_BEFORE_DEFINE("Math", "*", "*", fcf::NTest::FL_GROUP) {
// Initialize shared data when a new group starts
fcf::NTest::state().data("fixture_data", fcf::NTest::SharedPtrAny::make<std::string>("MyData"));
}
//This fixture will be held after the "Mathematics: Basics" group.
FCF_TEST_AFTER_DEFINE("Math", "*", "*", fcf::NTest::FL_GROUP) {
// Clean up the shared state when leaving the fixture scope
fcf::NTest::state().eraseData("fixture_data");
}
This fixture is executed whenever a new group within the Math part begins, and its cleanup counterpart is executed when that group completes.
Test Parameter Support
You can run a single test with different parameters by specifying them using the API.
Example of a parameterized test with command-line parameter input:
#define FCF_TEST_IMPLEMENTATION
#include <fcfTest/test.hpp>
// In the fixture we set parameters that will always be added
FCF_TEST_BEFORE_DEFINE("*", "*", "*", fcf::NTest::FL_GLOBAL){
fcf::NTest::TestPath path = {"Library", "Math", "sum"};
fcf::NTest::storage().appendParamValue(path, std::string("one")
, std::string("two")
, std::string("three"));
}
FCF_TEST_DEFINE("Library", "Math", "sum"){
// Get the test parameter value
fcf::NTest::log() << "Current test parameter: " << *fcf::NTest::state().param().cast<std::string>() << std::endl;
}
int main(int a_argc, char* a_argv[]) {
// Add test parameters passed via the command line
for(int argIndex = 1; argIndex < a_argc; ++argIndex) {
if (std::strcmp(a_argv[argIndex], "--param") == 0 && (argIndex + 1) < a_argc) {
fcf::NTest::storage().appendParamValue("Library", "Math", "sum", std::string(a_argv[argIndex+1]));
++argIndex;
}
}
// Start testing execution
bool error = false;
fcf::NTest::cmdRun(a_argc, a_argv, fcf::NTest::CRM_RUN, &error);
return error ? 1 : 0;
}
Example of application launch:
$ app --param "User-entered parameter"
Application output:
Performing the test: "Library" -> "Math" -> "sum" ...
== Parameter set: 1
> Current test parameter: User-entered parameter
== Parameter set: 2
> Current test parameter: one
== Parameter set: 3
> Current test parameter: two
== Parameter set: 4
> Current test parameter: three
[SUCCESS] Test completed successfully (0.000`022`794 sec)
[SUCCESS] All tests were completed.
Tests: 1 passed, 0 failed, 0 skipped, 1 total
Duration: 0.000`022`794 sec
JUnit XML format
The fcfTest framework supports exporting results to JUnit XML format for CI/CD integration using command-line arguments.
For example, to write a test report in JUnit format to a separate file, simply use the --test-file-junit parameter:
$ test --test-file-junit=report.xml
If you want to change the terminal output format, you can use the --test-format command-line parameter.
$ test --test-format junit
<?xml version="1.0" encoding="UTF-8"?>
<testsuites tests="1" failure="0" skipped="0" time="0.000028178">
<testsuite name="Performance/Sort" tests="1" failure="0" skipped="0" time="0.000028178">
<testcase classname="Performance/Sort" name="VectorSort" time="0.000028178"/>
</testsuite>
</testsuites>
The generated XML reports can be used in GitHub Actions to display test results in the workflow summary.
Example: GitHub Actions Integration Example (.github/workflows/ci.yml):
name: C++ CI with fcfTest
# Trigger the workflow on push or pull request events for the main branch
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
workflow_dispatch: # Allows manual trigger from the GitHub Actions tab
jobs:
build_and_test:
name: Build and Run Tests
runs-on: ubuntu-latest
# Required permissions to write test check runs and annotate commits/PRs
permissions:
contents: read
checks: write
pull-requests: write
steps:
# Step 1: Check out the repository code
- name: Checkout Code
uses: actions/checkout@v4
# Step 2: Configure and compile the C++ project using CMake
- name: Configure and Build
run: |
cmake -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build --config Release
# Step 3: Run the test executable and export results to a JUnit XML file
# The --test-file-junit flag tells fcfTest where to save the XML report
- name: Run fcfTest with JUnit Output
run: |
./build/my_test_executable --test-file-junit=report.xml
# Step 4: Visualize the JUnit XML report directly in the GitHub UI
# Uses the 'always()' condition to ensure the report is published even if tests fail
- name: Publish Test Report
uses: mikepenz/action-junit-report@v4
if: always()
with:
report_paths: '**/report.xml'
detailed_summary: true
include_passed: true
More than just tests: Logger and Benchmarking
Why bother with three different libraries when you can use one? fcfTest is a developer's all-in-one solution.
Built-in Logger
You can log test execution with different severity levels. This is useful for monitoring system state during long tests.
// Perform default logging
fcf::NTest::log() << "Starting heavy data processing..." << std::endl;
// First, save the current configuration
fcf::NTest::Logger::Prefixes prefixes = fcf::NTest::logger().prefixes();
// Remove the current prefixes
fcf::NTest::logger().clearPrefixes();
// You can add a custom prefix (e.g., time)
fcf::NTest::Logger::Prefix prefix;
prefix.name = "my-prefix";
prefix.prefix = "[LOG]: ";
prefix.category = fcf::NTest::LMC_USER_GROUP;
prefix.multiLine = false;
logger.appendPrefix(prefix);
// Perform logging with the new settings
fcf::NTest::log() << "Some actions have been completed" << std::endl;
// Restore the previous settings
fcf::NTest::logger().prefixes(prefixes);
Output:
> Starting heavy data processing...
> [LOG]: Some actions have been completed
Custom Output Formatting
If the built-in output formats do not meet your requirements, you can easily define and register your own custom formatting handler.
// Create a new format
fcf::NTest::Logger::Format customFormat;
customFormat.name = "my-custom-format";
customFormat.handler = [](fcf::NTest::Logger&, fcf::NTest::Logger::MessageContext& a_context) {
a_context.message = "[message: " + (std::stringstream() << std::hex << a_context.category).str() + "]\n";
};
// Add the format to the logger
logger.appendFormat(customFormat);
Output:
[message: 20002]
[message: 40001]
[message: 10008]
[message: 10003]
[message: 10005]
[message: 10006]
Built-in Benchmarking
The Duration class lets you measure code performance directly within tests. This turns a regular unit test into a performance verification tool.
#define FCF_TEST_IMPLEMENTATION
#include <fcfTest/test.hpp>
FCF_TEST_DEFINE("Performance", "Sort", "VectorSort") {
fcf::NTest::Duration bench(1000); // 1000 iterations
std::vector<int> sorted;
bench([&sorted]() {
sorted = {5, 2, 9, 1};
std::sort(sorted.begin(), sorted.end());
});
FCF_TEST(std::is_sorted(sorted.begin(), sorted.end()));
fcf::NTest::inf() << "Avg time: " << bench.duration().count() << " ns" << std::endl;
}
int main(int a_argc, char* a_argv[]) {
bool error;
// We run all tests declared in the application
fcf::NTest::cmdRun(a_argc, a_argv, fcf::NTest::CRM_RUN, &error);
return error ? 1 : 0;
}
Run:
$ test --test-log-level inf
Output:
Performing the test: "Performance" -> "Sort" -> "VectorSort" ...
> Avg time: 21 ns
[SUCCESS] Test completed successfully (0.000`030`622 sec)
[SUCCESS] All tests were completed.
Tests: 1 passed, 0 failed, 0 skipped, 1 total
Duration: 0.000`030`622 sec