-
Notifications
You must be signed in to change notification settings - Fork 0
Test Engine
serp::test_engine is a component testing framework built on top of Google Test. It gives service-level tests a managed event loop, controlled service lifecycle, async reply capture, and structured assertion helpers — without requiring a live transport or a full process deployment.
The test engine is included in the serp-dev package and is enabled by building with:
SERP_BUILD_TEST_ENGINE=ONCMake target: Serp::test_engine
target_link_libraries(my_tests PRIVATE Serp::test_engine GTest::gtest_main)The test engine is a development dependency. Do not link it into production binaries.
The central fixture helper. It:
- Creates and owns an
EventLoopfor the test. - Manages the full service lifecycle (
initialize→ test →deinitialize). - Enforces per-test timeouts so a stalled async call fails the test rather than hanging the suite.
- Provides
waitFor()to block the test thread until an async reply arrives.
A typed capture object for async replies. Pass reply.callback() wherever a service method expects its reply callback. Call runner.waitFor(reply, timeout) to block until the reply arrives, then assert on reply.value().
Validation helpers for asserting on string-typed or structured replies using check types:
| Check type | Meaning |
|---|---|
EQUAL |
Exact string match |
START |
Reply starts with the given prefix |
END |
Reply ends with the given suffix |
CONTAIN |
Reply contains the given substring |
REGEX |
Reply matches a regular expression |
JSON |
Reply parses as JSON and matches the expected structure |
#include <serp/test_engine/TestRunner.hpp>
#include <gtest/gtest.h>
class MyServiceTest : public testing::Test {
protected:
serp::TestRunner runner;
std::shared_ptr<MyService> service;
void SetUp() override {
service = runner.create<MyService>();
runner.initialize();
}
void TearDown() override {
runner.deinitialize();
}
};
TEST_F(MyServiceTest, BasicMethod) {
auto reply = serp::ReplyHolder<std::string>();
service->greet("world", reply.callback());
runner.waitFor(reply, std::chrono::seconds(2));
EXPECT_EQ(reply.value(), "Hello, world!");
}Key points:
-
runner.create<MyService>()constructs the service on the test event loop, not on a real service thread. The service behaves exactly as it would in production but is driven by the test runner. -
runner.initialize()callsonInitialize()on all created services and blocks until they complete. -
runner.deinitialize()callsonDeinitialize()on all services and blocks until they complete. -
runner.waitFor(reply, timeout)pumps the event loop until the reply arrives or the timeout expires. If the timeout expires, the test fails with a descriptive message.
ReplyHolder captures the most recent value. For testing a sequence of replies, use multiple holders or assert inside the callback:
TEST_F(MyServiceTest, SequentialCalls) {
auto r1 = serp::ReplyHolder<int>();
auto r2 = serp::ReplyHolder<int>();
service->add(2, 3, r1.callback());
runner.waitFor(r1, std::chrono::seconds(1));
EXPECT_EQ(r1.value(), 5);
service->multiply(4, 6, r2.callback());
runner.waitFor(r2, std::chrono::seconds(1));
EXPECT_EQ(r2.value(), 24);
}Verify that the event loop is not blocked or stalled after an operation:
TEST_F(MyServiceTest, EventLoopIdle) {
service->triggerBackgroundWork();
runner.waitIdle(std::chrono::milliseconds(500));
// Event loop processed all queued callbacks within 500ms
}waitIdle() returns once no more callbacks are pending on the test event loop. Use it to ensure that a triggered operation has fully completed before asserting on side effects.
Services that depend on other services can be tested with generated mock objects:
class MyServiceTest : public testing::Test {
protected:
serp::TestRunner runner;
std::shared_ptr<MockDependencyService> mockDep;
std::shared_ptr<MyService> service;
void SetUp() override {
// Create mock before the service that depends on it
mockDep = runner.create<MockDependencyService>();
service = runner.create<MyService>(mockDep);
// Set expectations before initialize()
EXPECT_CALL(*mockDep, fetchData(testing::_, testing::_))
.WillOnce(testing::InvokeArgument<1>(42));
runner.initialize();
}
void TearDown() override {
runner.deinitialize();
}
};Mocks are generated from SerpIDL interfaces alongside the real service stubs. The generated mock class follows the Google Mock naming convention (Mock<InterfaceName>).
TestRunner can simulate inbound calls to a service without a real transport client:
TEST_F(MyServiceTest, SimulatedInboundCall) {
auto reply = serp::ReplyHolder<std::string>();
// Drive the service as if a remote caller invoked the method
runner.invoke(*service, &MyService::processRequest, "input_data", reply.callback());
runner.waitFor(reply, std::chrono::seconds(2));
EXPECT_EQ(reply.value(), "processed: input_data");
}This dispatches the call on the test event loop, exactly as the transport layer would in production.
TEST_F(MyServiceTest, JsonReply) {
auto reply = serp::ReplyHolder<std::string>();
service->getStatus(reply.callback());
runner.waitFor(reply, std::chrono::seconds(1));
serp::PromiseTest::check(reply.value(),
serp::CheckType::JSON,
R"({"status":"ok"})");
}
TEST_F(MyServiceTest, PrefixCheck) {
auto reply = serp::ReplyHolder<std::string>();
service->getVersion(reply.callback());
runner.waitFor(reply, std::chrono::seconds(1));
serp::PromiseTest::check(reply.value(),
serp::CheckType::START,
"v2.");
}find_package(Serp REQUIRED COMPONENTS test_engine)
find_package(GTest REQUIRED)
add_executable(my_service_tests
component_test/MyServiceTest.cpp
)
target_link_libraries(my_service_tests PRIVATE
my_service_lib
Serp::test_engine
GTest::gtest_main
)
include(GoogleTest)
gtest_discover_tests(my_service_tests)See serp-demo/component_test/ in the serp-demo repository for a complete working example including service setup, mock injection, and reply capture.
Everything above is for tests you write by hand. The generator can also produce real, runnable component tests directly from sequence diagrams — specs/services/<group>/<service>/sequences/*.suml — the same serp::TestRunner machinery underneath, but the fixture, mocks, and assertions are all generated, not written.
A sequence diagram is a behavioral contract: one trigger (a method call, an incoming notification, or a property change), participants classified as the component-under-test, a mocked dependency, or an external actor, and an ordered message exchange between them — including precondition ... end setup blocks for steps that establish state before the actual trigger being tested:

For each service, the generator emits, as a sibling tree — not mixed in with your own hand-written tests — under gen/tests/services/<group>/<service>/:
-
<Service>Fixture.hpp/.cpp— one shared<Service>Test : public ::testing::Testfixture: aserp::TestRunner, one generated<Dep>Mockperuses:dependency (each dependency interface's staticcreate()factory is overridden to return the mock — that's the dependency-injection point), and the real hand-writtensrc/class as the component under test. -
one
<trigger>Test.cppper sequence file (e.g.playTest.cpp,nextTest.cpp) — one or moreTEST_F(<Service>Test, <scenario_name>)blocks that drive the CUT and assert the full ordered message sequence viaTestRunner::expect(...), not just the final return value.
TEST_F(CarMediaManagerTest, play_granted_fresh_play)
{
mCUT->Init()->getValue();
mCUT->PlaybackChanged.connect([this](const PlaybackState& state, const std::optional<TrackInfo>& track) {
mTestRunner->receive("PlaybackChanged.connect", state, track);
});
auto reply = mCUT->Play("track_01");
mTestRunner->expect(_CL_, "CarAudioManager::requestFocus(CarMediaManager, 0", FocusDecision::granted, serp::CheckType::START);
mTestRunner->expect(_CL_, "PlaybackChanged.connect(", {}, serp::CheckType::START);
// ...
}A real GTest executable + add_test() is generated per service (previously the target was always a library, never runnable directly), and every service now unconditionally links Serp::command and globs a user-owned commands/ folder for Command-backed sequence implementations.
Turning it off: the SERP AI Settings toggle "Generate tests during generation" (and the equivalent checkbox in SERP AI Configuration) controls whether this runs at all — disabling it passes --no-sequence-tests to generation.
Drift between a diagram's trigger and the real interface member is a warning (SERP070) during validate, not a hard error — it only becomes a hard error at generate-workspace time.
-
Services and Lifecycle — lifecycle callbacks that
TestRunnerdrives - Methods — method signatures your tests call
- Runtime and Debug Tools — interactive debugging complement to automated tests
-
Explorer — where generated (
gen) and manual tests show up per service -
Implementation Runs — the
service-implementation/sequence-implementationworkflows that these generated tests validate against
Getting Started
The Development Model
Architecture Language
Code Generator
- Generator Overview
- Generated Code Layout
- Deployment Configurations
- Lifecycle Backends
- CMake Integration
Framework Internals
- Core Concepts
- Services & Lifecycle
- Methods
- Properties
- Notifications
- Timers & Watchdog
- Promises & Async
- Streams
- Commands
- Logging
- Test Engine
- Transports
- Runtime & Debug Tools
VS Code Plugin
Examples