Featured image of post How to run disposable databases for your tests. Improve your integration tests accuracy with Testcontainers

How to run disposable databases for your tests. Improve your integration tests accuracy with Testcontainers

Spining up disposable databases with Testcontainers

Follow me

Introduction Link to this section

Integration tests are essential to ensure that the different components of our system work together as expected and continue to work after changes.

In this post, I’ll explain how to spin up disposable database containers to use in integration tests.

Integration tests and managed resources Link to this section

As explained in this article, in integration tests, we should mock unmanaged dependencies (dependencies that are external to our system and not controlled by us, like APIs) but test against real managed dependencies (dependencies that are controlled by our system, like databases). This improves the reliability of the integration tests because the communication with these dependencies are a complex part of the system and can break with a package update, a database update or even a simple change in a SQL command.

What is Testcontainers? Link to this section

Testcontainers is a library that provides lightweight, throwaway instances of databases, selenium web browsers, or anything that can run in a container. These instances can be especially useful for testing applications against real dependencies, like databases, that can be created and disposed of after the tests.

Why not run the containers manually? Link to this section

One key benefit of using Testcontainers instead of running the containers manually is that we can make use of libraries such as AutoFixture to generate data to seed the database instead of running scripts to insert the data. This also helps in avoiding collision between data used in different tests because the data is random.

Also, there are other advantages in usings Testcontainers:

  • To run the tests locally, you don’t need any extra steps, like running a docker-compose command;
  • You don’t have to wait or implement a waiting strategy to check if the containers are running before accessing them. Testcontainers already implements this logic;
  • Testcontainers have properties to access the port in which the container is running, so you don’t need to specify a fixed port, avoiding port conflict when running in the CI/CD pipeline or other machines;
  • Testcontainers stops and deletes the containers automatically after running, free resources on the machine.

Running a disposable container with Testcontainers Link to this section

To use Testcontainers, you will need to have a container runtime (Docker, Podman, Rancher, etc) installed on your machine.

Then, you need to add the Testcontainers NuGet package to your test project:

dotnet add package Testcontainers --version 3.1.0
dotnet add package Testcontainers.MySql --version 3.1.0

⚠️ Since the writing of this post, Testcontainers released version 3.1.0 and made breaking changes. I’m updating this post to reflect these changes. To see the original code with Testcontainers 2.3.*, look in this branch of the repository.

To run a container, we first need to use the ContainerBuilder class to build a DockerContainer or a derived class, for instance, MySqlBuilder:

1
2
3
4
5
await using var mySqlContainer = new MySqlBuilder()
    .WithDatabase("TestDB")
    .WithPassword("Test1234")
    .WithImage("mysql:8.0.31-oracle")
    .Build();

Then, we start the container and use the GetConnectionString method where needed:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
await using var mySqlContainer = new MySqlBuilder()
    .WithDatabase("TestDB")
    .WithPassword("Test1234")
    .WithImage("mysql:8.0.31-oracle")
    .Build();

await mySqlContainer.StartAsync();

await using var todoContext = TodoContext
    .CreateFromConnectionString(mySqlContainer.GetConnectionString());

DockerContainer implements IAsyncDisposable and needs to be disposed of after use. We can use the await using syntax or call the DisposeAsync method.

Testcontainers have classes for many different databases (called modules), for example:

  • Elasticsearch;
  • MariaDB;
  • Microsoft SQL Server;
  • MySQL;
  • Redis.

The full list can be seen here.

But we can also create containers from any image, as in the example below, where it creates a Memcached instance:

1
2
3
4
5
await using var MemCachedContainer = new ContainerBuilder()
    .WithImage("memcached:1.6.17")
    .Build();

await MemCachedContainer.StartAsync();

💡 In this post, I explain how to use Testcontainers with Localstack to create integration tests for systems that use AWS services.

More details in the official documentation.

Creating integration tests with Testcontainers Link to this section

In this example, I’m using the xUnit and the WebApplicationFactory<T> class from ASP.NET Core.

If you don’t know how to use the WebApplicationFactory<T> class, I explained in this post.

In this example, I have a controller with a GET method that returns a ToDo item and a POST method that add a ToDo item:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
[ApiController]
[Route("[controller]")]
public class TodoController : ControllerBase
{
    private readonly TodoContext _todoContext;

    public TodoController(TodoContext todoContext)
    {
        _todoContext = todoContext;
    }

    [HttpGet("{itemId}", Name = "GetTodoItem")]
    public async Task<ActionResult<TodoItem?>> GetByIdAsync(int itemId)
    {
        var item = await _todoContext.TodoItems.SingleOrDefaultAsync(a => a.ItemId == itemId);

        if (item is null)
        {
            return NotFound();
        }

        return Ok(item);
    }

    [HttpPost]
    public async Task<ActionResult<int>> PostAsync(TodoItem todoItem)
    {
        _todoContext.Add(todoItem);

        await _todoContext.SaveChangesAsync();
        
        return CreatedAtRoute("GetTodoItem", new { itemId = todoItem.ItemId }, todoItem);
    }
}

⚠️ The business logic is in the controller just for the sake of simplicity. In a real-world application, the logic should be in another layer.

Testing the GET method Link to this section

The test does the following actions:

  1. Create and start a MySql container;
  2. Create a Entity Framework DbContext using the ConnectionString from the MySql in the container;
  3. Create the database tables using Entity Framework; (Can also be done passing a script to the ExecScriptAsync method from the mySqlContainer object);
  4. Create a random object with AutoFixture and add it to the database table;
  5. Override our application configuration with the connection string from the container;
  6. Create an HttpClient pointing to our application;
  7. Make a request to the GET endpoint passing the Id of the random object we added to the database;
  8. Validate that the Status Code is 200 and the object returned is the same we added to the database.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
public class TodoIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly WebApplicationFactory<Program> _factory;

    public TodoIntegrationTests(WebApplicationFactory<Program> factory)
    {
        _factory = factory;
    }

    [Fact]
    public async Task GetOneItem_Returns200WithItem()
    {
        //Arrange
        await using var mySqlContainer = new MySqlBuilder()
            .WithDatabase("TestDB")
            .WithPassword("Test1234")
            .WithImage("mysql:8.0.31-oracle")
            .Build();

        await mySqlContainer.StartAsync();

        await using var todoContext = TodoContext
            .CreateFromConnectionString(mySqlContainer.GetConnectionString());

        // Creates the database if not exists
        await todoContext.Database.EnsureCreatedAsync();

        Fixture fixture = new Fixture();
        var todoItem = fixture.Create<TodoItem>();

        todoContext.TodoItems.Add(todoItem);

        await todoContext.SaveChangesAsync();

        var HttpClient = _factory
            .WithWebHostBuilder(builder =>
            {
                builder.UseSetting("MySqlConnectionString", mySqlTestcontainer.ConnectionString);
            })
            .CreateClient();

        //Act
        var HttpResponse = await HttpClient.GetAsync($"/todo/{todoItem.ItemId}");

        //Assert
        HttpResponse.StatusCode.Should().Be(HttpStatusCode.OK);

        var responseJson = await HttpResponse.Content.ReadAsStringAsync();
        var todoItemResult = JsonSerializer.Deserialize<TodoItem>(responseJson);

        todoItemResult.Should().BeEquivalentTo(todoItem);
    }
}

❗ Be aware that I pass the container tag version in the WithImage method even when using the typed MySqlBuilder class. This is very important because when we don’t pass the tag, the container runtime will use the latest tag and a database update may break the application and the tests.

Tests running and container lifecycle

Testing the POST method Link to this section

First, let’s migrate the MySqlContainer and the DbContext creation to a Fixture and a Collection so they can be shared between all tests. This is recommended because unless we do this, all tests will spin up and dispose of the container, making our tests slower than needed.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
[CollectionDefinition("MySqlTestcontainer Collection")]
public class MySqlTestcontainerCollection: ICollectionFixture<MySqlTestcontainerFixture>
{
}

public class MySqlTestcontainerFixture : IAsyncLifetime
{
    public MySqlContainer MySqlContainer { get; private set; } = default!;
    public TodoContext TodoContext { get; private set; } = default!;

    public async Task InitializeAsync()
    {
        MySqlContainer = new MySqlBuilder()
            .WithDatabase("TestDB")
            .WithPassword("Test1234")
            .WithImage("mysql:8.0.31-oracle")
            .Build();

        await MySqlContainer.StartAsync();

        TodoContext = TodoContext
            .CreateFromConnectionString(MySqlContainer.GetConnectionString());

        // Creates the database if it does not exists
        await TodoContext.Database.EnsureCreatedAsync();
    }

    public async Task DisposeAsync()
    {
        await MySqlContainer.DisposeAsync();

        await TodoContext.DisposeAsync();
    }
}

The test does the following actions:

  1. MySql container and DbContext are injected by the xUnit fixture;
  2. Create a random object with AutoFixture;
  3. Override our application configuration with the connection string from the container;
  4. Create an HttpClient pointing to our application;
  5. Make POST request to the endpoint passing the random object previously created;
  6. Validate that the Status Code is 200 and that the Location header has the correct URL for the GET endpoint of the created object;
  7. Query the database and validate that the object created is equal to the randomly created object.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
[Collection("MySqlTestcontainer Collection")]
public class TodoIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly WebApplicationFactory<Program> _factory;
    private readonly MySqlContainer _mySqlContainer;
    private readonly TodoContext _todoContext;

    public TodoIntegrationTests(WebApplicationFactory<Program> factory, 
        MySqlTestcontainerFixture mySqlTestcontainerFixture)
    {
        _factory = factory;
        _mySqlContainer = mySqlTestcontainerFixture.MySqlContainer;
        _todoContext = mySqlTestcontainerFixture.TodoContext;
    }

    //Other tests
    //...

    [Fact]
    public async Task PostOneItem_Returns201AndCreateItem()
    {
        //Arrange
        Fixture fixture = new Fixture();
        var todoItem = fixture.Build<TodoItem>()
            .With(x => x.ItemId, 0)
            .Create();

        var HttpClient = _factory
            .WithWebHostBuilder(builder =>
            {
                builder.UseSetting("MySqlConnectionString", _mySqlContainer.GetConnectionString());
            })
            .CreateClient();

        //Act
        var HttpResponse = await HttpClient.PostAsJsonAsync($"/todo", todoItem);

        //Assert
        HttpResponse.StatusCode.Should().Be(HttpStatusCode.Created);

        var responseJson = await HttpResponse.Content.ReadAsStringAsync();
        var todoItemResult = JsonSerializer.Deserialize<TodoItem>(responseJson);

        HttpResponse.Headers.Location.Should().Be($"{HttpClient.BaseAddress}Todo/{todoItemResult!.ItemId}");

        var dbItem = await _todoContext.TodoItems
            .SingleAsync(a => a.ItemId == todoItemResult!.ItemId);

        dbItem.Description.Should().Be(todoItem.Description);
    }
}

Source code of this sample Link to this section

https://github.com/dgenezini/TestcontainersMySqlSample

💬 Like or have something to add? Leave a comment below.
Ko-fi
GitHub Sponsor
Licensed under CC BY-NC-SA 4.0
Built with Hugo
Theme Stack designed by Jimmy