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.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
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.
. 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 theGuid
of the saga that was either created or retrieved from storage.Completed
: Indicates whether the handler invoked theMarkAsComplete()
method.HandledMessage
: Contains the message type, headers, and content of the message that was handled.Context
: ATestableMessageHandlerContext
which contains information about messages sent/published as well as any other operations that occurred on theIMessageHandlerContext
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
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.