Getting Started
Architecture
NServiceBus
Transports
Persistence
ServiceInsight
ServicePulse
ServiceControl
Monitoring
Modernization
Samples

Saga scenario testing

Component: Testing
NuGet Package: NServiceBus.Testing (7.4)
Target Version: NServiceBus 7.x
Standard support for version 7.x of NServiceBus has expired. For more information see our Support Policy.

While each handler in a saga can be tested using a unit test, it's often helpful to test an entire scenario involving multiple messages.

The TestableSaga class enables this kind of scenario testing and supports the following:

  • Exercises the ConfigureHowToFindSaga method to ensure mappings are valid.
  • Emulates saga processing behavior in NServiceBus, including automatic correlation property assignment in saga data upon receiving the first message.
  • Stores timeouts internally, which can be triggered by advancing time.

Example

A simple scenario test of a ShippingPolicy saga, including timeouts:

[Test]
public async Task SampleSagaScenarioTest()
{
    // Create the testable saga
    var testableSaga = new TestableSaga<ShippingPolicy, ShippingPolicyData>();

    // Create input messages
    var orderId = Guid.NewGuid().ToString().Substring(0, 8);
    var orderPlaced = new OrderPlaced { OrderId = orderId };
    var orderBilled = new OrderBilled { OrderId = orderId };

    // Process OrderPlaced and make assertions on the result
    var placeResult = await testableSaga.Handle(orderPlaced);
    Assert.Multiple(() =>
    {
        Assert.That(placeResult.Completed, Is.False);
        Assert.That(placeResult.FindPublishedMessage<OrderShipped>(), Is.Null);
        Assert.That(placeResult.FindTimeoutMessage<ShippingDelay>(), Is.Null);
    });

    // Process OrderBilled and make assertions on the result
    var billResult = await testableSaga.Handle(orderBilled);
    Assert.Multiple(() =>
    {
        Assert.That(billResult.Completed, Is.False);
        Assert.That(billResult.FindPublishedMessage<OrderShipped>(), Is.Null);
        Assert.That(billResult.FindTimeoutMessage<ShippingDelay>(), Is.Not.Null);

        // Each result includes a snapshot of saga data after each message.
        // Snapshots can be asserted even after multiple operations have occurred.
        Assert.That(placeResult.SagaDataSnapshot.OrderId, Is.EqualTo(orderId));
        Assert.That(placeResult.SagaDataSnapshot.Placed, Is.True);
        Assert.That(placeResult.SagaDataSnapshot.Billed, Is.False);
    });

    // Timeouts are stored and can be played by advancing time
    var noResults = await testableSaga.AdvanceTime(TimeSpan.FromMinutes(10));
    // But that wasn't long enough
    Assert.That(noResults, Is.Empty);

    // Advance time more to get the timeout to fire
    var timeoutResults = await testableSaga.AdvanceTime(TimeSpan.FromHours(1));
    Assert.That(timeoutResults.Length, Is.EqualTo(1));
    var shipped = timeoutResults.First().FindPublishedMessage<OrderShipped>();
    Assert.That(shipped.OrderId == orderId);
}

Creating a testable saga

Often, a testable saga can be created using just the type parameters:

// For testing: public class MySaga : Saga<MyData>
var testableSaga = new TestableSaga<MySaga, MyData>();

This assumes a parameterless constructor. If the saga requires injected services, a factory can be provided to create an instance per handled message:

var testableSaga = new TestableSaga<MySaga, MyData>(
    sagaFactory: () => new MySaga(new InjectedService()));

By default, CurrentTime is initialized to DateTime.UtcNow, but a specific value can be set via the constructor. See advancing time for details:

var testableSaga = new TestableSaga<MySaga, MyData>(
    initialCurrentTime: new DateTime(2022, 01, 01, 0, 0, 0, DateTimeKind.Utc));

Handling messages

The testable saga mimics NServiceBus saga infrastructure. Each time it handles a message, it:

  1. Instantiates a new saga instance.
  2. Uses ConfigureHowToFindSaga to locate the matching saga data in internal storage.

To handle a message:

var handleResult = await testableSaga.Handle(new MyCommand());

Optional parameters allow using a custom TestableMessageHandlerContext or providing custom headers:

var customHandlerContext = new TestableMessageHandlerContext();
var customHeaders = new Dictionary<string, string>();

var handleResult = await testableSaga.Handle(new MyCommand(), context: customHandlerContext, messageHeaders: customHeaders);

Handler results

Handling a message returns a HandleResult, which contains:

  • SagaId: The Guid of the saga instance created or loaded.
  • Completed: Whether MarkAsComplete() was called.
  • HandledMessage: Type, headers, and body of the message handled.
  • Context: A TestableMessageHandlerContext with sent/published messages and other operations during handling.
  • SagaDataSnapshot: Copy of saga data after handling.
  • Helpers for locating specific messages in the Context:
    • FindSentMessage<TMessage>()
    • FindPublishedMessage<TMessage>()
    • FindTimeoutMessage<TMessage>()
    • FindReplyMessage<TMessage>()

Advancing time

CurrentTime represents a virtual clock. It defaults to the test start time but can be set in the constructor.

Timeouts are stored during message handling. Calling AdvanceTime processes due timeouts and returns an array of HandleResult:

// Returns HandleResult[]
var timeoutResults = await testableSaga.AdvanceTime(timeSpanToAdvance);

DateTime newCurrentTime = testableSaga.CurrentTime;

Need custom TestableMessageHandlerContext per timeout? Use the overload:

// Returns HandleResult[]
var timeoutResults = await testableSaga.AdvanceTime(timeSpanToAdvance,
    provideContext: timeoutDetails =>
    {
        // Provide custom context based on timeout details
        return new TestableMessageHandlerContext();
    });

Simulating external handlers

Sagas often send commands to external handlers, expecting replies. These reply messages are auto-correlated using a saga ID header.

Simulate such replies with SimulateReply:

testableSaga.SimulateReply<DoStep1, Step1Response>(step1Command =>
{
    return new Step1Response();
});

The reply is enqueued internally, with the correlation header set. If you want to manually handle a reply instead:

await testableSaga.HandleReply(previousHandleResult.SagaId, new Step1Response());

You can also supply custom headers or a custom context:

var customHandlerContext = new TestableMessageHandlerContext();
var customHeaders = new Dictionary<string, string>();

await testableSaga.HandleReply(previousHandleResult.SagaId, new Step1Response(),
    context: customHandlerContext,
    messageHeaders: customHeaders);

Queued messages

Any message sent/published that the saga handles is queued internally. This includes:

You can inspect and assert against the message queue:

bool hasMessages = testableSaga.HasQueuedMessages;
int size = testableSaga.QueueLength;
var nextMessage = testableSaga.QueuePeek();

To process the next queued message:

await testableSaga.HandleQueuedMessage();

var customContext = new TestableMessageHandlerContext();
await testableSaga.HandleQueuedMessage(context: customContext);

Additional examples

For more usage patterns, see the NServiceBus.Testing saga tests.