How to create integration tests - rebus-org/Rebus GitHub Wiki
As you can probably imagine, writing integration tests is slightly more involved than writing ordinary unit tests. By nature, they contain more moving parts, so we need to handle some more problems, e.g. regarding timing and stuff.
Rebus is pretty nice though, because it has in-mem implementations of its transport and storage for subscriptions, sagas, and timeouts.
So, basically you can start a full bus running on in-mem persistence all the way through like this:
[Test]
public void MyTest()
{
using var activator = new BuiltinHandlerActivator();
using var bus = Configure.With(activator)
.Transport(t => t.UseInMemoryTransport(new InMemNetwork(), "queue-name"))
.Subscriptions(s => s.StoreInMemory())
.Sagas(s => s.StoreInMemory())
.Timeouts(t => t.StoreInMemory())
.Start();
// exercise bus here
}
If you let two bus instances share the InMemNetwork
passed to UseInMemoryTransport
, they can communicate, so that's how you can run realistic integration tests on your build server without any message broker available, and without the concurrency and state management problems that that would incur.
Similarly, if you pass an InMemorySubscriberStore
instance to StoreInMemory
under the Subscriptions
configurer, you can integration test a simple System.String
pub/sub scenario like this:
[Test]
public async Task SubscriberGetsPublishedStrings()
{
var network = new InMemNetwork();
var subscriberStore = new InMemorySubscriberStore();
using var publisherActivator = new BuiltinHandlerActivator();
using var subscriberActivator = new BuiltinHandlerActivator();
using var eventWasReceived = new ManualResetEvent(initialState: false);
using var publisher = Configure.With(publisherActivator) // rebus v7+: Configure.OneWayClient()
.Transport(t => t.UseInMemoryTransport(network, "publisher"))
.Subscriptions(s => s.StoreInMemory(subscriberStore))
.Start();
subscriberActivator.Handle<string>(async message => eventWasReceived.Set());
var subscriber = Configure.With(subscriberActivator)
.Transport(t => t.UseInMemoryTransport(network, "subscriber"))
.Subscriptions(s => s.StoreInMemory(subscriberStore))
.Start();
await subscriber.Subscribe<string>();
await publisher.Publish("HEJ MED DIG MIN VEN");
Assert.That(eventWasReceived.WaitOne(TimeSpan.FromSeconds(5)), Is.True,
"Did not receive the published event within 5 s timeout");
}
in this case using a ManualResetEvent
to block and wait for the subscriber to receive the published string, failing with an AssertionException
if it's not received within a 5 s timeout.
Another great option is to use the Hypothesist asynchronous assertion framework with a provided adapter for Rebus:
[Test]
public async Task SubscriberGetsPublishedStrings()
{
// Arrange
var hypothesis = Hypothesis.For<string>()
.Any(x => x == "HEJ MED DIG MIN VEN"); // hypothesist for async assertions
using var activator = new BuiltinHandlerActivator()
.Register(hypothesis.AsHandler); // adapater to register the hypothesis as handler
// remaining setup equals previous example, except for the manual reset event.
await hypothesis.Validate(TimeSpan.FromSeconds(5));
}
While this might seem fairly straightforward, here's a little word of warning: Writing integration tests like this can quickly become complicated, so my advice is to exercise the same amount of care and discipline as you would with your production code. But that actually holds for all of your testing code – the same patterns and anti-patterns apply there too, because why wouldn't they? 😉
One of the important distinctions to make early on in my experience, is between code that would want to share between your system and your tests, and code you want to be able to swap out with test code, like the example above.
I often end up with using configuration code that looks like this:
Configure.With(activator)
.Transport(t =>
{
if (Backdoor.Network != null)
{
t.UseInMemoryTransport(Backdoor.Network, "queue-name");
}
else
{
t.UseMsmq("queue-name");
}
})
.Subscriptions(s =>
{
if (Backdoor.SubscriberStore != null)
{
s.StoreInMemory(Backdoor.SubscriberStore);
}
else
{
s.StoreInSqlServer(connectionString, "Subscriptions", isCentralized: true);
}
})
.Start();
and then I have a Backdoor
lying around in the project that looks somewhat like this:
internal static class Backdoor
{
internal static InMemNetwork Network;
internal static InMemorySubscriberStore SubscriberStore;
public static void Reset()
{
Network = null;
SubscriberStore = null;
}
public static void EnableTestMode()
{
Network = new InMemNetwork();
SubscriberStore = new InMemorySubscriberStore();
}
}
Combined with [InternalsVisibleTo("MyTestProject")]
I can then
Backdoor.EnableTestMode();
and
Backdoor.Reset();
before and after each test in my BusIntegrationTestFixtureBase
.
This way, the configuration code I use in my integration tests is the same as the configuration code I use in my production system, save for the few lines that configure which queue and subscription storage I'm using.