Header-only C++ testing framework for the win
░██ ░██ ░████ ░██
░██ ░██ ░██ ░██
░████████ ░███████ ░███████ ░████████ ░████████ ░████████ ░██ ░██ ░██
░██ ░██ ░██ ░██ ░██ ░██ ░██ ░██ ░██ ░██
░██ ░█████████ ░███████ ░██ ░██ ░██ ░██ ░████ ░██
░██ ░██ ░██ ░██ ░██ ░██ ░██░██ ░██░██
░████ ░███████ ░███████ ░████ ░██ ░████ ░███ ░███
testftw is a lightweight, header-only C++ testing framework designed for simplicity and cross-standard compatibility. It provides composable fixtures, testcase wrappers, testsuites, and benchmarking helpers in a single testftw.hpp header.
The framework is dogfooded — the repository includes a test suite that uses testftw itself as the runner, ensuring the framework works correctly across C++11, C++14, C++17, and C++23.
- Header-only: Include
testftw.hppand start testing. No build system integration required. - Cross-standard: Supports C++11 through C++23 with automatic feature detection.
- Self-contained: The test suite validates the framework using the framework itself.
- Minimal dependencies: Only requires the C++ standard library.
- Type-erased fixtures with setup/call/teardown lifecycle
- Dual implementation paths:
std::anyfor C++17+, polymorphic wrappers for pre-C++17 - Timed testcases with automatic iteration and duration measurement
- Testsuite management with add/remove operations and suite-level fixtures
- Ownership-transfer casting via
unique_ptr_dynamic_cast - Scoped benchmarking macros (
BENCHMARK_START/BENCHMARK_END) - Null-safe fixture handling with automatic pointer argument detection
- Compile-time configuration through template parameters
testftw is a single-header library. Copy testftw.hpp into your project:
cp testftw.hpp /path/to/your/project/Or include it directly from this repository:
#include <testftw.hpp>
using namespace testftw;No build system configuration is required. The header is self-contained and uses only standard library headers.
#include <testftw.hpp>
#include <cassert>
#include <iostream>
using namespace testftw;
// Define a fixture with setup/call/teardown lifecycle
class counter_fixture : public fixture_base<int, int> {
int count = 0;
int setup(int initial) override {
count = initial;
return count;
}
int operator()(int increment) override {
count += increment;
return count;
}
int teardown(int) override {
int final_count = count;
count = 0;
return final_count;
}
};
// Define a testcase body
int add_ten(int value, std::vector<fixture_interface*>) {
return value + 10;
}
int main() {
// Create fixture and bind argument
counter_fixture counter;
auto arg = counter_fixture::makearg(5);
// Create testcase with fixture
testcase<false, 1, int, int> tc(add_ten, {{&counter, arg}});
// Run and verify
int result = tc.run(0);
assert(result == 10);
std::cout << "All tests passed!" << std::endl;
return 0;
}Compile and run:
g++ -std=c++17 -o example example.cpp && ./exampleDemonstrates the fixture lifecycle with direct and bound calls:
#include <testftw.hpp>
#include <cassert>
#include <vector>
using namespace testftw;
class log_fixture : public fixture_base<int, int> {
std::vector<int> *events;
public:
explicit log_fixture(std::vector<int> *e) : events(e) {}
int setup(int value) override {
events->push_back(1000 + value);
return value;
}
int operator()(int value) override {
events->push_back(2000 + value);
return value;
}
int teardown(int value) override {
events->push_back(3000 + value);
return value;
}
};
int main() {
std::vector<int> events;
log_fixture fixture(&events);
// Direct calls (C++17+)
#if __cplusplus >= 201703L
assert(std::any_cast<int>(fixture.call_setup(std::any(7))) == 7);
assert(std::any_cast<int>(fixture.call(std::any(8))) == 8);
assert(std::any_cast<int>(fixture.call_teardown(std::any(9))) == 9);
#endif
// Verify lifecycle events
assert((events == std::vector<int>{1007, 2008, 3009}));
return 0;
}Create testcases that measure execution time:
#include <testftw.hpp>
#include <cassert>
#include <iostream>
using namespace testftw;
int fibonacci(int n, std::vector<fixture_interface*>) {
if (n <= 1) return n;
return fibonacci(n - 1, {}) + fibonacci(n - 2, {});
}
int main() {
// Timed testcase: 100 iterations
testcase<true, 100, int, int> tc(fibonacci);
std::chrono::nanoseconds total, average;
int result = tc.run(20, &total, &average);
std::cout << "fibonacci(20) = " << result << std::endl;
std::cout << "Total: " << total.count() << " ns" << std::endl;
std::cout << "Average: " << average.count() << " ns" << std::endl;
assert(total.count() > 0);
assert(average.count() > 0);
assert(average <= total);
return 0;
}Group testcases into a suite with shared fixtures:
#include <testftw.hpp>
#include <cassert>
using namespace testftw;
int test_add(int, std::vector<fixture_interface*>) {
assert(1 + 1 == 2);
return 0;
}
int test_subtract(int, std::vector<fixture_interface*>) {
assert(5 - 3 == 2);
return 0;
}
int main() {
testcase<false, 1, int, int> t1(test_add);
testcase<false, 1, int, int> t2(test_subtract);
testsuite<int, int> suite;
suite.add(&t1, 0);
suite.add(&t2, 0);
suite.run(0);
std::cout << "All suite tests passed!" << std::endl;
return 0;
}Use pointer arguments with automatic null detection:
#include <testftw.hpp>
#include <cassert>
using namespace testftw;
class ptr_fixture : public fixture_base<int, void*> {
bool was_null = false;
int setup(void *value) override {
was_null = (value == nullptr);
return was_null ? 0 : 1;
}
int operator()(void *value) override {
return value ? 1 : 0;
}
int teardown(void *) override {
return was_null ? 0 : 1;
}
};
int body(void*, std::vector<fixture_interface*>) {
return 0;
}
int main() {
ptr_fixture fixture;
auto arg = ptr_fixture::makearg(nullptr);
testcase<false, 1, int, void*> tc(body, {{&fixture, arg}});
int result = tc.run(reinterpret_cast<void*>(0x1));
assert(result == 0);
return 0;
}template<class derived, class base>
std::unique_ptr<derived> unique_ptr_dynamic_cast(std::unique_ptr<base>&& p);
template<class derived, class base>
std::unique_ptr<derived> unique_ptr_dynamic_cast(std::unique_ptr<base>& p);Dynamically casts ownership from a base unique_ptr to a derived unique_ptr.
Parameters:
p— Source unique_ptr (rvalue or lvalue reference)
Returns:
std::unique_ptr<derived>on successful cast (ownership transferred)nullptron failed cast (source retains ownership)
Example:
std::unique_ptr<base_type> base_ptr(new derived_type());
std::unique_ptr<derived_type> derived_ptr =
unique_ptr_dynamic_cast<derived_type>(std::move(base_ptr));
assert(derived_ptr.get() != nullptr); // Cast succeeded
assert(base_ptr.get() == nullptr); // Ownership transferred#define BENCHMARK_START(runs)
#define BENCHMARK_END(totalns)Scoped benchmarking macros that execute a block multiple times and measure elapsed time.
Parameters:
runs— Number of iterations to executetotalns—std::chrono::nanosecondsvariable receiving elapsed time
Example:
std::chrono::nanoseconds total;
int counter = 0;
BENCHMARK_START(1000)
++counter;
BENCHMARK_END(total)
assert(counter == 1000);
std::cout << "Elapsed: " << total.count() << " ns" << std::endl;Abstract base class for type-erased fixtures.
class fixture_interface {
public:
using fixture_interface_argtype = std::shared_ptr<std::any>;
virtual std::any call_setup(const std::any&) = 0;
virtual std::any bindcall_setup() = 0;
virtual std::any call(const std::any&) = 0;
virtual std::any bindcall() = 0;
virtual std::any call_teardown(const std::any&) = 0;
virtual std::any bindcall_teardown() = 0;
virtual void setarg(const fixture_interface_argtype&) = 0;
virtual ~fixture_interface() = default;
protected:
fixture_interface_argtype arg;
};class fixture_interface {
public:
struct fixture_interface_ret { virtual ~fixture_interface_ret() = default; };
struct fixture_interface_arg { virtual ~fixture_interface_arg() = default; };
using fixture_interface_returntype = std::shared_ptr<fixture_interface_ret>;
using fixture_interface_argtype = std::shared_ptr<fixture_interface_arg>;
virtual fixture_interface_returntype call_setup(const fixture_interface_argtype&) = 0;
virtual fixture_interface_returntype call(const fixture_interface_argtype&) = 0;
virtual fixture_interface_returntype call_teardown(const fixture_interface_argtype&) = 0;
virtual void setarg(const fixture_interface_argtype&) = 0;
virtual fixture_interface_returntype bindcall_setup() = 0;
virtual fixture_interface_returntype bindcall() = 0;
virtual fixture_interface_returntype bindcall_teardown() = 0;
virtual ~fixture_interface() = default;
protected:
fixture_interface_argtype arg;
};Key Methods:
call_*()— Invoke with an explicit argumentbindcall_*()— Invoke with the bound argument (set viasetarg())setarg()— Bind an argument for subsequentbindcall_*invocations
Typed fixture implementation. Derive from this class and implement setup(), operator()(), and teardown().
template<typename returntype, typename argtype>
class fixture_base : public fixture_interface {
public:
// Create a bound argument object
static fixture_interface_argtype makearg(argtype a);
// Pure virtual hooks to implement
virtual returntype setup(argtype a) = 0;
virtual returntype operator()(argtype a) = 0;
virtual returntype teardown(argtype a) = 0;
// Bind an argument for bindcall_* operations
void setarg(const fixture_interface_argtype& a) override;
};Template Parameters:
returntype— Return type for setup, body, and teardown operationsargtype— Argument type accepted by the fixture
Static Methods:
// Create a bound argument object for this fixture type
static fixture_interface_argtype makearg(argtype a);
// (Pre-C++17 only) Cast type-erased return wrapper to typed wrapper
static fixture_base_returntype retval(const fixture_interface_returntype& r);makearg(argtype a)— Create a bound argument object for this fixture type. Returnsfixture_interface_argtype(astd::shared_ptr<std::any>in C++17+ or a shared polymorphic wrapper in pre-C++17).retval(const fixture_interface_returntype& r)— (Pre-C++17 only) Cast a type-erased return wrapper to this fixture's typed return wrapper. Returnsfixture_base_returntypeornullptron type mismatch.
Example:
class my_fixture : public fixture_base<int, int> {
int setup(int value) override {
// Initialization logic
return value;
}
int operator()(int value) override {
// Test body logic
return value * 2;
}
int teardown(int value) override {
// Cleanup logic
return value;
}
};Abstract testcase interface stored by testsuite.
template<typename return_type = int, typename arg_type = void*>
class testcase_base {
public:
#if __cplusplus >= 201703L
virtual std::optional<return_type> operator()(
arg_type data,
std::chrono::nanoseconds *ptotal = nullptr,
std::chrono::nanoseconds *pns = nullptr
) = 0;
#else
virtual return_type operator()(
arg_type data,
std::chrono::nanoseconds *ptotal = nullptr,
std::chrono::nanoseconds *pns = nullptr
) = 0;
#endif
virtual ~testcase_base() = default;
};Concrete testcase wrapper with optional timing and iteration.
template<
bool timed = false,
std::size_t runs = 1,
typename return_type = int,
typename arg_type = void*,
typename fn_type = std::function<return_type(arg_type, std::vector<fixture_interface*>)>
>
class testcase final : public testcase_base<return_type, arg_type> {
public:
// Fixture pointer plus bound argument for construction
struct fixture_pack {
fixture_interface *fixture;
const fixture_interface::fixture_interface_argtype &arg;
};
// Construct from callable and fixture packs
testcase(fn_type fn_, std::initializer_list<fixture_pack> fixtures_ = {});
// Construct from callable and varargs fixture packs
testcase(fn_type fn_, std::size_t nfixtures_, ...);
// Construct from existing fixture pointer vector
testcase(fn_type fn_, const std::vector<fixture_interface*>& fixtures_);
testcase(fn_type fn_, std::vector<fixture_interface*>&& fixtures_);
// Run the testcase
return_type run(
arg_type data,
std::chrono::nanoseconds *ptotal = nullptr,
std::chrono::nanoseconds *pavg = nullptr
);
};Template Parameters:
| Parameter | Default | Description |
|---|---|---|
timed |
false |
Enable timing and iteration |
runs |
1 |
Number of iterations (when timed) |
return_type |
int |
Testcase return type |
arg_type |
void* |
Testcase argument type |
fn_type |
std::function<...> |
Callable signature |
Constructors:
- Initializer list:
testcase(fn, {{&fixture1, arg1}, {&fixture2, arg2}}) - Varargs:
testcase(fn, nfixtures, fixture_ptr1, fixture_arg1, ...) - Vector:
testcase(fn, std::vector<fixture_interface*>{...})
Collection of testcases with optional suite-level fixtures.
template<typename return_type = int, typename arg_type = void*>
class testsuite {
public:
using times_type = std::pair<std::chrono::nanoseconds, std::chrono::nanoseconds>;
// Fixture pointer plus bound argument
struct fixture_pack {
fixture_interface *fixture;
const fixture_interface::fixture_interface_argtype &arg;
};
// Testcase pointer plus argument
struct testcase_pack {
testcase_type *testcase;
arg_type arg;
};
// Constructors
testsuite(std::initializer_list<testcase_pack> testcases_,
std::initializer_list<fixture_pack> fixtures_ = {});
testsuite(std::initializer_list<testcase_pack> testcases_);
testsuite(std::initializer_list<fixture_pack> fixtures_ = {});
testsuite(const std::vector<testcase_pack>& testcases_,
const std::vector<fixture_interface*>& fixtures_ = {});
testsuite(std::vector<testcase_pack>&& testcases_,
std::vector<fixture_interface*>&& fixtures_ = {});
// Add a testcase to the suite
void add(testcase_type *tcptr, arg_type arg);
// Remove matching testcase from the suite
void remove(testcase_type *tcptr, arg_type arg);
// Run all testcases
void run(arg_type data, std::vector<times_type> *ptimes = nullptr);
};Key Methods:
add(tcptr, arg)— Add a testcase with its argumentremove(tcptr, arg)— Remove a matching testcaserun(data, ptimes)— Execute all testcases; suite fixtures run setup before and teardown after
Example:
testcase<false, 1, int, int> t1(test_func);
testsuite<int, int> suite(
{{&t1, 42}}, // testcase packs
{{&fixture, fixture_arg}} // suite-level fixtures
);
suite.run(0);testftw uses type erasure to store fixtures and testcases with heterogeneous types in containers. The framework provides two implementations:
| Standard | Mechanism | Trade-offs |
|---|---|---|
| C++17+ | std::any |
Simpler code, requires <any> header |
| Pre-C++17 | Polymorphic wrappers | More code, no extra headers |
The header uses #if __cplusplus >= 201703L to select implementations:
testftw.hpp
├── std::make_unique polyfill (C++11 only)
├── fixture_interface
│ ├── C++17+: std::any-based interface
│ └── Pre-C++17: Polymorphic wrapper interface
├── fixture_base<returntype, argtype>
│ ├── C++17+: Direct std::any_cast operations
│ └── Pre-C++17: Wrapper-based type conversion
├── testcase<timed, runs, ...>
│ ├── C++17+: if constexpr for timed/untimed paths
│ └── Pre-C++17: SFINAE template specialization
└── testsuite<return_type, arg_type>
└── Shared implementation (uses testcase_base)
┌──────────────────────┐
│ testcase.run(data) │
└──────────┬───────────┘
│
▼
┌────────────────────────────┐
│ For each fixture: │
│ fixture.bindcall_setup() │
└─────────────┬──────────────┘
│
▼
┌─────────────────────────┐
│ Execute testcase body │
│ fn(data, fixtures) │
└────────────┬────────────┘
│
▼
┌───────────────────────────────┐
│ For each fixture: │
│ fixture.bindcall_teardown() │
└───────────────┬───────────────┘
│
▼
┌─────────────────┐
│ Return result │
└─────────────────┘
Suite-Level Fixtures:
When fixtures are added to a testsuite, they execute their lifecycle around all testcases:
suite.run()
├── suite fixture setup (once)
├── testcase 1
├── testcase 2
├── ...
└── suite fixture teardown (once)
All commands are run from the tests/ directory:
cd testsBuild and run the test suite with the default C++ standard (C++23):
make checkRun the test suite across all supported C++ standards:
make compatThis executes:
make clean && make check CXX_VERSION=c++11make clean && make check CXX_VERSION=c++14make clean && make check CXX_VERSION=c++17make clean && make check CXX_VERSION=c++23
Run with AddressSanitizer and UndefinedBehaviorSanitizer:
make asanRemove generated test binaries:
make cleanOverride the compiler with CXX:
make check CXX=clang++| Standard | Status | Notes |
|---|---|---|
| C++11 | Supported | Uses std::make_unique polyfill, polymorphic fixture wrappers |
| C++14 | Supported | Same as C++11 with improved SFINAE |
| C++17 | Supported | Uses std::any, if constexpr, std::optional |
| C++23 | Supported | Full modern C++ support |
- GCC (g++) — Primary development compiler
- Clang (clang++) — Verified via
CXX=clang++ make check
| Header | Standard | Purpose |
|---|---|---|
<cstdint> |
C++11 | Fixed-width integers |
<cstdarg> |
C++11 | Varargs support |
<utility> |
C++11 | std::move, std::forward |
<vector> |
C++11 | Container storage |
<functional> |
C++11 | std::function |
<algorithm> |
C++11 | std::remove |
<memory> |
C++11 | Smart pointers |
<chrono> |
C++11 | Timing utilities |
<optional> |
C++17 | Optional return values |
<any> |
C++17 | Type-erased storage |
The project includes a Doxyfile for generating API documentation:
# Generate HTML documentation
doxygen Doxyfile
# Open in browser (Linux)
xdg-open docs/html/index.htmlDocumentation is output to docs/html/.
- Two-space indentation in class bodies and control blocks
- Opening braces on the same line for functions and conditionals
lower_snake_casefor identifiers- Template parameter names should be descriptive (e.g.,
return_type,arg_type)
- All new functionality must include test cases in
tests/testftw_tests.cc - Tests must pass across all supported C++ standards (
make compat) - New code should pass sanitizer checks (
make asan)
- One concise sentence per commit
- Follow up in paragraphs with details if you may
- Capitalize first word
- Describe the API or behavior change
- List C++ standards tested
- Mention compatibility tradeoffs
- Include command output summaries
MIT License — see LICENSE for details.
Copyright (C) 2026 notweerdmonk
Assisted-by: Codex:gpt-5.5-medium
Assisted-by: OpenCode:mimo-v2.5-free