Dica Rápida – TestContainers

Hello!
This is another Quick Tip, and this time we're going to talk about TestContainers, a Docker-based tool that makes integration testing easier.
Let's get started!
The Problem
Before talking about the tool, it's worth discussing the problem it solves. TestContainers addresses a very common issue: the need to deploy to remote environments in order to run integration tests, because of infrastructure dependencies such as databases and messaging services.
At the same time, integration tests that aren't automated don't run as part of CI/CD pipelines, which ends up weakening the tests and reducing their reliability.
The Solution
TestContainers is a library that lets developers create containers with all sorts of dependencies and use them in their automated tests, making the local environment sufficient for running integration tests while also allowing the deployment pipeline to run the tests to enable a more reliable deployment.
Requirements
To use TestContainers, all you need is Docker installed locally; the library makes all the magic happen from there.
Our Project
For the demo project, I use a version of the library with Postgres to test an API via xUnit.
Let's see how it's done; you'll be surprised by how simple it is.
The Application
Since this is a demonstration, the application is quite simple: it's a task manager built as a Web API. In it, you can only create and complete a task, with no additional functionality.
Since this is a Quick Tip, I'll refrain from writing out the application's code here; you'll be able to check it out on GitHub at the end of the article.
The Tests
Here it's worth explaining a bit more.
To make the tests possible, we'll make use of WebApplicationFactory, a class that runs our application locally, in this case an ASP.NET Web API; check out the code below:
public class TodoWebApplicationFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
private readonly PostgreSqlContainer _dbContainer = new PostgreSqlBuilder("postgres:16-alpine")
.WithDatabase("test_db")
.WithUsername("postgres")
.WithPassword("postgres")
.Build();
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
var descriptor = services.SingleOrDefault(s => s.ServiceType == typeof(DbContextOptions<TodoDbContext>));
services.Remove(descriptor!);
services.AddDbContext<TodoDbContext>(options =>
options.UseNpgsql(_dbContainer.GetConnectionString()));
});
}
public async ValueTask InitializeAsync()
{
await _dbContainer.StartAsync();
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<TodoDbContext>();
await db.Database.EnsureCreatedAsync();
}
public new async Task DisposeAsync() =>
await _dbContainer.StopAsync();
}Let's go over the code above to understand how it works.
Notice the private PostgreSqlContainer field; it creates an instance of the Postgres Docker container via PostgreSqlBuilder. This builder takes the Postgres image version that will be used by the library (postgres:16-alpine) and then creates the test_db database. It also specifies the credentials that should be used for the database connection.
Believe it: that's all it takes to run the container!
In the next method, ConfigureWebHost, is where we tell our Web API that it should refer to the container's database in order to run. First we remove the DbContext configured in the application, and then we register a version of it configured to use the container via _dbContainer.GetConnectionString().
Because we're using the IAsyncLifetime interface, we can add an initialization method that will create the table where we'll store our tasks.
That's enough to move on to the next step: actually implementing the tests.
Here's the code for one of our Web API's endpoints, the one responsible for creating a task:
public class TodoTest(TodoWebApplicationFactory factory)
: IClassFixture<TodoWebApplicationFactory>, IDisposable, IAsyncDisposable
{
[Fact]
public async Task ShouldCreateTodo()
{
//Arrange
const string todoTitle = "Todo 1";
var client = factory.CreateClient();
//Act
var result = await client.PostAsJsonAsync("/todo/create",
new CreateTodoCommand(todoTitle), CancellationToken.None);
//Assert
Assert.True(result.IsSuccessStatusCode);
var scope = factory.Server.Services.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<TodoDbContext>();
var todo = context.Todos.FirstOrDefault(t => t.Title == todoTitle);
Assert.NotNull(todo);
}Notice that to make our test-customized application runnable, we need to inject our TodoWebApplicationFactory into our test class. It's responsible for creating the HTTP client that will access the available endpoints.
What we have in the test is quite simple: the factory allows us to create the HTTP client, which is configured to send a POST to our task-creation endpoint, which is then triggered.
The interesting part happens right after: from a scope, we obtain an instance of our TodoDbContext, and from it we check whether our task was successfully inserted into the database.
With that, we have a complete integration test, with verifiable HTTP and Postgres access.
Advantages
By now you've probably noticed some other advantages of using TestContainers over conventional methods:
- There's no need to use an in-memory database that emulates the behavior of the database used in production;
- There are no mocks! and;
- There's a massive increase in productivity.
The first point seems fundamental to me, because with an in-memory database, or other techniques such as using in-memory collections as well, it's not possible to explore the true behavior of your database. If you need to test a SQL query, the in-memory database will show limitations, and even if you use a database like SQLite, which is also quite common, there will be differences between its capabilities and those of a database like Postgres (SQLite, for example, doesn't offer all the data types that Postgres does).
The second point is interesting for two reasons: mocks don't let you test the mappings between your model's fields and the database columns. Of course, EF Core makes this mapping easier since it creates the table in the database from the model using the Code First strategy, but in the case of an application that uses Dapper or even plain ADO.NET, this work is basically impossible.
The third seems to me the main justification for using TestContainers, given that the time spent on tests in remote environments is usually not small, especially when you need to go through the deployment pipeline for the application to become available in one of those environments, which can cause the process to be interrupted if it's down or even if the infrastructure dependencies are unavailable at test time.
Conclusion
This model of automated testing with TestContainers is quite powerful because it allows automated tests to be as close as possible to the production environment, making your tests more reliable and able to run in any environment where Docker is available.
Something that isn't shown in this post, since it's a Quick Tip after all, is that it's possible to build images of your applications from a docker file. In other words, it's entirely feasible to have communication between two applications via HTTP or even messaging, making your integration tests even more powerful.
I see TestContainers as an indispensable tool for application development, given its simplicity and the main advantages we've listed above.
As always, the article's sample code is available on GitHub; feel free to download it and give it a try.
Did you like it? Let me know through the indicators, comments, or even through my social media.
See you in the next post!
Translated from the Brazilian Portuguese original · Read the original





