Skip to content

Testing Service Bus triggers for Azure Functions

Published:
Testing Service Bus triggers for Azure Functions

Photo by Diana Light on Unsplash

Last year I wrote a long post about my paradigm shift in regard to testing. More specifically I wrote about on the harmful effects of too many mocks and isolated tests. It is much more useful to have broader tests that cover a larger part of the application or component using integration tests (or service behavior tests, the term I coined in that same article).
For ASP.NET applications this a fairly straight-forward and well documented path. We just write integration tests using WebApplicationFactory and a test framework such as xUnit. WebApplicationFactory takes care of all the plumbing to host an in-memory version of our application and gives us a client to talk to it.

Recently I worked on a green-field project that had to use Azure Functions that are triggered by messages on a Service Bus queue and I wanted to write integration tests for these functions as well. However, I found that there is no official documentation on integration testing Azure Functions and many of the blog posts on this topic are about Azure Functions with the in-process model (which will be retired in 2026) and not the isolated worker model. So, let’s see what it takes to build this.

Function to test

The function app we’ll be testing has the following Program.cs:

using Microsoft.Extensions.Hosting;

var host = new HostBuilder()
    .ConfigureFunctionsWorkerDefaults()
    .ConfigureServices(services => services.AddServices())
    .Build();

host.Run();

Note that the services are configured via an extension method. This is important because it enables re-use of the same method later in our integration test.

The extension method itself is not relevant for this post so I’ll just link to it: ServiceRegistrations.cs.

The function we’re going to test is simple: it triggers based on a message on a service bus queue, deserializes the message body and performs some very simple validation. When this is successful it uses a service bus output binding to put a message on a different queue:

using System.Text.Json;
using Azure.Messaging.ServiceBus;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Extensions.Logging;

namespace ExampleFunctionApp.Functions;

public class ServiceBusTriggerFunction
{
    private readonly ILogger<ServiceBusTriggerFunction> _logger;
    private readonly IExampleValidator _exampleValidator;

    public ServiceBusTriggerFunction(
        ILogger<ServiceBusTriggerFunction> logger,
        IExampleValidator exampleValidator)
    {
        _logger = logger;
        _exampleValidator = exampleValidator;
    }

    [Function(nameof(ServiceBusTriggerFunction))]
    [ServiceBusOutput("my-out-queue", Connection = "ServiceBusConnectionString")]
    public async Task<string?> Run(
        [ServiceBusTrigger(
            "my-in-queue",
            Connection = "ServiceBusConnectionString",
            AutoCompleteMessages = true)]
        ServiceBusReceivedMessage message,
        ServiceBusMessageActions messageActions)
    {
        var parsedMessage = message.Body.ToObjectFromJson<ExampleInMessage>(
            new JsonSerializerOptions { PropertyNameCaseInsensitive = true }
        );

        if (!_exampleValidator.Validate(parsedMessage))
        {
            await messageActions.DeadLetterMessageAsync(
                message,
                deadLetterReason: "Validation failed: invalid message body");
            return null;
        }

        var response = new ExampleOutMessage(parsedMessage.ItemId);
        return JsonSerializer.Serialize(response);
    }
}

Test harness

Just like WebApplicationFactory we’ll need to host our Azure Function, add services and configure test services.

By using an xUnit fixture we can share the same host between tests to increase test performance:

using Microsoft.Extensions.Hosting;

namespace ExampleFunctionApp.Integration.Tests;

public class FunctionAppFixture : IAsyncLifetime
{
    private readonly IHost _host;

    public IServiceProvider ServiceProvider => _host.Services;

    public FunctionAppFixture()
    {
        _host = new HostBuilder()
            .ConfigureFunctionsWorkerDefaults()
            .ConfigureTestHost()
            .ConfigureServices(services => services
                .AddServices()
                .AddTestServices())
            .Build();
    }

    public async Task InitializeAsync() => await _host.StartAsync();

    public Task DisposeAsync()
    {
        _host?.Dispose();
        return Task.CompletedTask;
    }
}

We expose the service provider so we can resolve registered services in our test to get an entry point into the application.

The host is almost identical to the Azure Functions host we configure in Program.cs: we call ConfigureFunctionsWorkerDefaults, we register the services. The only difference is 2 test extension methods.

These host extension methods look like this:

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

namespace ExampleFunctionApp.Integration.Tests;

public static class HostBuilderExtensions
{
    public static IHostBuilder ConfigureTestHost(this IHostBuilder hostBuilder)
    {
        hostBuilder.ConfigureLogging(logging =>
        {
            logging.ClearProviders();
        });

        return hostBuilder;
    }

    public static IServiceCollection AddTestServices(this IServiceCollection services)
    {
        // Remove internal class WorkerHostedService to prevent unconfigured gRPC exception:
        // "gRPC channel URI 'http://:63425' could not be parsed."

        // Removing this hosted service does mean triggers such as Storage Queue Triggers stop working:
        // See https://github.com/Azure/azure-functions-dotnet-worker/issues/968
        var hostedService = services.First(
            descriptor => descriptor.ImplementationType?.Name == "WorkerHostedService");
        services.Remove(hostedService);

        return services;
    }
}

At the moment ConfigureTestHost just clears the logging providers to avoid unnecessary logging in our tests, but we can use this method to customize our host, for example by injecting configuration.

The other extension method AddTestServices is more interesting. It removes a hosted service called WorkerHostedService which listens to GRPC requests from the Function host. We have no Functions host in our integration test so it would fail if it’s not removed.

Since the worker is internal we have to remove it by name. Alternatively, you can remove all IHostedService implementations but this would also remove any hosted services we might register ourselves.

Integration test

We’re now ready to write the integration test itself:

using Azure.Messaging.ServiceBus;
using ExampleFunctionApp.Functions;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;

namespace ExampleFunctionApp.Integration.Tests.Functions;

[Collection("FunctionApp")]
public class ServiceBusTriggerFunctionTests
{
    private readonly FunctionAppFixture _functionAppFixture;

    public ServiceBusTriggerFunctionTests(FunctionAppFixture functionAppFixture)
    {
        _functionAppFixture = functionAppFixture;
    }

    [Fact]
    public async Task ReceiveEvent_ValidMessageBody_RepondsWithOutMessage()
    {
        // Arrange
        var sut = ActivatorUtilities.CreateInstance<ServiceBusTriggerFunction>(
            _functionAppFixture.ServiceProvider);

        var messageBody = """
            {
                "itemId": 1000,
                "category": "Books"
            }
            """;

        var message = ServiceBusModelFactory.ServiceBusReceivedMessage(
            body: new BinaryData(messageBody));

        var messageActions = Substitute.For<ServiceBusMessageActions>();

        // Act
        var result = await sut.Run(message, messageActions);

        // Assert
        Assert.NotNull(result);
        Assert.Equal(
            """
            {"ItemId":1000}
            """,
            result);
    }
}

Some noteworthy elements are the activation of our function, with all dependencies injected. We use ActivatorUtilities for this and pass in the service provider from our test harness.

Also note that we test the contract of our service bus messages by specifying the messages as json (using a raw string literal), instead of serializing them. The benefit of this is that whenever we make a breaking change to the contract, we also break the test, which should alert us for any accidental changes.
A mocked input message is then created using the ServiceBusModelFactory.ServiceBusReceivedMessage method from the Azure.Messaging.ServiceBus namespace.

We only use one other mock: a mocked instance of ServiceBusMessageActions. We could use it to check if messages have been manually completed or moved to the dead-letter queue:

await messageActions.Received().DeadLetterMessageAsync(
    message,
    deadLetterReason: Arg.Is<string>(r => r.Contains("Validation failed")));

The function itself is triggered manually by calling the Run method and passing in the mocks.

Source

The full source is available in this repo: azure-functions-integration-testing.