Supercharged Sagas - Unit Testing Strategies

September 27, 2024#Software Development
Series: NServiceBus
Article
Author image.

Kyle McMaster, Senior Consultant

This post is part of a series on NServiceBus. In the past couple articles, we created a saga that tries to verify a contributor’s phone number and marks them accordingly based on their action (or lack thereof). In this post, we’ll work through unit testing our saga and demonstrate how to check its behavior. All the code for this series can be found in the NServiceBusTutorial repository on GitHub.

Testing Sagas ✅

Testing sagas can be a bit tricky. On one hand, we may simply need to verify a specific behavior similar to how we would test NServiceBus message handlers. On the other hand, Sagas are long-running processes that can span multiple messages or timeouts. When testing sagas, we likely need to verify that the saga sends or publishes messages in response to specific events. We may also need to verify that the saga updates its state correctly in response to messages or timeouts. We can assert all of this behavior is working as expected using traditional unit testing approaches and tools from the NServiceBus.Testing NuGet Package. Let’s take a look at how we can test our saga.

Testing Saga Message Handlers 📨

When testing individual saga message handlers, we can use the TestableMessageHandlerContext from the NServiceBus.Testing package to peek into the handlers behavior. This allows us to verify that the saga sends or publishes messages in response to specific events. Referencing our sample saga, in the Handle method for StartContributorVerificationCommand we send a VerifyContributorPhoneNumberCommand. We can verify this behavior in a unit test like so:

[Fact]
public async Task ShouldSendVerifyContributorCommand()
{
  var message = new StartContributorVerificationCommand();
  var saga = new ContributorVerificationSaga
  {
    Data = new()
  };
  var context = new TestableMessageHandlerContext();

  await saga.Handle(message, context);

  var sentMessage = context.FindSentMessage<VerifyContributorCommand>();
  Assert.NotNull(sentMessage);
}

In the Arrange step of this test, we initialize a StartContributorVerificationCommand, ContributorVerificationSaga, and TestableMessageHandlerContext. In the Act step, the Handle method is invoked on the saga. In the Assert step, we’ll inspect the results to check whether a VerifyContributorCommand was sent. To make our lives a little easier, we can use the FindSentMessage method on TestableMessageHandlerContext to search sent messages for a message of a specified type. Finally, we assert that the saga sent a VerifyContributorCommand message. This test verifies that the saga sends the correct message in response to a StartContributorVerificationCommand.

Saga Scenario Tests 🧪

When testing sagas, we typically write scenario tests that simulate the behavior of the saga in response to a sequence of messages. These tests help us verify that the saga behaves correctly when it receives messages in a specific order. The NServiceBus.Testing package provides a TestableSaga class that we can use to write scenario tests for our saga. The TestableSaga class allows us to simulate the behavior of the saga, simulate time passing (without using Thread.Sleep 😴), and verify the saga’s state and behavior. Let’s write a scenario test for our saga that verifies the saga’s behavior when it receives a StartContributorVerificationCommand followed by a ContributorVerifiedEvent.

[Fact]
public async Task ShouldInitializeSagaAndMarkAsCompleted()
{
  int expectedContributorId = 4680;
  var startCommand = new StartContributorVerificationCommand
  {
    ContributorId = expectedContributorId
  };
  var verifiedEvent = new ContributorVerifiedEvent
  {
    ContributorId = expectedContributorId
  };
  var saga = new TestableSaga<ContributorVerificationSaga, ContributorVerificationSagaData>();
  var context = new TestableMessageHandlerContext();

  var startResult = await saga.Handle(startCommand, context);
  var completeResult = await saga.Handle(verifiedEvent, context);

  Assert.Equal(expectedContributorId, startResult.SagaDataSnapshot.ContributorId);
  Assert.True(completeResult.Completed);
}

In this test, we arrange a StartContributorVerificationCommand, ContributorVerifiedEvent, TestableSaga, and TestableMessageHandlerContext. Notice how the TestableSaga class has generic parameters for the saga class and its Data class. In our act step, we invoke the Handle method on the saga with the StartContributorVerificationCommand and ContributorVerifiedEvent. With the TestableSaga these Handle methods return a snapshot of the saga after the method completes. This allows us to capture the snapshots and write assertions against them. In our Assertion step, we use the SagaDataSnapshot property on the TestableSagaResult to inspect the saga’s state after the StartContributorVerificationCommand is handled. We assert that the ContributorId in the saga’s state matches the expected value. Next, we also assert that the saga is marked as completed after the ContributorVerifiedEvent is handled. This test verifies that the saga initializes correctly and marks itself as completed when it receives a StartContributorVerificationCommand followed by a ContributorVerifiedEvent.

We can also use the TestableSaga to simulate time passing in the saga. For example, we can write a test that verifies the saga’s behavior when it receives a StartContributorVerificationCommand and never receives a ContributorVerifiedEvent. We can use the TestableSaga to simulate time passing and verify that the saga sends a MarkContributorAsUnverifiedCommand after a timeout. In our test, we’ll advance time over the 24 hour limit and verify the ContributorVerificationSagaTimeout handler produces its side effects as expected. Here’s an example of how we can write a test for this scenario:

[Fact]
public async Task ShouldTimeoutWhenTimeAdvancesOverLimit()
{
  int expectedContributorId = 4680;
  var startCommand = new StartContributorVerificationCommand
  {
    ContributorId = expectedContributorId
  };
  var saga = new TestableSaga<ContributorVerificationSaga, ContributorVerificationSagaData>();
  var context = new TestableMessageHandlerContext();

  var startResult = await saga.Handle(startCommand, context);
  var timeouts = await saga.AdvanceTime(TimeSpan.FromHours(25));

  Assert.Equal(expectedContributorId, startResult.SagaDataSnapshot.ContributorId);
  var timeoutResult = timeouts.Single();
  Assert.NotNull(timeoutResult.FindSentMessage<NotVerifyContributorCommand>());
  Assert.True(timeoutResult.Completed);
}

Our arrangement step is similar to the previous test. We initialize a StartContributorVerificationCommand, TestableSaga, and TestableMessageHandlerContext. In the Act step, we invoke the Handle method on the saga with the StartContributorVerificationCommand. We then advance time by 25 hours using the AdvanceTime method on the TestableSaga. This method returns a list of TestableSagaResult objects that represent the saga’s state after the time has advanced. In the Assert step, we inspect the results to verify that the saga sends a NotVerifyContributorCommand message after the timeout. We also assert that the saga is marked as completed after the timeout.

Conclusion 🚀

In this post, we learned a few different strategies for writing unit tests for our saga. We covered how to test individual saga message handlers and how to write scenario tests to verify the behavior of the saga in response to a sequence of messages. We used the TestableMessageHandlerContext and TestableSaga from the NServiceBus.Testing package to peek into the saga’s behavior and verify that it sends or publishes messages in response to specific events. We also demonstrated how to verify that the saga updates its state correctly in response to messages. These tests can be invaluable in ensuring that our saga behaves as expected and covering complex flows and durations of time. Stay tuned for more helpful tips on using NServiceBus! 👋

Resources 📚


Copyright © 2024 NimblePros - All Rights Reserved