-
-
Notifications
You must be signed in to change notification settings - Fork 364
How to unit test a saga handler
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.
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. 😊
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);
}
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 aResendInvitation
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 anAbortInvitation
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! 😉
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("[email protected]"));
// assert
var data = fixture.Data
.OfType<InviteNewUserSagaData>()
.FirstOrDefault(d => d.EmailAddress == "[email protected]");
Assert.That(data, Is.Not.Null);
Assert.That(data.EmailAddress, Is.EqualTo("[email protected]"));
}
}
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("[email protected]"));
var command = bus.Events
.OfType<MessageDeferredToSelf<ResendInvitation>>()
.Single()
.CommandMessage;
// assert
Assert.That(command.EmailAddress, Is.EqualTo("[email protected]"));
}
}
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 = "[email protected]" });
// act
fixture.Deliver(new UserSuccessfullyRegistered("[email protected]"));
// assert
var data = fixture.Data.OfType<InviteNewUserSagaData>()
.Where(d => d.EmailAddress == "[email protected]")
.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 anIFailed<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. aConcurrencyException
– provides a way for you to test your implementation ofResolveConflict
, 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
Basic stuff
- Home
- Introduction
- Getting started
- Different bus modes
- How does rebus compare to other .net service buses?
- 3rd party extensions
- Rebus versions
Configuration
Scenarios
Areas
- Logging
- Routing
- Serialization
- Pub sub messaging
- Process managers
- Message context
- Data bus
- Correlation ids
- Container adapters
- Automatic retries and error handling
- Message dispatch
- Thread safety and instance policies
- Timeouts
- Timeout manager
- Transactions
- Delivery guarantees
- Idempotence
- Unit of work
- Workers and parallelism
- Wire level format of messages
- Handler pipeline
- Polymorphic message dispatch
- Persistence ignorance
- Saga parallelism
- Transport message forwarding
- Testing
- Outbox
- Startup/shutdown
Transports (not a full list)
Customization
- Extensibility
- Auto flowing user context extensibility example
- Back off strategy
- Message compression and encryption
- Fail fast on certain exception types
Pipelines
- Log message pipelines
- Incoming messages pipeline
- Incoming step context
- Outgoing messages pipeline
- Outgoing step context
Prominent application services