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.
, 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:
- Instantiates a new saga instance.
- 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
: TheGuid
of the saga instance created or loaded.Completed
: WhetherMarkAsComplete()
was called.HandledMessage
: Type, headers, and body of the message handled.Context
: ATestableMessageHandlerContext
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:
- Saga sends/publishes of messages it also handles.
- Messages produced by external handler simulations.
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);
Use HandleQueuedMessage()
to test specific message ordering or simulate race conditions. Control whether timeouts or replies are processed first by choosing AdvanceTime()
or HandleQueuedMessage()
accordingly.
Additional examples
For more usage patterns, see the NServiceBus.Testing saga tests.