ASP.NET Core Integration Tests with Test Containers & Postgres
Introduction
In this post, I will demonstrate how test containers can be leveraged for proper DAL integration testing of ASP.NET Core, EF Core, and Postgres.
I will outline why you will want to use it over other common integration testing scenarios and demonstrate how it can be used together with the WebApplicationFactory
to fully run your ASP.NET Core application in memory and create a reusable fixture for your testbed.
Test Containers & DAL testing scenarios
If you are a Spring Boot/Java developer, you might have heard of a library called testcontainers. The Java library provides "... lightweight, throwaway instances of common databases, Selenium web browsers, or anything else that can run in a Docker container."
For this post, I will use its C# counterpart, called .NET Testcontainers. This NuGet package follows the idea of its Java predecessor and provides throwaway Docker instances for testing purposes.
It is built on top of the .NET Docker remote API and comes with a couple of pre-configured configurations, e.g., Postgres, Microsoft SQL Server, MySQL, Redis, RabbitMQ, and a couple more.
EF Core Testing Strategies & Test Containers
You can follow two paths when choosing an EF Core testing strategy. You either use a test double or run your test against a production database.
There are different kinds of test doubles you can choose from, which are:
- SQLite (in-memory mode)
- EF Core in-memory provider
- Mock/stub the
DBContext
andDBSet
- Introduce a repository layer between EF Core and your application code and mock or stub that layer.
These strategies have pros and cons, which I will not fully elaborate on here.
However, they all share one important drawback: The test doubles do not behave exactly like your production database. Let me name a few important points:
- The same LINQ query may return different results on different providers due to differences in case sensitivity
- Provider-specific methods cannot be tested
- Limited testing of referential integrity
- Limited raw SQL support
So, depending on the complexity of your application, these difficulties will sooner or later result in false-negative test results (functionality is broken, but the test passes) or will leave some functionality untested.
This inevitably leads to the point where you want to test against a production database. However, involving a production database also has its hurdles.
First, you must set up an RDBMS on your developer machine and a build server (and maintain it).
Second, since the database is a shared dependency on the testing code, special effort is required to manage test database instances and their states.
A shared dependency is a dependency that is shared between tests and provides means for those tests to affect each other’s outcome. - (Khorikov, 2020, p.28)
Enter test containers
Using ephemeral containers relieves you from both of the aforementioned burdens.
First, there is no need for a complex RDBMS setup, and second, your tests will always start with a known state since each test can use a fresh container.
Using test containers instead of a fully-fledged RDBMS installation makes the database an out-of-process dependency since tests no longer work with the same instance.
An out-of-process dependency is a dependency that runs outside the application’s execution process; it’s a proxy to data that is not yet in the memory. - (Khorikov, 2020, p.28)
Last, your integration tests benefit from the full feature set of the involved RDBMS.
This is what a basic test setup looks like. It uses xUnits IAsyncLifetime
interface to ensure the container is ready to serve requests before the test runs.
You'll need to add the testcontainers and the module Nuget packages to your xUnit project.
dotnet add package Testcontainers
dotnet add package Testcontainers.PostgreSql
Now that the stage is set, let's move on and introduce ASP.NET Cores WebApplicationFactory
before we put everything to work in the last chapter.
ASP.NET Core & WebApplicationFactory
The WebApplicationFactory
is a class that allows running an in-memory version of your real application by using a test web host and a test web server.
The NuGet package provides the typeMicrosoft.AspNetCore.Mvc.Testing
and is using your application's real configuration, DI service registration, and middleware pipeline.
Here is a basic integration test making use of the WebApplicationFactory
together with xUnits IClassFixture
interface, which is a marker interface. It tells xUnit to build an instance of T
before building the test class and inject the instance into the test class' constructor.
To make this test work, you'll have to add a reference from your test project to your ASP.NET Core project and add public partial class Program {}
to it.
By running the real application in memory, you can keep as much distance as possible between your tests and your application's inner workings, which eases the testing of the observable behavior. This approach reduces test fragility by focusing on the whats instead of the hows.
Custom WebApplicationFactory & dependencies
Now let's see how we can create a custom WebApplicationFactory
and how to replace dependencies.
As mentioned at the beginning of this section, the factory allows running the application in memory just as it would in production. This implies that EF Core will also connect to your productive database if you don't replace this shared dependency (DbContext
) with one pointing to your test container.
Following the simple example above, we would have to replace this dependency for each and every integration test. Instead, we will create a custom factory. This is as simple as inheriting from WebApplicationFactory
.
Next, we will remove the database context from the DI container, register a new one pointing to the test container and make sure the database schema gets properly initialized by calling context.Database.EnsureCreated()
.
This is not the most beautiful code in the world... let's move the removal- and schema creation part to an extension method.
public static class ServiceCollectionExtensions
{
public static void RemoveDbContext<T>(this IServiceCollection services) where T : DbContext
{
var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<T>));
if (descriptor != null) services.Remove(descriptor);
}
public static void EnsureDbCreated<T>(this IServiceCollection services) where T : DbContext
{
var serviceProvider = services.BuildServiceProvider();
using var scope = serviceProvider.CreateScope();
var scopedServices = scope.ServiceProvider;
var context = scopedServices.GetRequiredService<T>();
context.Database.EnsureCreated();
}
}
This results in a much cleaner factory class.
Pay close attention to call builder.ConfigureTestServices()
and not builder.ConfigureServices()
when testing an ASP.NET Core application prior to version 6.
The last method will be executed before the WebApplicationFactory
calls Startup.ConfigureServices()
, which means your production DI registration code, will override your changes, and you might test against your production database!
☝🏻Order of execution 💣
builder.ConfigureServices()
inside yourWebApplicationFactory
Startup.ConfigureServices()
from your application codebuilder.ConfigureTestServices()
insideWebApplicationFactory
Putting everything to work
The only thing left is to merge everything together. I have introduced generics to make it reusable across different projects in a solution.
And here is a basic test making use of the custom factory.
Final thoughts
This solution is nice because it balances resistance to refactoring, protection against regressions, and fast feedback (see Khorikov, 2020, p. 88).
Last but not least, the tests are runnable on GitHub without further modifications to the virtual environments 🤓