Skip to content

Unit testing

Sen ships a TestKernel and a TestComponent so your packages can be driven from an ordinary unit testing framework, without a running system. Both are declared in libs/kernel/include/sen/kernel/test_kernel.h.

TestKernel starts in virtual time, so a test decides when time advances instead of waiting for it. You move it forward with step(), one cycle at a time.

The double step

If your code reacts to property-changed callbacks, you need two steps before the callback runs - kernel.step() twice, or kernel.step(2).

To see why, look at how a cycle works.

Screenshot Screenshot

The properties changed during a cycle are collected in the commit phase ❶, and become visible in the drain phase ❷ of the next cycle.

When a test pushes an update, it does so between kernel steps ❸, while the kernel is waiting. The queue of changed properties for that cycle has already been built, so the change needs one more step to reach the object's getter, and another for the callback that reports it.

Example

The code below is the real test Sen runs against its own kernel, included from libs/kernel/test/unit/test_kernel/test_kernel_test.cpp. It compiles and passes in CI, so it cannot drift from the API it demonstrates.

First the setup: an object, a component that publishes it, and callbacks that count what arrives.

  // to track the evolution of the test
  int32_t counter = 0;
  int32_t eventCount = 0;
  int32_t propCount = 0;
  sen::TimeStamp lastTime;

  // the object that we will stimulate
  auto object = std::make_shared<MyClassImpl>("myObject", sen::VarMap {});

  // set up a component that will hold our object
  sen::kernel::TestComponent component;

  // on init we register the object and set a callback to track event counts
  std::shared_ptr<sen::ObjectSource> source;
  component.onInit(
    [&](sen::kernel::InitApi&& api) -> sen::kernel::PassResult
    {
      source = api.getSource("local.test");
      source->add(object);
      return sen::kernel::done();
    });

  // on each iteration we track the count and last simulation time
  bool emitEvent = true;
  bool emitProp = true;
  component.onRun(
    [&](auto& api)
    {
      object->onSomethingHappened({api.getWorkQueue(), [&]() { ++eventCount; }}).keep();
      object->onPropChanged({api.getWorkQueue(), [&]() { ++propCount; }}).keep();
      return api.execLoop(std::chrono::seconds(1),
                          [&]()
                          {
                            ++counter;
                            lastTime = api.getTime();
                            if (emitEvent)
                            {
                              object->somethingHappened();
                            }
                            if (emitProp)
                            {
                              object->setNextProp(object->getProp() + 1);
                            }
                          });
    });

  // create the kernel holding only our simple component
  sen::kernel::TestKernel kernel(&component);

TestComponent lets you supply the component's phases as lambdas instead of writing a subclass. onInit gets an InitApi and is where objects are published to a bus; onRun gets a RunApi and usually ends in execLoop, which drives a cycle at the given period. The callback passed to execLoop is optional, and runs once per cycle.

Note .keep() on the callback registrations. They return a ConnectionGuard, and dropping it unregisters the callback immediately.

Now the double step, with the assertions that show it:

  // iteration 1, sim time was 1s, we have executed 2s, we emit the event
  emitEvent = true;
  emitProp = true;
  kernel.step();
  EXPECT_EQ(2, counter);
  EXPECT_EQ(0, eventCount);
  EXPECT_EQ(1, object->getProp());
  EXPECT_EQ(0, propCount);
  EXPECT_EQ(std::chrono::seconds(1), lastTime.sinceEpoch().toChrono());
  EXPECT_EQ(std::chrono::seconds(2), kernel.getTime().sinceEpoch().toChrono());

  // iteration 2, sim time was 2s, we have executed 3s, we received the event
  emitEvent = false;
  emitProp = false;
  kernel.step();
  EXPECT_EQ(3, counter);
  EXPECT_EQ(1, eventCount);
  EXPECT_EQ(1, object->getProp());
  EXPECT_EQ(1, propCount);
  EXPECT_EQ(std::chrono::seconds(2), lastTime.sinceEpoch().toChrono());
  EXPECT_EQ(std::chrono::seconds(3), kernel.getTime().sinceEpoch().toChrono());

Read the two blocks together. After the first step() the property has already changed - get<Prop>() returns 1, but propCount is still 0, because the callback has not run yet. It takes the second step() for the change to be reported. That is the double step, and it is why a test that checks a callback after a single step will see nothing.