ASP.NET Core Integration Tests with Test Containers & Postgres

ASP.NET Core Integration Tests with Test Containers & Postgres

Introduction

In this post, I am going to demonstrate how test containers can be leveraged for proper DAL integration testing of ASP.NET Core & EF Core + 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.

GitHub - testcontainers/testcontainers-dotnet: A library to support tests with throwaway instances of Docker containers for all compatible .NET Standard versions.
A library to support tests with throwaway instances of Docker containers for all compatible .NET Standard versions. - GitHub - testcontainers/testcontainers-dotnet: A library to support tests with ...

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 and DBSet
  • 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 are not behaving 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 need to set up an RDBMS on your developer machine and a build server (and maintain it).

And 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 beforementioned 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)

And last but not least, 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 actual test runs.

public sealed class PostgreSqlTest : IAsyncLifetime
{
    private readonly PostgreSqlContainer _postgreSqlContainer = new PostgreSqlBuilder()
        .WithImage("postgres:14.7")
        .WithDatabase("db")
        .WithUsername("postgres")
        .WithPassword("postgres")
        .WithCleanUp(true)
        .Build(); 
    
    [Fact]
    public void ExecuteCommand()
    {
        using var connection = new NpgsqlConnection(_postgreSqlContainer.GetConnectionString());
        using var command = new NpgsqlCommand();
        connection.Open();
        command.Connection = connection;
        command.CommandText = "SELECT 1";
        command.ExecuteReader();
    }

    public Task InitializeAsync()
    {
        return _postgreSqlContainer.StartAsync();
    }

    public Task DisposeAsync()
    {
        return _postgreSqlContainer.DisposeAsync().AsTask();
    }
}
PostgreSqlTest.cs

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.

public class IntegrationTest : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly WebApplicationFactory<Program> _factory;

    public IntegrationTest(WebApplicationFactory<Program> factory)
    {
        _factory = factory;
    }

    [Fact]
    public async Task Should_return_weather_forecast_on_http_get()
    {
        var client = _factory.CreateClient();

        var response = await client.GetAsync("/WeatherForecast");

        response.EnsureSuccessStatusCode();
    }
}
IntegrationTest.cs

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.

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();

app.Run();

public partial class Program { }
Program.cs

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().

public class CustomFactory : WebApplicationFactory<Program>
{
    // Gives a fixture an opportunity to configure the application before it gets built.
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureTestServices(services =>
        {
            // Remove AppDbContext
            var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
            if (descriptor != null) services.Remove(descriptor);
            
            // Add DB context pointing to test container
            services.AddDbContext<AppDbContext>(options => { options.UseNpgsql("the new connection string"); });
            
            // Ensure schema gets created
            var serviceProvider = services.BuildServiceProvider();

            using var scope = serviceProvider.CreateScope();
            var scopedServices = scope.ServiceProvider;
            var context = scopedServices.GetRequiredService<AppDbContext>();
            context.Database.EnsureCreated();
        });
    }
}
CustomFactory.cs

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.

public class CustomFactory : WebApplicationFactory<Program>
{
    // Gives a fixture an opportunity to configure the application before it gets built.
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureTestServices(services =>
        {
            // Remove AppDbContext
            services.RemoveDbContext<AppDbContext>();
            
            // Add DB context pointing to test container
            services.AddDbContext<AppDbContext>(options => { options.UseNpgsql("the new connection string"); });
            
            // Ensure schema gets created
            services.EnsureDbCreated<AppDbContext>();
        });
    }
}
CustomFactory.cs

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 💣

  1. builder.ConfigureServices() inside your WebApplicationFactory
  2. Startup.ConfigureServices() from your application code
  3. builder.ConfigureTestServices() inside WebApplicationFactory

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.

public class IntegrationTestFactory<TProgram, TDbContext> : WebApplicationFactory<TProgram>, IAsyncLifetime
    where TProgram : class where TDbContext : DbContext
{
    private readonly TestcontainerDatabase _container;

    public IntegrationTestFactory()
    {
        _container = new TestcontainersBuilder<PostgreSqlTestcontainer>()
            .WithDatabase(new PostgreSqlTestcontainerConfiguration
            {
                Database = "test_db",
                Username = "postgres",
                Password = "postgres",
            })
            .WithImage("postgres:11")
            .WithCleanUp(true)
            .Build();
    }

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureTestServices(services =>
        {
            services.RemoveProdAppDbContext<TDbContext>();
            services.AddDbContext<TDbContext>(options => { options.UseNpgsql(_container.ConnectionString); });
            services.EnsureDbCreated<TDbContext>();
        });
    }

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

    public new async Task DisposeAsync() => await _container.DisposeAsync();
}
IntegrationTestFactory.cs

And here is a basic test making use of the custom factory.

public class Tests : IClassFixture<IntegrationTestFactory<Program, AppDbContext>>
{
    private readonly IntegrationTestFactory<Program, AppDbContext> _factory;

    public Tests(IntegrationTestFactory<Program, AppDbContext> factory) => _factory = factory;

    [Fact]
    public async Task Foo()
    {
        var client = _factory.CreateClient();

        var response = await client.GetAsync("/weatherforecast");
        
        response.EnsureSuccessStatusCode();
    }
}
IntegrationTest.cs

Final thoughts

The nice thing about this solution is that it balances resistance to refactoring, protection against regressions, and fast feedback (see Khorikov, 2020, p. 88).

And last but not least, the tests are runnable on GitHub without further modifications to the virtual environments 🤓

Further reading

Integration tests in ASP.NET Core
Learn how integration tests ensure that an app’s components function correctly at the infrastructure level, including the database, file system, and network.
Integration tests in ASP.NET Core
Converting integration tests to .NET Core 3.0: Upgrading to ASP.NET Core 3.0 - Part 5
In this post I discuss the changes required to upgrade integration tests that use WebApplicationFactory or TestServer to ASP.NET Core 3.0.
How to use IOutputHelper in a custom WebApplicationFactory
virtual-environments/Ubuntu2004-Readme.md at main · actions/virtual-environments
GitHub Actions virtual environments. Contribute to actions/virtual-environments development by creating an account on GitHub.
List of available packages on GitHub-hosted runners
Testcontainers for .NET