AI agents: use the documentation index at llms.txt to locate machine-readable pages. This section is indexed by https://docs.particular.net/nservicebus/llms.txt. The markdown version of this page is served as plain text. An MCP server at /mcp serves the same content via the search_docs and read_doc tools; it is read-only and needs no credentials. Markdown versions of documentation pages are available by appending .md to the page URL. Directory URLs use index.md. They are served as text/plain because some retrieval backends reject text/markdown.

Saga scenario testing

Component:
Testing
NuGet Package:
NServiceBus.Testing 11.x
Target Version:
NServiceBus 10.x

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);
    using (Assert.EnterMultipleScope())
    {
        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);
    using (Assert.EnterMultipleScope())
    {
        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, Has.Length.EqualTo(1));
    var shipped = timeoutResults.First().FindPublishedMessage<OrderShipped>();
    Assert.That(shipped.OrderId, Is.EqualTo(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();
    });

Chained timeouts

AdvanceTime handles timeouts in due time order, and sets the clock to each timeout's due time while the timeout is handled. The clock never moves backwards. A timeout requested by a timeout handler is handled in the same call when it is due before the end of the advanced time. A saga that requests a new 10-minute reminder from every reminder produces six results when advancing time by one hour:

var timeoutResults = await testableSaga.AdvanceTime(TimeSpan.FromHours(1));

// Reminders at 10, 20, 30, 40, 50, and 60 minutes
Assert.That(timeoutResults, Has.Length.EqualTo(6));

// Each reminder was handled at its own due time
var firstReminder = timeoutResults[0].SagaDataSnapshot;
Assert.That(firstReminder.LastReminderAt, Is.EqualTo(startTime.AddMinutes(10)));

To assert on the saga between reminders, advance time in steps no longer than the reminder interval. Each result also holds a SagaDataSnapshot that can be inspected after a longer advance.

// Advancing by the reminder interval handles one reminder per call
var firstResults = await testableSaga.AdvanceTime(TimeSpan.FromMinutes(10));
Assert.That(firstResults.Single().SagaDataSnapshot.RemindersSent, Is.EqualTo(1));

var secondResults = await testableSaga.AdvanceTime(TimeSpan.FromMinutes(10));
Assert.That(secondResults.Single().SagaDataSnapshot.RemindersSent, Is.EqualTo(2));

A timeout that is already overdue, such as one requested for an absolute time in the past, is handled at CurrentTime because the clock cannot move backwards. Timeouts requested by its handler are scheduled relative to that time.

AdvanceTime throws an ArgumentOutOfRangeException for a negative TimeSpan. It throws an InvalidOperationException after 1,000 timeouts are handled without the clock moving forward, which happens when handlers keep requesting timeouts that are already due, for example with a zero delay.

Each timeout is removed from storage right before it is handled. If the call is canceled or a timeout handler throws, the remaining timeouts stay stored and later calls handle them when they are due. The timeout that threw is not retried. CurrentTime stays at the time the last timeout that started was handled.

Using a time provider

A saga that reads the current time should take a TimeProvider as a dependency instead of calling DateTime.UtcNow. TestableSaga is backed by a FakeTimeProvider from the Microsoft.Extensions.TimeProvider.Testing package, so the time the saga reads is the time that AdvanceTime moves. Timers that other code creates on the same provider fire in order as time advances.

NServiceBus.Testing depends on Microsoft.Extensions.TimeProvider.Testing, version 10.11.0 or later, which is restored as a transitive dependency. A test project only needs its own reference to the package when it must pin a different version, for example with central package management.

The factory overload receives the FakeTimeProvider that the testable saga creates. It starts at DateTimeOffset.UtcNow:

var testableSaga = new TestableSaga<ShippingPolicy, ShippingPolicyData>(
    sagaFactory: timeProvider => new ShippingPolicy(timeProvider, new ShippingCalendar(timeProvider)));

To share the clock with other test doubles or to start at a specific time, create the FakeTimeProvider in the test and pass it to the constructor:

var timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 1, 8, 0, 0, TimeSpan.Zero));
var shippingCalendar = new ShippingCalendar(timeProvider);

var testableSaga = new TestableSaga<ShippingPolicy, ShippingPolicyData>(
    sagaFactory: _ => new ShippingPolicy(timeProvider, shippingCalendar),
    timeProvider: timeProvider);

The provider is available as TestableSaga.TimeProvider, and CurrentTime reads from it. While AdvanceTime handles a timeout, the provider reports the due time of that timeout, or the current time for an overdue timeout. When all timeouts are handled, the provider is at the requested end time:

var timeoutResults = await testableSaga.AdvanceTime(TimeSpan.FromHours(1));

// The handler saw the due time of the timeout, not the end of the advanced period
DateTimeOffset shippedAt = timeoutResults.Single().SagaDataSnapshot.ShippedAt;

DateTime newCurrentTime = testableSaga.CurrentTime;

Time advanced directly on the provider is also honored. New timeouts are scheduled relative to it, and timeouts that become due are handled by the next call to AdvanceTime, for example AdvanceTime(TimeSpan.Zero):

// The same value as testableSaga.CurrentTime
DateTimeOffset now = testableSaga.TimeProvider.GetUtcNow();

testableSaga.TimeProvider.Advance(TimeSpan.FromHours(1));

// Timeouts that became due are handled by the next AdvanceTime call
var timeoutResults = await testableSaga.AdvanceTime(TimeSpan.Zero);

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.