Dev & EngARTICLE

Dica Rápida – TestContainers

Dica Rápida – TestContainers
Image: William Santos

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