Framework test lifecycle¶
This page covers everything that happens around your test: how a coroutine becomes a registered test, how checks record success and failure, who owns the processes it spawns, how a test ends, and what it reports when it does.
All of this lives in the reusable C++ framework rather than in the cpptb
command, so an embedding application can discover a compiled catalog, select
one test, run it, and consume its result directly — no particular build system
or regression harness required. If you just want to run tests from the command
line, Running tests is the shorter path.
The framework owns:
compiled test registration, metadata, and exact selection;
one
TestContextand oneTestResultfor each invocation;nonfatal checks, fatal requirements, and structured warnings;
ownership, provenance, and cleanup of processes started by a test;
terminal test states and simulation-time timeout policy; and
optional in-process result callbacks through
ResultSink.
Process-aware diagnostic messages are provided by the separate structured logging API. Log levels and ordered log histories remain observational; this page defines the warnings and checks that change structured test results.
A harness may build simulator executables, start fresh processes, filter catalogs, enforce wall-time limits, and translate results into CI formats. Those policies are deliberately outside the lifecycle API. See Running tests for the optional reference harness.
Register and invoke tests¶
Register each root coroutine in the testbench translation unit:
Task<void> reset_defaults(Dut dut, TestContext& test) {
dut.clk.set_now(0);
test.start_clock(dut.clk, 10_ns);
dut.rst_n.set(0);
co_await RisingEdge{dut.clk};
co_await ReadOnly{};
test.expect_eq("reset count", dut.count.get(), 0u);
}
CPPTB_REGISTER_TEST(reset_defaults);
The framework exposes registered_tests<Dut>() for catalog discovery and
run_registered_test(...) for exact selection. Each invocation receives
fresh lifecycle state; checks, failures, warnings, and owned processes are
never shared between results.
Checks and diagnostics¶
Checks always carry a concise user label:
test.expect("response is in range", response >= low && response <= high);
test.expect_eq("response payload", response, expected);
test.require("configuration loaded", configured);
test.require_eq("protocol version", version, kSupportedVersion);
expect() and expect_eq() are nonfatal. Independent failures accumulate in
the current result. require() and require_eq() record a failure and stop
the owning test cleanly, allowing structured reporting to complete.
Equality checks retain formatted actual and expected values. Formatting is
only invoked after actual == expected fails, so passing checks do not build
diagnostic strings.
Value type |
Failure representation |
|---|---|
Boolean |
|
Signed and unsigned integers |
Decimal value |
Floating point |
Round-trip decimal value |
C++ enums |
Symbolic name when supplied, otherwise the underlying value |
Strings and string-like values |
Text value |
Width-qualified hexadecimal, such as |
|
|
Width-qualified binary preserving |
Arrays and input ranges |
Recursive list, such as |
Generated packed views |
Width-qualified raw packed value |
Generated HDL enums provide their symbolic names automatically. A user type
can specialize DiagnosticFormatter<T> without modifying the check API:
template <>
struct cpptb::DiagnosticFormatter<Transaction> {
static std::string format(const Transaction& value) {
return "Transaction{address=" + std::to_string(value.address) +
", data=" + std::to_string(value.data) + "}";
}
};
Warnings and process ownership¶
warn() records a structured warning without failing the test:
test.warn("response used the protocol fallback");
Start lifecycle-owned concurrency through the context:
Task<void> protocol_watcher(Dut dut, TestContext test) {
while (true) {
co_await RisingEdge{dut.response_valid};
co_await ReadOnly{};
test.expect_eq("response reserved bits",
dut.response_data.get() & 0xff00'0000u, 0u);
}
}
Task<void> traffic_test(Dut dut, TestContext& test) {
test.spawn_detached(protocol_watcher(dut, test));
auto driver = test.spawn(input_driver(dut));
co_await driver;
test.require_eq("driver completed", driver.done(), true);
}
CPPTB_REGISTER_TEST(traffic_test);
Both forms attach the child to the current test. A detached process is
detached only from a user-visible Process handle; it remains owned by the
test. Normal completion, a fatal requirement, a timeout, or an uncaught child
exception cancels unfinished owned processes.
The watcher takes TestContext by value because it may remain alive until the
root test completes. Its checks and uncaught exceptions still carry the
watcher’s process ID and spawn location. The finite driver retains a handle so
the foreground sequence can await it and inspect its terminal state.
Use spawn() when work must run concurrently, needs an independent process
identity, or may be cancelled through a handle. A sequential helper can be
awaited directly with co_await helper(...); this avoids creating an
independent process while retaining ordinary coroutine composition.
Every root and spawned process receives a stable invocation-local numeric ID.
Failures, warnings, and exceptions retain that ID, the process description,
and the source location of the spawn() call. An uncaught child exception is
therefore attributed to its owning test and process instead of being lost or
surfacing later as a generic timeout.
Lifecycle outcomes¶
The terminal states have explicit meanings:
Status |
Meaning |
Successful result? |
|---|---|---|
|
Body completed with no failures |
Yes |
|
An expectation or requirement failed |
No |
|
Selection failed or an exception escaped |
No |
|
Metadata or the running test requested a skip |
Yes |
|
A test marked expected-to-fail failed normally |
Yes |
|
A test marked expected-to-fail passed |
No |
|
The simulation-time limit expired |
No |
running and not_run are nonterminal states used during selection and
execution. Exceptions and timeouts are never converted into expected
failures.
A test can skip dynamically:
if (!dut.has_optional_feature.get()) {
test.skip("optional feature is not present");
}
Static policy belongs in registration metadata:
CPPTB_REGISTER_TEST_WITH_OPTIONS(
known_issue,
(::cpptb::TestOptions{
.tags = {"nightly", "known-issue"},
.expected_failure = true,
.expected_failure_reason = "tracked as RTL-241",
.simulation_timeout = 20_us,
}));
The timeout is measured in simulator time and cancels the test body cleanly. A harness-level wall-time timeout is a separate safeguard for a stalled simulator process.
Parameterized cases¶
Parameterized cases retain one typed C++ value per descriptor. Stable catalog
names use test_name[case_name], and an embedding harness can inspect the
case name and tags without knowing how the value is represented:
struct CounterCase {
uint32_t pulses;
uint32_t expected;
};
Task<void> counter_case(Dut dut, TestContext& test,
const CounterCase& parameter) {
for (uint32_t i = 0; i < parameter.pulses; ++i) {
co_await RisingEdge{dut.clk};
}
test.expect_eq("count", dut.count.get(), parameter.expected);
}
CPPTB_REGISTER_TEST_CASE(counter_case, empty, (CounterCase{0, 0}));
CPPTB_REGISTER_TEST_CASE_WITH_OPTIONS(
counter_case, wraps, (CounterCase{256, 0}),
(::cpptb::TestOptions{.tags = {"nightly", "wrap"}}));
The catalog contains counter_case[empty] and counter_case[wraps] as
independently selectable tests.
Structured results¶
TestResult is the harness-neutral result model. It records:
test and case names, tags, terminal status, and status reason;
check, failure, and warning counts;
final simulation and wall time;
structured failure and warning records; and
process identity, spawn location, check location, and comparison values.
ResultSink provides optional test_started, failure_recorded,
warning_recorded, and test_finished callbacks. The versioned JSON
serializer is another adapter over the same result model. Neither requires
the public command-line harness.
CLI tag filtering, process launch policy, JUnit XML, waveform-on-failure reruns, wall-time enforcement, reproduction command rendering, and build diagnostic presentation remain harness concerns rather than framework lifecycle behavior.