Testing enterprise-scale distributed systems is a challenge. A dedicated NuGet package, NServiceBus.
, is provided with tools for unit testing endpoint handlers and sagas.
The testing package can be used with any .NET unit testing framework, such as NUnit, xUnit.net or MSTest.
Testing a handler
Testing a message handler is done using the TestableMessageHandlerContext
class provided by the NServiceBus.
package. This class implements IMessageHandlerContext
and can be passed to the handler under test. After the handler is executed, the TestableMessageHandlerContext
can be interrogated to assert that various actions (sending a message, publishing an event, etc.) occurred in the handler as expected.
Property | Description |
---|---|
SentMessages | A list of all messages sent by context. |
PublishedMessages | A list of all messages published by context. |
RepliedMessages | A list of all messages sent using context. |
TimeoutMessages | A list of all messages resulting from use of Saga. |
ForwardedMessages | A list of all forwarding destinations set by context. |
MessageHeaders | Gets the list of key/value pairs found in the header of the message |
HandlerInvocationAborted | Indicates if DoNotContinueDispatchingCurrentMessageToHandlers() was called |
Example
Given the following handler:
public class MyReplyingHandler :
IHandleMessages<MyRequest>
{
public Task Handle(MyRequest message, IMessageHandlerContext context)
{
return context.Reply(new MyResponse());
}
}
This test verifies that a Reply
occurred:
[Test]
public async Task ShouldReplyWithResponseMessage()
{
var handler = new MyReplyingHandler();
var context = new TestableMessageHandlerContext();
await handler.Handle(new MyRequest(), context);
Assert.That(context.RepliedMessages, Has.Length.EqualTo(1));
Assert.That(context.RepliedMessages[0].Message, Is.InstanceOf<MyResponse>());
}
Interface messages
When using interface messages, an instance of the message can be created by either defining a custom implementation of the message interface or by using the MessageMapper
as shown in the following snippet:
var messageMapper = new MessageMapper();
var myMessage = messageMapper.CreateInstance<IMyMessage>(message => { /* ... */ });
await handler.Handle(myMessage, context);
Testing message session operations
Use TestableMessageSession
to test message operations outside of handlers. The following properties are available:
Property | Description |
---|---|
SentMessages | A list of all messages sent by session. |
PublishedMessages | A list of all messages published by session. |
Subscriptions | A list of all message types explicitly subscribed to using session. |
Unsubscriptions | A list of all message types explicitly unsubscribed to using session. |
Example
The following code shows how to verify that a message was Sent
using IMessageSession
.
[Test]
public async Task ShouldLogCorrectly()
{
var testableSession = new TestableMessageSession();
var somethingThatUsesTheMessageSession = new SomethingThatUsesTheMessageSession(testableSession);
await somethingThatUsesTheMessageSession.DoSomething();
Assert.That(testableSession.SentMessages, Has.Length.EqualTo(1));
Assert.That(testableSession.SentMessages[0].Message, Is.InstanceOf<MyResponse>());
}
Testing a saga
Testing a saga can be accomplished in one of two ways:
- Test saga handler methods individually, similar to testing a handler. With this testing method, the test must supply some of the conditions (such as the contents of the saga data) normally provided by the NServiceBus framework. This testing method is described below.
- Saga scenario testing, in which the results of a scenario consisting of multiple message inputs can be tested with a virtual saga storage to store the saga data between messages. This also adds a virtual concept of time, allowing the test to advance time by a set period between messages and enabling observation of the results of the saga timeouts that would be fired during that time. With this testing method, the testing framework emulates much of the behavior provided by NServiceBus in production.
Testing saga handlers
Testing a saga uses the same TestableMessageHandlerContext
as testing a handler. The same properties are used to perform assertions after a saga method is invoked.
Because timeouts are technically sent messages, any timeout requested from the saga will appear in both the TimeoutMessages
and SentMessages
collections of the TestableMessageHandlerContext
.
Example
Here's an example of a saga, that processes an order and gives a 10% discount for orders above an amount of 1000:
public class DiscountPolicy :
Saga<DiscountPolicyData>,
IAmStartedByMessages<SubmitOrder>
{
public Task Handle(SubmitOrder message, IMessageHandlerContext context)
{
Data.CustomerId = message.CustomerId;
Data.TotalAmount += message.TotalAmount;
if (Data.TotalAmount >= 1000)
{
return ProcessWithDiscount(message, context);
}
return ProcessOrder(message, context);
}
Task ProcessWithDiscount(SubmitOrder message, IMessageHandlerContext context)
{
var processOrder = new ProcessOrder
{
CustomerId = Data.CustomerId,
OrderId = message.OrderId,
TotalAmount = message.TotalAmount * (decimal)0.9
};
return context.Send(processOrder);
}
Task ProcessOrder(SubmitOrder message, IMessageHandlerContext context)
{
var processOrder = new ProcessOrder
{
CustomerId = Data.CustomerId,
OrderId = message.OrderId,
TotalAmount = message.TotalAmount
};
return context.Send(processOrder);
}
protected override void ConfigureHowToFindSaga(SagaPropertyMapper<DiscountPolicyData> mapper)
{
mapper.MapSaga(saga => saga.CustomerId)
.ToMessage<SubmitOrder>(msg => msg.CustomerId);
}
}
The following unit test checks that the total amount has the discount applied:
[Test]
public async Task ShouldProcessDiscountOrder()
{
// arrange
var saga = new DiscountPolicy
{
Data = new DiscountPolicyData()
};
var context = new TestableMessageHandlerContext();
var discountOrder = new SubmitOrder
{
CustomerId = Guid.NewGuid(),
OrderId = Guid.NewGuid(),
TotalAmount = 1000
};
// act
await saga.Handle(discountOrder, context);
// assert
var processMessage = (ProcessOrder)context.SentMessages[0].Message;
Assert.Multiple(() =>
{
Assert.That(processMessage.TotalAmount, Is.EqualTo(900));
Assert.That(saga.Completed, Is.False);
});
}
Testing a behavior
Message pipeline behaviors also can be tested, but using different testable context objects. Each of the pipeline stages uses a specific interface for its context, and each context interface has a testable implementation.
To determine the testable context for a behavior context, replace the I
at the beginning of the interface name with Testable
.
For example:
- A behavior using
IIncomingLogicalMessageContext
can be tested usingTestableIncomingLogicalMessageContext
. - A behavior using
IInvokeHandlerContext
can be tested usingTestableInvokeHandlerContext
.
Refer to the pipeline stages document for a complete list of the available behavior contexts.
Each of these testable types contains properties similar to those found in TestableMessageHandlerContext
that can be used to assert that a behavior is working as designed.
Example
The following custom behavior adds a header to an outgoing message in case the message is of the type MyResponse
:
public class CustomBehavior :
Behavior<IOutgoingLogicalMessageContext>
{
public override Task Invoke(IOutgoingLogicalMessageContext context, Func<Task> next)
{
if (context.Message.MessageType == typeof(MyResponse))
{
context.Headers.Add("custom-header", "custom header value");
}
return next();
}
}
The behavior can be tested similar to a message handler or a saga by using a testable representation of the context:
[Test]
public async Task ShouldAddCustomHeaderToMyResponse()
{
var behavior = new CustomBehavior();
var context = new TestableOutgoingLogicalMessageContext
{
Message = new OutgoingLogicalMessage(typeof(MyResponse), new MyResponse())
};
await behavior.Invoke(context, () => Task.CompletedTask);
Assert.That(context.Headers["custom-header"], Is.EqualTo("custom header value"));
}
Testing logging behavior
To test that logging is performed correctly, use the TestingLoggerFactory
. The factory writes to a StringWriter
to allow unit tests to assert on log statements.
Example
Using WriteTo
or Level
set the provided parameters to the statically cached factory for the lifetime of the application domain. For isolation of logging in concurrent scenarios it is recommended to use BeginScope
that was introduced in Version 7.2.
The following code show how to verify that logging is performed by the message handler.
[SetUpFixture]
public class LoggingSetupFixture
{
static StringBuilder logStatements = new StringBuilder();
[OneTimeSetUp]
public void SetUp()
{
LogManager.Use<TestingLoggerFactory>()
.WriteTo(new StringWriter(logStatements));
}
public static string LogStatements => logStatements.ToString();
public static void Clear()
{
logStatements.Clear();
}
}
The setup fixture above sets the testing logging factory once per assembly because the factory is statically cached during the lifetime of the application domain. Subsequent test executions then clear the logged statements before every test run as shown below.
[SetUp]
public void SetUp()
{
LoggingSetupFixture.Clear();
}
[Test]
public async Task ShouldLogCorrectly()
{
var handler = new MyHandlerWithLogging();
await handler.Handle(new MyRequest(), new TestableMessageHandlerContext());
Assert.That(LoggingSetupFixture.LogStatements, Does.Contain("Some log message"));
}
Starting from Version 7.2 and above a scope can be defined for a user defined scope. Within that scope all the log statements will be collected on the text writer available within that scope.
[TestFixture]
public class LoggingTestsAmbient
{
StringBuilder logStatements;
IDisposable scope;
[SetUp]
public void SetUp()
{
logStatements = new StringBuilder();
scope = LogManager.Use<TestingLoggerFactory>()
.BeginScope(new StringWriter(logStatements));
}
[TearDown]
public void Teardown()
{
scope.Dispose();
}
[Test]
public async Task ShouldLogCorrectly()
{
var handler = new MyHandlerWithLogging();
await handler.Handle(new MyRequest(), new TestableMessageHandlerContext());
Assert.That(logStatements.ToString(), Does.Contain("Some log message"));
}
}