Skip to content

Test Engine

Oleksandr Geronime edited this page Jul 16, 2026 · 2 revisions

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.


Availability

The test engine is included in the serp-dev package and is enabled by building with:

SERP_BUILD_TEST_ENGINE=ON

CMake 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.


Key Classes

serp::TestRunner

The central fixture helper. It:

  • Creates and owns an EventLoop for 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.

serp::ReplyHolder<T>

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().

serp::PromiseTest

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

Basic Test Pattern

#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() calls onInitialize() on all created services and blocks until they complete.
  • runner.deinitialize() calls onDeinitialize() 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.

Testing Multiple Reply Values

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);
}

Idle Checking

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.


Mocking Dependencies

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>).


Method Receive / Invoke Simulation

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.


Structured Assertions with PromiseTest

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.");
}

CMake Integration

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)

Example Project

See serp-demo/component_test/ in the serp-demo repository for a complete working example including service setup, mock injection, and reply capture.


Generated Tests from Sequence Diagrams

Everything above is for tests you write by hand. The generator can also produce real, runnable component tests directly from sequence diagramsspecs/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:

A generated sequence test's source diagram

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::Test fixture: a serp::TestRunner, one generated <Dep>Mock per uses: dependency (each dependency interface's static create() factory is overridden to return the mock — that's the dependency-injection point), and the real hand-written src/ class as the component under test.
  • one <trigger>Test.cpp per sequence file (e.g. playTest.cpp, nextTest.cpp) — one or more TEST_F(<Service>Test, <scenario_name>) blocks that drive the CUT and assert the full ordered message sequence via TestRunner::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.


See Also

  • Services and Lifecycle — lifecycle callbacks that TestRunner drives
  • 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-implementation workflows that these generated tests validate against

Clone this wiki locally