Skip to content

How to unit test a saga handler

Mogens Heller Grabe edited this page Jun 21, 2019 · 10 revisions

Since sagas are slightly more involved than ordinary messages handlers, Rebus has special support to help you test them: A little helper thing called SagaFixture.

Sagas differ from ordinary message handlers in the sense that

  • correlation of incoming messages, and
  • inspection of saga state before/after message handling, and
  • various events that pertain to Rebus' management of saga state

become important aspects of the test.

Overview

You need to install the Rebus.TestHelpers NuGet package, and then you'll get access to SagaFixture. It can be used like this:

[Test]
public void CanDoStuff()
{
    // you create a fixture for your saga
    using (var fixture = SagaFixture.For(() => new YourSaga()))
    {
        // and then you do stuff in here - e.g. deliver a message:
        fixture.Deliver(new SomeMessage());

        // or inspect the saga data
        var data = fixture.Data
            .OfType<YourSagaData>()
            .Single(d => d.SomeField == "whatever");
        
        Assert.That(data.SomeOtherField, Is.EqualTo("cool"));

        // and more :)
    }
}

Read on for a fuller example. 😊

Example

Here's an example: In the "How to unit test a message handler" example, you can see how a simple message handler can be tested. Let's extend the example by turning it into a saga, so we can

  • make it idempotent towards email addresses (it doesn't make sense to run two concurrent invitation flows for the same email address), and
  • make it consist of multiple steps: Send an invitation email, and then after a week: Re-send the invitation email (in case the first one was missed), and then after yet another week: Abort the invitation process.

thus turning the process of inviting a user into a process governed by a "process manager" (which is what this type of "sagas" are called in the literature).

First, let's turn the code into this – now it's called InviteNewUserSaga, because it handles more than one message, and it's slightly longer: 😁

public class InviteNewUserSaga : Saga<InviteNewUserSagaData>,
    IAmInitiatedBy<InviteNewUserByEmail>,
    IHandleMessages<ResendInvitation>,
    IHandleMessages<UserSuccessfullyRegistered>,
    IHandleMessages<AbortInvitation>
{
    readonly IInvitationService _invitationService;
    readonly IMessageContext _messageContext;
    readonly IBus _bus;

    public InviteNewUserSaga(IBus bus, IInvitationService invitationService, IMessageContext messageContext)
    {
        _bus = bus;
        _invitationService = invitationService;
        _messageContext = messageContext;
    }

    protected override void CorrelateMessages(ICorrelationConfig<InviteNewUserSagaData> config)
    {
        config.Correlate<InviteNewUserByEmail>(m => m.EmailAddress, d => d.EmailAddress);
        config.Correlate<UserSuccessfullyRegistered>(m => m.EmailAddress, d => d.EmailAddress);
        config.Correlate<ResendInvitation>(m => m.EmailAddress, d => d.EmailAddress);
        config.Correlate<AbortInvitation>(m => m.EmailAddress, d => d.EmailAddress);
    }

    public async Task Handle(InviteNewUserByEmail message)
    {
        var headerValue = _messageContext.Headers.GetValue(Headers.SentTime);
        var sentTime = DateTimeOffset.ParseExact(headerValue, "o", null, DateTimeStyles.RoundtripKind);
        var emailAddress = message.EmailAddress;

        // store email address in saga data
        Data.EmailAddress = emailAddress;

        // send invitation
        await _invitationService.Invite(emailAddress, sentTime);

        // ensure we resend in one week
        await _bus.DeferLocal(TimeSpan.FromDays(7), new ResendInvitation(emailAddress));
    }

    public async Task Handle(UserSuccessfullyRegistered message)
    {
        MarkAsComplete();
    }

    public async Task Handle(ResendInvitation message)
    {
        var emailAddress = Data.EmailAddress;

        // re-send invitation
        await _invitationService.ResendInvite(emailAddress);

        // ensure we learn that the user never registered
        await _bus.DeferLocal(TimeSpan.FromDays(7), new AbortInvitation(emailAddress));
    }

    public async Task Handle(AbortInvitation message)
    {
        MarkAsComplete();
    }
}

The saga data is simple – it looks like this:

public class InviteNewUserSagaData : SagaData
{
    public string EmailAddress { get; set; }
}

The saga is initiated by the InviteNewUserByEmail, as indicated by IAmInitiatedBy<InviteNewUserByEmail> – the other message types, ResendInvitation, UserSuccessfullyRegistered, and AbortInvitation are simply handled.

They're all correlated by the value of the EmailAddress field contained in the message, which gets correlated with the EmailAddress field of the saga data, as indicated by this portion of the code:

protected override void CorrelateMessages(ICorrelationConfig<InviteNewUserSagaData> config)
{
    config.Correlate<InviteNewUserByEmail>(m => m.EmailAddress, d => d.EmailAddress);
    config.Correlate<UserSuccessfullyRegistered>(m => m.EmailAddress, d => d.EmailAddress);
    config.Correlate<ResendInvitation>(m => m.EmailAddress, d => d.EmailAddress);
    config.Correlate<AbortInvitation>(m => m.EmailAddress, d => d.EmailAddress);
}

Description of the logic

For each message handled, the logic is as follows:

  • InviteNewUserByEmail: We get the necessary data from the message and its headers, then we store the email address in the saga data, and then we send a ResendInvitation to our future selves.
  • UserSuccessfullyRegistered: We simply mark the saga as completed, which means it'll get deleted.
  • ResendInvitation: When we receive this, a week has passed by, and the saga is still alive – it means that the user has NOT completed the registration at this point, so we resend the invitation, and then we send an AbortInvitation command to our future selves.
  • AbortInvidation: If the saga is STILL alive at this point, the email recipient has not completed registration within 14 days, so we just stop trying at this point.

A real user registration flow could probably contain more sophisticated handling of deviations from the sunshine scenario, but this will do for this example. 😃

If you install the Rebus.TestHelpers NuGet package, you'll get access to a SagaFixture, which is a great way to test your sagas.

SagaFixture actually spins up a full Rebus instance, using in-mem implementations of transport, subscriptions, sagas, timeouts, etc., so it's fully capable and very realistic. This is great, because then there's very little difference between exercising your code with SagaFixture and how it's going to run when activated in an actual application.

Check this out! 😉

How to test the logic then?

Let's test that InviteNewUserByEmail actually starts a new saga instance:

[Test]
public void CanInitiateRegistration()
{
    // arrange
    var bus = new FakeBus();
    var invitationService = A.Fake<IInvitationService>();

    using (var fixture = SagaFixture.For(() => new InviteNewUserSaga(bus, invitationService, MessageContext.Current)))
    {
        // act
        fixture.Deliver(new InviteNewUserByEmail("hello@rebus.fm"));

        // assert
        var data = fixture.Data
            .OfType<InviteNewUserSagaData>()
            .FirstOrDefault(d => d.EmailAddress == "hello@rebus.fm");

        Assert.That(data, Is.Not.Null);
        Assert.That(data.EmailAddress, Is.EqualTo("hello@rebus.fm"));
    }
}

As you can see, we can easily send a message to our saga and verify that a new saga data instance was actually created. Let's also verify that we send the ResendInvitation command to future selves (btw. FakeBus, in case you haven't seen it, is a helper for unit testing interactions with the bus – it's described in more detail here: How to test code that uses the bus to do things):

[Test]
public void SendsResendCommandIntoTheFuture()
{
    // arrange
    var bus = new FakeBus();
    var invitationService = A.Fake<IInvitationService>();

    using (var fixture = SagaFixture.For(() => new InviteNewUserSaga(bus, invitationService, MessageContext.Current)))
    {
        // act
        fixture.Deliver(new InviteNewUserByEmail("hello@rebus.fm"));

        var command = bus.Events
            .OfType<MessageDeferredToSelf<ResendInvitation>>()
            .Single()
            .CommandMessage;

        // assert
        Assert.That(command.EmailAddress, Is.EqualTo("hello@rebus.fm"));
    }
}

That was InviteNewUserByEmail – let's go on and test that our saga ends, when we receive the UserSuccessfullyRegistered event for our email address. For this test, we're assuming that UserSuccessfullyRegistered is an event that gets published elsewhere by someone else, and then we subscribe to it.

In this test, we plant a piece of saga data as part of the setup (the "arrange" phase), and then we simply want to verify that the saga data was marked as complete.

That could be done like this:

[Test]
public void RegistrationProcessIsDoneWhenUserIsSuccessfullyRegistered()
{
    // arrange
    var bus = new FakeBus();
    var invitationService = A.Fake<IInvitationService>();

    using (var fixture = SagaFixture.For(() => new InviteNewUserSaga(bus, invitationService, MessageContext.Current)))
    {
        fixture.Add(new InviteNewUserSagaData { EmailAddress = "hello@rebus.fm" });

        // act
        fixture.Deliver(new UserSuccessfullyRegistered("hello@rebus.fm"));

        // assert
        var data = fixture.Data.OfType<InviteNewUserSagaData>()
            .Where(d => d.EmailAddress == "hello@rebus.fm")
            .ToList();

        Assert.That(data.Count, Is.EqualTo(0));
    }
}

So, as you can see, it's fairly easy to subject your saga to various messages, check the state of your saga data after the fact, as well as create saga data as part of the setup phase of your tests.

SagaFixture has the following members, which you can use in your test:

  • Data: IEnumerable<ISagaData> that provides access to all instances currently in the saga store
  • HandlerExceptions: IEnumerable<HandlerException> that gets exceptions and additional data caught while handling messages
  • LogEvents: IEnumerable<LogEvent> that gets all logged events, allowing you to e.g. inspect that warnings were logged, etc.
  • Deliver: Delivers a message to the saga
  • DeliverFailed: Delivers a message to the saga as an IFailed<YourMessage> as if the message is a 2nd level retry
  • PrepareConflict: Puts a special mark on saga instances, causing their next update to cause a conflict (i.e. a ConcurrencyException – provides a way for you to test your implementation of ResolveConflict, if you've implemented that)
  • Correlated: Event raised when a message gets successfully correlated with a saga instance
  • CouldNotCorrelate: Event raised when a message could NOT be correlated and was thus ignored
  • Created / Updated / Deleted: Raised when these things happened to a saga data instance
  • Disposed: Raised when the saga fixture is disposed
Clone this wiki locally