Integration Testing for Azure Functions with .NET Core Isolated Workers
Written on
In earlier articles, I have discussed the principles of clean architecture for Azure Functions. In our last discussion, we utilized .NET 8 alongside Azure Functions v4 for isolated workers. This article will focus specifically on integration testing within Azure Functions.
While working on expanding the integration tests for Azure Functions, I encountered several challenges. A notable issue is that isolated workers may struggle with initializing dependency injection and interacting with the WebApplication factory, particularly when modifying the Program.cs file of the Azure Function. This often leads to GRPC port errors. Additionally, when using Entity Framework, transitioning my database and determining whether to work with a production or local database complicates the selection of a testing database strategy. You can either test against a production database or utilize a testing database (like an in-memory provider, SQL Lite, or a mock repository).
We won't delve into the comparison between testing against a production database and using test doubles, as Microsoft has comprehensively addressed this topic in the resources linked below. Nevertheless, the general consensus suggests that employing an actual database enhances test coverage. In contrast, while in-memory or SQL Lite databases, as well as test doubles, can simplify testing, they often fail to account for all aspects of a real database, such as transaction management and case-insensitive string comparisons. On the other hand, real databases can present challenges with concurrent tasks and tests that may alter the database scope and records.
Having summarized the challenges, let's take a look at the tools we've utilized for the integration testing of Azure Functions:
- The selected testing framework is XUnit.
- The .NET version in use is 8.
- The function worker runtime is dotnet-isolated.
- Test containers are employed for straightforward database initialization.
What is the main advantage of using test containers? In modern application development, we typically integrate various technologies such as databases, caching, and messaging systems. During testing, it's essential to facilitate the usage of these technologies efficiently. Test containers offer lightweight APIs through Docker containers, allowing us to test against real resources rather than relying solely on in-memory or other test doubles.
Another advantage of test containers is that they automatically clean up resources after your integration tests finish, so there's no need to worry about managing external resources.
Now, let’s explore the integration testing process for Azure Functions.
Begin by writing the function you want to test. The function will have several attributes that identify it, but the main focus should be on the constructor that accepts IMediatr and the Run method which takes FunctionContext.
public class InsertJobPostFunction : Abstraction
{
[Function("InsertJobPost")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Function, "post")] HttpRequestData req,
FunctionContext executionContext) =>
await PostResponse(req, new InsertJobPostRequest(req.Convert<InsertJobPostDto>()));
Next, create a command handler that incorporates the business logic invoked by Mediatr.
using MediatR;
namespace Application.UseCases.JobPost;
using Domain.Aggregation.JobPost;
using Services;
using Dto;
public record InsertJobPostRequest(InsertJobPostDto Dto) : IRequest<string>;
public class InsertJobPostHandler : Handler, IRequestHandler<InsertJobPostRequest, string>
{
public async Task<string> Handle(InsertJobPostRequest request, CancellationToken cancellationToken)
{
try
{
var jobPost = request.Dto.Cast<JobPost>();
await UnitOfWork.JobPostService().Insert(jobPost!);
await UnitOfWork.CommitAsync(cancellationToken);
return "success";
}
catch (Exception e)
{
Console.WriteLine(e);
throw;
}
}
}
After setting up the function and command handler, let's examine the test class for the function. The code will guide you on creating an input type and the required functions. Then, we will generate a "FakeFunctionContext" which the Run function will utilize. The FakeFunctionContext will require an "HttpRequestData", instantiated as FakeHttpRequestData.
using System.Net;
using System.Text;
using Application.UseCases.JobPost.Dto;
using Function.Functions;
using MediatR;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.DependencyInjection;
using Newtonsoft.Json;
namespace Test.IntegrationTest.JobPost;
[Collection("Factory collection")]
public class InsertJobPostFunctionTest2
{
private readonly InsertJobPostFunction _sut;
public InsertJobPostFunctionTest2(FunctionApplicationStartup startup)
{
_sut = new InsertJobPostFunction(startup.host.Services.GetRequiredService<IMediator>());
}
[Fact]
public async Task Insert_JobPost_Should_Be_Added()
{
// Arrange
var dto = new InsertJobPostDto
{
Title = "Software Engineer",
Description = "We are looking for a software engineer"
};
var context = new FakeFunctionContext();
var req = new FakeHttpRequestData(context,
new MemoryStream(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(dto))));
// Act
var result = await _sut.Run(req, context);
result.Body.Position = 0;
var reader = new StreamReader(result.Body);
var text = await reader.ReadToEndAsync();
// Assert
Assert.Equal(HttpStatusCode.OK, result.StatusCode);
Assert.Equal("success", text);
}
}
In this step, we create a class that inherits from the "FunctionContext" abstract class to help us initiate the "FakeFunctionContext" and supply parameters for the run function.
public class FakeFunctionContext : FunctionContext
{
public override string InvocationId { get; }
public override string FunctionId { get; }
public override TraceContext TraceContext { get; }
public override BindingContext BindingContext { get; }
public override RetryContext RetryContext { get; }
public override IServiceProvider InstanceServices { get; set; }
public override FunctionDefinition FunctionDefinition { get; }
public override IDictionary<object, object> Items { get; set; }
public override IInvocationFeatures Features { get; }
}
A crucial aspect of the test environment is creating a Function startup class. Since we are using the dotnet isolated worker runtime for Azure Functions, we must utilize Program.cs and initialize our function with a FunctionApplicationStartup class.
In this class, we utilize MsSqlBuilder via the test container. This allows us to avoid concerns over database resources since it provides a containerized database that cleans up after testing.
using Infrastructure;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Hosting;
using Testcontainers.MsSql;
namespace Test.IntegrationTest;
public class FunctionApplicationStartup : IAsyncLifetime
{
public readonly IHost host;
public MsSqlContainer container = new MsSqlBuilder()
.WithName("TestDb")
.WithPassword("Password1234!")
.Build();
private static readonly object _lock = new();
private static bool _databaseInitialized;
public FunctionApplicationStartup()
{
InitializeAsync().Wait();
// Set default database to TestDb
var connectionString = container.GetConnectionString().Replace("master", "TestDb");
var startup = new Startup(connectionString);
host = new HostBuilder().ConfigureWebJobs(startup.Configure).Build();
host.Start();
lock (_lock)
{
if (!_databaseInitialized)
{
using (var context = CreateContext())
{
context.Database.EnsureDeleted();
context.Database.EnsureCreated();
context.SaveChanges();
}
_databaseInitialized = true;
}
}
}
public Context CreateContext() => new(
new DbContextOptionsBuilder<Context>()
.UseSqlServer(container.GetConnectionString().Replace("master", "TestDb"))
.Options);
public Task InitializeAsync() => container.StartAsync();
public Task DisposeAsync() => container.DisposeAsync().AsTask();
}
To share the test context among our test classes, we use ICollectionFixture.
using Test.IntegrationTest;
namespace Test;
[CollectionDefinition("Factory collection")]
public record FactoryCollection : ICollectionFixture<FunctionApplicationStartup>;
You will notice that we instantiated a Startup class in the "FunctionApplicationStartup" class. This Startup class implements "FunctionsStartup" and assists with registering dependency injection and building our host for testing purposes.
using Application;
using Infrastructure;
using MediatR;
using Microsoft.Azure.Functions.Extensions.DependencyInjection;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace Test;
public class Startup(string connString) : FunctionsStartup
{
public override void Configure(IFunctionsHostBuilder builder)
{
builder.Services.AddDbContext<Context>(options =>
{
options.UseSqlServer(connString)
.UseSnakeCaseNamingConvention();
});
builder.Services.RegisterApplication();
builder.Services.RegisterInfrastructure();
builder.Services.AddHttpContextAccessor();
}
}
When you execute a specific test, you will observe that it passes successfully.
Summary Through various attempts, I have successfully implemented integration testing for Azure Functions using the .NET Core isolated worker. While there’s always room for code enhancement, this article concentrated on the core concepts of integration testing with a production database via test containers and Entity Framework utilizing XUnit.
I trust this information will be beneficial to you as well.
Happy coding!
Resources:
Choosing a testing strategy - EF Core
This resource explores different approaches for testing applications that utilize Entity Framework Core.
learn.microsoft.com
What is Testcontainers, and why should you use it?
This guide introduces Testcontainers and explains the challenges it addresses.
testcontainers.com
Shared Context between Tests
Documentation for the xUnit.net unit testing framework.
xunit.net