Introduction
Wolverine is a great messaging library. Personally, I have have found it to be a better development experience compared to the other major messaging libraries in the .NET space.
I've had success validating message-based services by creating local integration tests using Testcontainers, where we can quickly verify the workflows and asynchronous interactions inside our service without requiring a full-blown deployment or relying upon setting up complex external dependencies during development.
Wolverine has rich support for Azure Service Bus, and Microsoft has released the Azure Service Bus Emulator which, as we will see, combined with Testcontainers makes configuring and running these types of tests relatively trivial. We'll also be using XUnit as our testing framework.
Okay. Let's go!
Wolverine Configuration
Let's start with building a very simple .NET 8 Wolverine application that subscribes to an Azure Service Bus topic.
public class Program
{
public static async Task Main(string[] args)
{
var host = CreateApp(args);
await host.RunAsync();
}
public static WebApplication CreateApp(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
builder.Configuration.AddEnvironmentVariables();
builder.UseWolverine(opts =>
{
// This will be set as an environment variable in our test host
var connectionString = builder.Configuration.GetValue<string>("AzureServiceBusConnectionString")
var azureServiceBus = opts.UseAzureServiceBus(connectionString);
azureServiceBus
.ListenToAzureServiceBusSubscription("my-subscription")
.FromTopic("my-topic");
// NOTE: If you're using any of the following options, makes ure they are *disabled* when running integration tests.
// Provisioning emulator resources requires a configuration file instead of using the ASB management API, therefore enabling these options will result in errors during testing.
// opts.Services.AddResourceSetupOnStartup(); // Should not be called for integration tests
// azureServiceBus.SystemQueuesAreEnabled(false); // Should be called with 'false' for integration tests
// azureServiceBus.EnableWolverineControlQueues(); // Should not be called for integration tests
// azureServiceBus.AutoProvision(); // Should not be called for integration tests
});
return builder.Build();
}
}
Message Handler
Next, let's implement a very simple message handler
using Wolverine;
public class MyMessageHandler
{
public async Task Handle(MyMessage message, IMessageContext context)
{
// Do some work
}
}
Test Host
We'll use the WebApplicationFactory<T>
, giving us the ability override our app's Azure Service Bus connection string. More details on this below.
public class TestAppFactory : WebApplicationFactory<Program>
{
public string ServiceBusConnectionString { get; set; }
protected override IHost CreateHost(IHostBuilder builder)
{
builder
.ConfigureHostConfiguration(cfg =>
{
Environment.SetEnvironmentVariable("AzureServiceBusConnectionString", ServiceBusConnectionString);
});
return base.CreateHost(builder);
}
}
Azure Service Bus Emulator Config
One of the main differences between using the Azure Service Bus Emulator and an actual instance running in Azure is how resources (queues, topics, subscriptions, etc.) are defined.
Using the emulator, you must define all resources in a specific configuration structure. This configuration can be passed in as a raw string to the Azure Service Bus test container, or it can be stored in a file alongside your test code.
We'll store it in a file with this example. And if you ensure this file is included in your build output, loading it into the emulator at runtime is pretty straight forward, as we will see below.
Here is an example configuration that will be stored in a file called azure-servicebus-config.json
.
NOTE: The
sbemulatorns
namespace name is important, do not change it.
{
"UserConfig": {
"Namespaces": [
{
"Name": "sbemulatorns",
"Queues": [],
"Topics": [
{
"Name": "my-topic",
"Subscriptions": [
{
"Name": "my-subscription"
}
]
}
]
}
],
"Logging": {
"Type": "File"
}
}
}
Test Fixture
You'll very likely end up creating a dedicated test fixture if you write more than a single integration test.
This allows us to reuse the Azure Service Bus container for multiple test cases, by way of using the XUnit CollectionDefinition
attribute.
[CollectionDefinition(nameof(TestFixture), DisableParallelization = true)]
public class TestCollection : ICollectionFixture<TestFixture> { }
public class TestFixture
{
public ServiceBusContainer ServiceBusContainer { get; protected set; } = null!;
public TestAppFactory AppFactory { get; protected set; } = null!;
public TestFixture()
{
// Some of this might be better handled in a test fixture using XUnit's IAsyncLifetime
var configPath = Path.Combine(AppContext.BaseDirectory, "azure-servicebus-config.json");
ServiceBusContainer = new ServiceBusBuilder()
.WithImage("mcr.microsoft.com/azure-messaging/servicebus-emulator:latest")
.WithAcceptLicenseAgreement(true)
.WithName("my-azure-service-bus-container")
.WithConfig(configPath)
.WithPortBinding(5672, false)
.Build();
ServiceBusContainer.StartAsync().Wait();
AppFactory = new TestAppFactory
{
ServiceBusConnectionString = ServiceBusContainer.GetConnectionString()
};
}
}
The tests
And finally! The moment we've all been waiting for. Wolverine has a pretty rich and easy-to-use API for this type of testing.
[Collection(nameof(TestFixture))]
public class MyTests
{
private readonly TestFixture _fixture;
private readonly TestAppFactory _app;
public MyTests(TestFixture fixture)
{
_fixture = fixture;
_app = _fixture.AppFactory;
}
[Fact]
public async Task Handles_my_message()
{
// Arrange
var message = new MyMessage();
// Act
// Publish the message and wait for it to be processed
await _app.Host
// Tell Wolverine to start tracking message handling so we can evaluate it
.TrackActivity()
// Set a timeout period to whatever you think is reasonable to wait for conditions defined below
.Timeout(5.Seconds())
// Tell Wolverine to wait for our message to be received and handled
.WaitForMessageToBeReceivedAt<MyMessage>(_app.Host)
// Finally, actually publish the message
.PublishMessageAndWaitAsync(message);
// Assert
// Some other verification logic here
}
}
Conclusion
This may have seemed like a lot of setup to get from point A to B. But a lot of it is just that, initial configuration. And there may be some smarter ways to configure some of these things, but the beauty is that the work is largely a one-time effort. Adding tests at this point on should be fairly easy.
Happy testing!
Top comments (0)