A reliable suite of integration tests significantly increases confidence in your code's correctness, but running your tests in an environment as close to production as possible is equally crucial. If you are running your integration tests against dependencies that are mocked or stubbed, you risk having a false sense of security. For instance, you may be used to test your code against in-memory database from Entity Framework Core, but did you know that the in-memory database behaves differently than the real database engine? It lacks a few essential features, such as transactions or constraints, and you might not catch some bugs that would occur in a real environment. So, why not run your tests against the real database engine or messaging broker? With TestContainers library, you can do just that. Spinning up a tangible instance of a database for the lifetime of your tests requires only a few lines of code.
But before we start, all code examples in this post are available in my GitHub repository so feel free to clone it and check it out locally.
What's so great about TestContainers?
TestContainers is a .NET library that allows you to programmatically manage Docker images and containers for the purpose of integration testing. It lets you create and dispose containers of common, predefined images as well as custom Docker images, built based on your Dockerfile or configuration provided in the code.
You can find more information about TestContainers library and its documentation on the TestContainers project page.
How to use TestContainers?
To use TestContainers in your project, you need to have Docker running on your machine. You can make sure that it's up by running docker info
in command line. If Docker is running, this will display detailed information about the Docker daemon.
The next step is adding the TestContainers NuGet package to your test project. In my example, I will be creating temporary instances of Postgresql database for the integration tests, so I will also add a package that contains predefined Postgresql test containers. You can do this by running the following command in terminal in test project directory:
1dotnet add package TestContainers 2dotnet add package Testcontainers.PostgreSql
Once you have both packages installed, you can start using it in your tests.
Using preconfigured containers
First, let's configure integration tests base class, that will manage the Postgresql Container lifecycle and your test classes will inherit from. Here is an example of how you can write it:
1using Microsoft.AspNetCore.Mvc.Testing; 2using Microsoft.EntityFrameworkCore; 3using Testcontainers.PostgreSql; 4using Microsoft.Extensions.DependencyInjection; 5using TestContainersExample.API; 6using DotNet.Testcontainers.Builders; 7 8namespace TestContainersExample.Tests; 9 10public class PreconfiguredContainerIntegrationTestBase : IAsyncLifetime 11{ 12 protected HttpClient _client; 13 private PostgreSqlContainer _postgresContainer; 14 private WebApplicationFactory<Program> _factory; 15 16 private readonly string dbUsername = "testuser"; 17 private readonly string dbPassword = "testpassword"; 18 private readonly string dbName = "testdb"; 19 20 public async Task InitializeAsync() 21 { 22 _postgresContainer = new PostgreSqlBuilder() 23 .WithUsername(dbUsername) 24 .WithPassword(dbPassword) 25 .WithDatabase(dbName) 26 .WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(5432)) 27 .Build(); 28 29 await _postgresContainer.StartAsync(); 30 var connectionString = _postgresContainer.GetConnectionString(); 31 32 _factory = new WebApplicationFactory<Program>() 33 .WithWebHostBuilder(builder => 34 { 35 builder.ConfigureServices(services => 36 { 37 var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<AppDbContext>)); 38 if(descriptor != null) 39 services.Remove(descriptor); 40 41 services.AddDbContext<AppDbContext>(options => 42 { 43 options.UseNpgsql(connectionString); 44 }); 45 }); 46 }); 47 48 _client = _factory.CreateClient(); 49 } 50 51 public async Task DisposeAsync() 52 { 53 await _postgresContainer.StopAsync(); 54 } 55}
In this class, we are creating a new instance of Postgresql container with predefined username, password and database name. We are also setting a wait strategy that will wait until the Postgresql port is available. There is more configuration options available in PostgresqlBuilder
object, so that you can prepare container to suit you use-case. The container is started in InitializeAsync
method and stopped in DisposeAsync
method, which are part of IAsyncLifetime
interface.
We are also creating a new instance of WebApplicationFactory
that will host our test instance of API and configuring the AppDbContext
to use the connection string from the Postgresql container. To wrap this up, we are creating a new HttpClient
that will be used in our tests to make requests to app endpoints.
Then we are ready to create first test method that will use the container:
1public class PreconfiguredContainerIntegrationTests : PreconfiguredContainerIntegrationTestBase 2{ 3 [Fact] 4 public async Task TestWeatherForecastEndpoint() 5 { 6 // Arrange 7 var weatherForecast = new WeatherForecast(new DateOnly(2021, 1, 1), 25, "Sunny"); 8 SeedWeatherEntities(weatherForecast); // helper method to seed the database 9 10 // Act 11 var response = await _client.GetAsync("/weatherforecast"); 12 13 // Assert 14 response.EnsureSuccessStatusCode(); 15 16 var actualForecasts = await response.Content.ReadFromJsonAsync<WeatherForecast[]>(); 17 Assert.NotNull(actualForecasts); 18 Assert.Single(actualForecasts); 19 Assert.Equal(weatherForecast.Date, actualForecasts[0].Date); 20 Assert.Equal(weatherForecast.TemperatureC, actualForecasts[0].TemperatureC); 21 Assert.Equal(weatherForecast.Summary, actualForecasts[0].Summary); 22 } 23}
This simple tests checks whether the API resposne is successful and asserts a few properties of the weather forecast object. The SeedWeatherEntities
method is a helper method that seeds the database with the provided weather forecast object.
For this test, xUnit
will create a new instance of PreconfiguredContainerIntegrationTests
class, which will start a new container, set up test API instance and run the test method. After the test method is finished, the container will be stopped and disposed. If there are more test methods in the same class, the containers will be created and disposed for each of them, so each test will have its own instance of the container. This ensures that tests are isolated and do not interfere with one another.
Now you can run the tests by executing dotnet test
command in the test project directory. The tests should pass and you can be sure that your API works correctly with the Postgresql database.
Custom containers
In the previous example, I used an already provided Postgresql container, but what if what you want to use is not available in the TestContainers library? Thankfully, TestContainers allow you to create your own custom container by providing a Dockerfile or configuration in the code. Here is an example of how you can create the same Postgresql container as in the previous example, but this time using custom container defined purely in the code:
1public class CustomContainerIntegrationTestBase : IAsyncLifetime 2{ 3 protected HttpClient _client; 4 private IContainer _postgresContainer; 5 private WebApplicationFactory<Program> _factory; 6 7 private readonly string dbUsername = "testuser"; 8 private readonly string dbPassword = "testpassword"; 9 private readonly string dbName = "testdb"; 10 private static int port => 5432; 11 12 public async Task InitializeAsync() 13 { 14 _postgresContainer = new ContainerBuilder() 15 .WithImage("postgres:latest") 16 .WithPortBinding(port, true) 17 .WithEnvironment("POSTGRES_USER", dbUsername) 18 .WithEnvironment("POSTGRES_PASSWORD", dbPassword) 19 .WithEnvironment("POSTGRES_DB", dbName) 20 .WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(port)) 21 .Build(); 22 23 await _postgresContainer.StartAsync(); 24 var connectionString = $"Host={_postgresContainer.Hostname};Port={_postgresContainer.GetMappedPublicPort(port)};Database={dbName};Username={dbUsername};Password={dbPassword}"; 25 26 _factory = new WebApplicationFactory<Program>() 27 .WithWebHostBuilder(builder => 28 { 29 // .... code omitted for brevity 30 }); 31 32 _client = _factory.CreateClient(); 33 } 34 35 public async Task DisposeAsync() 36 { 37 await _postgresContainer.StopAsync(); 38 } 39}
When building custom container, you can use ContainerBuilder
type to specify what image you want to use, what ports you want to expose, what environment variables you want to set and much more - you can check the list of available configuration methods in the docs. The rest of the code is the same as in the previous example, so you can reuse the same test methods. Running dotnet test
should produce the same results as before, but this time the Postgresql container is created based on the configuration provided in the code.
Dockerfile containers
If you don't want to configure custom container in code, you are free to use Dockerfile to build the image and then use it in the tests. Here is an example of how you can do it:
1var postgresImage = new ImageFromDockerfileBuilder() 2 .WithDockerfileDirectory(CommonDirectoryPath.GetSolutionDirectory(), "TestContainersExample.Tests") 3 .WithDockerfile("PostgresqlDockerfile") 4 .WithName("dockerfile-postgres") 5 .Build(); 6await postgresImage.CreateAsync(); 7 8_postgresContainer = new ContainerBuilder() 9 .WithImage(postgresImage) 10 .WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(5432)) 11 .Build(); 12 13await _postgresContainer.StartAsync();
This time, you need to provide a path to Dockerfile and programmatically build Docker image out of it, and then run it as container similarly as in previous examples.
By using custom code configuration or custom Dockerfile you can create any container you want for the purpose of integration testing, so you can be sure that your tests are as close to the real-world environment as possible.
Final thoughts
Having the ability to run integration tests against tangible dependencies is a great advantage, and TestContainers library makes it easy to achieve. Keep in mind, that you are not limited to a single container - you can create as many containers for your tests as you need. If you need Redis instance to test your caching layer, just have at it. Message broker for your messaging system? No problem. You can even create a network of containers that will communicate with each other, so you can test the whole system in one go.
TestContainers is a great addition to your testing toolbox, so I encourage you to give it a try in your next project.