Saga scenario testing

Component: Testing
NuGet Package: NServiceBus.Testing (7.4)

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

The TestableSaga class allows this type of scenario testing and supports the following features:

  • Exercises the ConfigureHowToFindSaga method to ensure that mappings are valid.
  • Emulates how sagas are processed by NServiceBus, including automatically setting the correlation property in the saga data when the first message is received.
  • Stores timeouts internally, which can be triggered by advancing time.

Example

Here's a simple sample of a 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.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.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.Length, Is.EqualTo(0));

    // 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

In many cases a testable saga can be created using only the type parameters from the saga.

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

This assumes that the saga has a parameter-less constructor. If the saga has a constructor that requires services to be injected, a factory can be specified to create an instance of the saga class for each handled message.

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

By default, the CurrentTime for the saga is set to DateTime.UtcNow. The constructor can also be used to set the CurrentTime to a different initial value. For more details see advancing time.

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

Handling messages

The testable saga is similar to the saga infrastructure in NServiceBus. Every time it is asked to handle a message, the testable saga instantiates a new instance of the saga class and use the mapping information in the ConfigureHowToFindSaga method to locate the correct saga data in the internal storage.

To have the saga infrastructure handle a message, use the Handle method:

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

If necessary, optional parameters exist to allow the use of a custom TestableMessageHandlerContext or specify custom message headers:

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

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

Handler results

The HandleResult returned when each message is handled contains information about the message that was handled and the result of that operation which can be used for assertions.

The HandleResult class contains:

  • SagaId: Identifies the Guid of the saga that was either created or retrieved from storage.
  • Completed: Indicates whether the handler invoked the MarkAsComplete() method.
  • HandledMessage: Contains the message type, headers, and content of the message that was handled.
  • Context: A TestableMessageHandlerContext which contains information about messages sent/published as well as any other operations that occurred on the IMessageHandlerContext while the message was being handled.
  • SagaDataSnapshot: Contains a copy of the saga data after the message handler completed.
  • Convenience methods for finding messages of a given type inside the Context:
    • FindSentMessage<TMessage>()
    • FindPublishedMessage<TMessage>()
    • FindTimeoutMessage<TMessage>()
    • FindReplyMessage<TMessage>()

Advancing time

The testable saga contains a CurrentTime property that represents a virtual clock for the saga scenario. The CurrentTime property defaults to the time when test execution starts, but can be optionally specified in the TestableSaga constructor.

As each message handler runs, timeouts are collected in an internal timeout storage. By calling the AdvanceTime method, these timeouts will come due and the messages they contain will be handled. The AdvanceTime method returns an array of HandleResult, one for each timeout that is handled.

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

DateTime newCurrentTime = testableSaga.CurrentTime;

If a custom TestableMessageHandlerContext is needed to process each timeout, an optional parameter allows creating them:

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

Simulating external handlers

Many sagas send commands to external handlers, which do some work, then send a reply message back to the saga so that the saga can move on to the next step of a multi-step process. These reply messages are auto-correlated: the saga includes the saga ID as a message header in the outbound message, and the external handler returns that message when it does a reply.

In a saga scenario test, the external handler's response can be simulated using the SimulateReply method:

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

When the saga being tested sends a DoStep1 command, the reply is simulated using the provided Func<TSagaMessage, TReplyMessage> delegate. The resulting Step1Response message is added to the testable saga's internal queue, including the header containing the saga's ID so that a ConfigureHowToFindSaga mapping is not required.

Alternatively, a reply message can be handled directly without using a simulator, but then the SagaId value must be provided:

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

The HandleReply method also contains optional parameters for a custom TestableMessageHandlerContext or additional message headers:

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 generated of a type that is handled by the saga is added to the testable saga's internal queue. This includes:

  • When a saga handler sends or publishes any message that the saga itself handles. This is commonly done within a saga to create a new transactional scope around a new message.
  • When using a external handler simulator, the resulting message is added to the queue.

The testable saga has several methods available to evaluate what is in the queue, which can be used for test assertions

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

The next message in the queue is handled by calling HandleQueuedMessage():

await testableSaga.HandleQueuedMessage();

var customContext = new TestableMessageHandlerContext();
await testableSaga.HandleQueuedMessage(context: customContext);
Using HandleQueuedMessage() allows specific ordering of message processing in order to write tests related to specific ordering and race condition concerns. Whether or not a timeout or a reply message is handled first in a specific scenario is controlled by whether the test calls the AdvanceTime() or HandleQueuedMessage() method.

Additional examples

For more examples of what is possible with saga scenario tests, see the saga tests in the NServiceBus.Testing repository.


Last modified