Skip to content

Testing with .NET

TinyConductor.Testing runs an Embedded engine inside your test process: no server, no port, no container. It loads the engine’s native library (bpm-embedded-ffi), which is the Embedded server itself, so your tests run the same engine and get the same answers as production, and gives you a typed API. The API is synchronous: the engine runs in your process and handles one call at a time, so async would only add noise. The full reference is the .NET testing SDK.

The runnable project is testing/dotnet in the examples download.

The SDK is planned to ship as the NuGet package TinyConductor.Testing, with the native library for every platform inside it, so that dotnet add package TinyConductor.Testing is the whole setup. It is not on NuGet yet. Until it is, a release provides the two parts as files:

  1. The SDK package. The release has the package file TinyConductor.Testing.0.1.0.nupkg. Keep it in a folder and make that folder a package source for your test project, with a nuget.config next to the project (or in a folder above it):

    <configuration>
    <packageSources>
    <add key="tinyconductor-release" value="path/to/the/folder/with/the/nupkg" />
    </packageSources>
    </configuration>

    A relative value is relative to the nuget.config file. Then add the package as usual:

    Terminal window
    dotnet add package TinyConductor.Testing --version 0.1.0

    The package holds the SDK only, not the native library.

  2. The native library. Download and unpack the release archive for your platform (the list is in Embedded mode). The library is in its lib folder: libbpm_embedded_ffi.dylib on macOS, libbpm_embedded_ffi.so on Linux and bpm_embedded_ffi.dll on Windows. Copy it next to your test assembly; the example’s project file does that, and fails the build with a clear message if the library is missing:

    <None Include="$(TinyConductorNativeDir)/$(TinyConductorNativeName)"
    Link="$(TinyConductorNativeName)" CopyToOutputDirectory="PreserveNewest" />
  3. Put the BPMN, DMN and form files in the output directory (the example copies the download’s order.* files into processes/).

The example project is set up this way already. Put the downloads in one folder, the examples download and the release archive unpacked and the package file as it is:

downloads/
tinyconductor-0.1.0-examples/
tinyconductor-0.1.0-linux-x86_64/
TinyConductor.Testing.0.1.0.nupkg

Its nuget.config makes the downloads folder the package source for TinyConductor.Testing (the test framework comes from nuget.org), and the project takes the native library from the release archive for your platform in that folder. If the archive is somewhere else, point TinyConductorNativeDir at its lib folder: dotnet test -p:TinyConductorNativeDir=/path/to/tinyconductor-0.1.0-linux-x86_64/lib.

using TinyConductor.Testing;
using Xunit;
public sealed class OrderProcessTests : IDisposable
{
// xUnit creates a new instance, and so a new engine, for every test.
private readonly EmbeddedEngine _engine = new();
[Fact]
public void Order_completes_after_payment_review_period_and_confirmation()
{
_engine.DeployResource("processes/order.form");
_engine.DeployResource("processes/order-decisions.dmn");
_engine.DeployResource("processes/order.bpmn");
_engine.MockJobWorker("send-invoice").ThenComplete();
var order = _engine.CreateInstance(
"order-process",
new { orderId = 4711, amount = 99.90 });
_engine.CompleteJob("charge-card", new { charged = true });
// The 24-hour review timer fires inside this call; no sleeping or polling.
_engine.Clock.AdvanceBy(TimeSpan.FromDays(1));
Assert.Single(_engine.UserTasks).Complete(new { shipped = true });
TinyConductorAssert.That(order)
.IsCompleted()
.HasCompletedElements(
"charge-card", "shipping-tier", "review-period",
"send-invoice", "confirm-shipment")
.HasVariable("charged", true)
.HasVariable("shippingTier", "standard")
.HasVariable("shipped", true)
.HasNoIncidents();
}
public void Dispose() => _engine.Dispose();
}

Run it:

Terminal window
cd tinyconductor-0.1.0-examples/testing/dotnet
dotnet test
NeedCall
deploy a resourceengine.DeployResource(path), engine.DeployResourceXml(name, xml)
start an instanceengine.CreateInstance(processId, variables)
act as a workerengine.CompleteJob(type, variables), engine.ActivateJobs(type) then job.Complete(), job.Fail(...), job.ThrowError(code)
let a worker answer automaticallyengine.MockJobWorker(type).ThenComplete() / .ThenThrowError(...) / .ThenHandle(job => …)
messages and signalsengine.PublishMessage(...), engine.BroadcastSignal(...)
user tasksengine.UserTasks, task.Assign(...), task.Complete(...)
timeengine.Clock.AdvanceBy(TimeSpan), engine.Clock.AdvanceTo(DateTimeOffset), engine.Clock.Current
assertionsTinyConductorAssert.That(instance): IsCompleted, IsActive, IsTerminated, HasCompletedElements, HasActiveElements, HasVariable, HasVariableNames, HasNoIncidents, HasActiveIncidents
start overengine.Reset(); disposing the engine drops everything it held

Moving the clock returns only after everything it caused has run: the timers due at the new instant, the engine work they trigger and any registered worker mocks. An assertion straight after AdvanceBy therefore sees the result.

Jobs have leases, as on a server: ActivateJobs leases each job for 30 seconds of engine time. If you move the clock past that before completing or failing the job, the job is offered again, and completing the old ActivatedJob fails with EmbeddedEngineException. Activate it again after the clock move.

Variables may be anonymous objects, POCOs or dictionaries. Read a job’s variables with job.GetVariable<T>("name"). For a JSON operation the typed API does not cover yet, engine.Transport.Send(json) sends it raw (see C and FFI for the operations).