Integrating xUnit with Testcontainers in an ASP.NET Core Web API testing setup allows us to create robust, isolated test environments that closely mimic production scenarios. This setup provides a consistent and repeatable way to run tests against an actual database without manually managing database instances. Let’s start by creating a new project called CompanyEmployees.IntegrationTests
.
To use xUnit
and Testcontainers
, we need to install the following NuGet packages in our test project:
dotnet add package xunit --version 2.9.2 dotnet add package xunit.runner.visualstudio --version 3.0.0-pre.49 dotnet add package Testcontainers --version 4.0.0 dotnet add package Testcontainers.MsSql --version 4.0.0 Microsoft.AspNetCore.Mvc.Testing --version 9.0.0
Or with Package Manager Console:
Install-Package xunit -Version 2.9.2 Install-Package xunit.runner.visualstudio --version 3.0.0-pre.49 Install-Package Testcontainers --version 4.0.0 Install-Package Testcontainers.MsSql --version 4.0.0 Install-Package Microsoft.AspNetCore.Mvc.Testing --version 9.0.0
We’ll also need the testing SDK Microsoft.NET.Test.Sdk
so add it as well. Once the installation is complete, let’s create our custom WebApplicationFactory
to configure our WebAPI for integration testing. This factory will handle the setup and configuration of the application for each test.
Let’s create a new folder called Factories
, and then a new class called CompanyEmployeesTestcontainersFactory
inside it:
public class CompanyEmployeesTestcontainersFactory: WebApplicationFactory<Program> { private const string Database = "master"; private const string Username = "sa"; private const string Password = "yourStrong(!)Password"; private const ushort MsSqlPort = 1433; private readonly IContainer _mssqlContainer; }
First, we create a CompanyEmployeesTestcontainersFactory
class that implements the WebApplicationFactory<T>
class where T
is our Program
class. The WebApplicationFactory<T>
provides us with functionality for running an application in memory for various testing purposes.
To be able to use Program
we must reference our main project, but since Program
is inaccessible due to the protection level, we can add this line at the end of the class:
public partial class Program { }
This should make it accessible through our Testing project. We declare several constants that we will later use for the container’s configuration and the database connection string, as well as an instance of IContainer
called _mssqlContainer
.
Configuring Default Test Containers
Next, in the same class, we create a constructor for our CompanyEmployeesTestcontainersFactory
class:
public CompanyEmployeesTestcontainersFactory() { _mssqlContainer = new MsSqlBuilder().Build(); }
Here, we create a new instance on the MsSqlBuilder
class and invoke its Build()
method. This will create a default MSSQL Docker container for us. The database name, username, password, and port will be the default ones for the Microsoft SQL Server and the same as the constants we defined earlier. The pre-defined image is mcr.microsoft.com/mssql/server:2019-CU18-ubuntu-20.04.
This approach comes in handy when we need a SQL Server container and are not interested in the details of setting it up further. Similar additional NuGet packages support default containers for MySQL, PostgreSQL, MongoDB, Redis, and many others. The GitHub repository from Testcontainers contains these packages.
Configuring Custom Test Containers
If we want more control, we can define a custom container:
public CompanyEmployeesTestcontainersFactory() { _mssqlContainer = new ContainerBuilder() .WithImage("mcr.microsoft.com/mssql/server:2022-latest") .WithPortBinding(MsSqlPort) .WithEnvironment("ACCEPT_EULA", "Y") .WithEnvironment("SQLCMDUSER", Username) .WithEnvironment("SQLCMDPASSWORD", Password) .WithEnvironment("MSSQL_SA_PASSWORD", Password) .Build(); }
In the constructor, we instantiate our _mssqlContainer
member variable. We achieve that by using the Testcontainers’ ContainerBuilder
class and its provided functionality to build container definitions. We start by using the WithImage()
method to specify which Docker image we want to use, opting for the latest version of the Microsoft SQL Server 2022. Then, we move on to the WithPortBinding()
method, passing the MsSqlPort
constant to specify which port to use.
This will do a one-to-one port mapping from host to container. Then, we chain several instances of the WithEnvironment()
method to set environment variables on the container. Those variables correspond to important properties such as SQL Server username and password. We finish things off with the Build()
method, which builds an instance of the IContainer
interface with all of our custom settings.
Overriding the Database Configuration
Then, we override the ConfigureWebHost()
method:
protected override void ConfigureWebHost(IWebHostBuilder builder) { var host = _mssqlContainer.Hostname; var port = _mssqlContainer.GetMappedPublicPort(MsSqlPort); builder.ConfigureServices(services => { services.RemoveAll(typeof(DbContextOptions<RepositoryContext>)); services.AddDbContext<RepositoryContext>(options => options.UseSqlServer($"Server={host},{port};Database={Database};User Id={Username};Password={Password};TrustServerCertificate=True") .ConfigureWarnings(warnings => warnings.Ignore(RelationalEventId.PendingModelChangesWarning))); }); }
The first thing we do is extract the host
and port
of the container. Then, in the ConfigureServices()
method, we first remove all services of type DbContextOptions<RepositoryContext>
to make sure we clear all database configurations. Then we add our ApplicationDbContext
again using the AddDbContext()
method and passing a connection string pointing to our container.
We need to configure warnings with .NET 9 unless we want to get an exception. We also want to use the data we already have in our real database, so let’s apply the migrations in the ConfigureServices
method as well:
builder.ConfigureServices(services => { services.RemoveAll(typeof(DbContextOptions<RepositoryContext>)); services.AddDbContext<RepositoryContext>(options => options.UseSqlServer($"Server={host},{port};Database={Database};User Id={Username};Password={Password};TrustServerCertificate=True") .ConfigureWarnings(warnings => warnings.Ignore(RelationalEventId.PendingModelChangesWarning))); var sp = services.BuildServiceProvider(); using var scope = sp.CreateScope(); var appContext = scope.ServiceProvider.GetRequiredService<RepositoryContext>(); try { appContext.Database.Migrate(); // Apply all migrations to keep schema in sync } catch (Exception ex) { // Log or handle migration exceptions if needed throw; } });
That should take care of our data. We want to have at least some data we can use in our tests. This step is unnecessary as you can always add your own data during testing, but since we already have some data in our migrations, why not use it?
Starting and Stopping Containers
Finally, let’s add a way to start our container before each test and stop it afterward:
public class CompanyEmployeesTestcontainersFactory : WebApplicationFactory<Program>, IAsyncLifetime { public async Task InitializeAsync() { await _mssqlContainer.StartAsync(); } public new async Task DisposeAsync() { await _mssqlContainer.DisposeAsync(); } }
We implement the xUnit’s interface IAsyncLifetime
and define its methods to achieve this. In the InitializeAsync()
method we the StartAsync()
method to start our container. In the DisposeAsync()
method, we dispose of our container using the identically named disposing method.
Best Practices for Tests With Testcontainers
We want to make a few modifications before we start writing tests. Awaiting strategies are a key feature that ensures no tests will be run until the Docker container is up and running:
public CompanyEmployeesTestcontainersFactory() { _mssqlContainer = new ContainerBuilder() .WithImage("mcr.microsoft.com/mssql/server:2022-latest") .WithPortBinding(MsSqlPort) .WithEnvironment("ACCEPT_EULA", "Y") .WithEnvironment("SQLCMDUSER", Username) .WithEnvironment("SQLCMDPASSWORD", Password) .WithEnvironment("MSSQL_SA_PASSWORD", Password) .WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(MsSqlPort)) .Build(); }
In our container initialization, we add the WithWaitStrategy()
method. It takes one parameter of the custom IWaitForContainerOS
interface. For this, we use the Wait
class with two of its methods. We start with the ForUnixContainer()
method and follow up with the UntilPortIsAvailable()
method, passing the MsSqlPort
. This prevents our test methods from running before everything is up and running, saving us from failed tests due to the unready containers.
Having a static host port is not ideal as it can lead to clashes, so it is better to use dynamic host ports:
public CompanyEmployeesTestcontainersFactory() { _mssqlContainer = new ContainerBuilder() .WithImage("mcr.microsoft.com/mssql/server:2022-latest") .WithPortBinding(MsSqlPort, true) .WithEnvironment("ACCEPT_EULA", "Y") .WithEnvironment("SQLCMDUSER", Username) .WithEnvironment("SQLCMDPASSWORD", Password) .WithEnvironment("MSSQL_SA_PASSWORD", Password) .WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(MsSqlPort)) .Build(); }
Again in our CompanyEmployeesTestcontainersFactory
constructor, we update the WithPortBinding()
method call by passing true
as a second argument. This will assign a random host port for each instance of our container. That’s all the factory configuration needs. Since it seems vague now, let’s see how we can implement it in our test classes.