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 already heard of a library called testcontainers. It's a Java library that provides "... lightweight, throwaway instances of common databases, Selenium web browsers, or anything else that can run in a Docker container."
For this post, I am going to use its C# counterpart which is 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. for Postgres, Microsoft SQL Server, MySQL, Redis, RabbitMQ, and a couple more.
EF Core Testing Strategies & Test Containers
When it comes to choosing an EF Core testing strategy, you can follow two different paths. 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
All of these strategies have their pros and cons, which I am not going to elaborate in full detail 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 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 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 of it.
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 is using xUnits IAsyncLifetime
interface to ensure the container is ready to serve requests before the actual test runs.
public sealed class PostgreSqlTest : IAsyncLifetime
{
private readonly TestcontainerDatabase testcontainers = new TestcontainersBuilder<PostgreSqlTestcontainer>()
.WithDatabase(new PostgreSqlTestcontainerConfiguration
{
Database = "db",
Username = "postgres",
Password = "postgres",
})
.Build();
[Fact]
public void ExecuteCommand()
{
using (var connection = new NpgsqlConnection(this.testcontainers.ConnectionString))
{
using (var command = new NpgsqlCommand())
{
connection.Open();
command.Connection = connection;
command.CommandText = "SELECT 1";
command.ExecuteReader();
}
}
}
public Task InitializeAsync()
{
return this.testcontainers.StartAsync();
}
public Task DisposeAsync()
{
return this.testcontainers.DisposeAsync().AsTask();
}
}
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 type is provided by the NuGet package Microsoft.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();
}
}
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 { }
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 provided from above, we would have to replace this dependency for each and every integration test. Instead, we are 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();
});
}
}
This is not the most beatiful 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 into 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>();
});
}
}
Pay close attention to call builder.ConfigureTestServices()
and not builder.ConfigureServices()
when testing an ASP.NET Core application prior 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.
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();
}
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();
}
}
Final thoughts
The nice thing about this solution is that it provides a good balance between 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

