Utilizando TestContainers para realizar tests de integración

El problema

Muchas veces se nos plantea la necesidad de realizar tests de integración en nuestras APIs desarrolladas en .NET. Por ejemplo, para testear que la base de datos funciona correctamente.

Para el caso de la base de datos, podemos usar EF InMemory, pero incluso Microsoft nos dice lo siguiente:

While some users use the in-memory database for testing, this is discouraged.

También nos dicen que no debemos testear contra la base de datos de producción, y, aunque es cierto que podemos tener una base de datos de test, ¿quién se encarga de limpiar esa base de datos después? ¿Y qué pasa si otro compañero necesita correr los tests? ¿Tenemos que estar creando a mano la base de datos en cada uno de los entornos donde queramos probar? Al final esto es una pesadilla.

La solución

Para este tipo de cosas se inventó Docker, que al final lo que nos permite es sencillo:

  • Replicar el comportamiento en cualquier entorno,

  • Tener siempre el mismo estado inicial y

  • No dejar rastro tras la ejecución

En base a esto, parece que una de las estrategias más fáciles para testear la base de datos es levantar un contenedor (con la imagen de MySQL por ejemplo), meterle los datos iniciales, ejecutar los tests y borrar el contenedor. Así resolvemos de una sola vez todos los problemas asociados a tener una base de datos para testing en local.

Sin embargo, tener que estar a mano pendiente de levantar el contenedor, borrarlo, etc. puede ser muy incómodo. Y ahí es donde entra la librería TestContainers. La documentación ya para empezar es bastante clara, pero por si acaso vamos a darle un repaso y poner un ejemplo sencillo.

El ejemplo y el porqué

Vamos a suponer que queremos implementar tests para un endpoint de un CRUD muy sencillo de estudiantes. En este caso vamos a tener todos en una única clase, pero podríamos separarlos (por ejemplo, los GET por un lado, los POST por otro, etc.). Además, vamos a utilizar un único contenedor por clase, para que no sean demasiado lentos. Esta combinación de factores nos va a obligar a hacer cierta limpieza o a crear mecanismos que impidan que se produzcan condiciones inesperadas en los tests.

En primer lugar, vamos a crear una fixture para el contenedor. Una fixture no es más que una especie de colección a nivel de clase, que se ejecuta antes que el primer test. Recordemos que el constructor y el destructor de la clase de test se ejecutan antes y después de cada test.

⚠ Nota: es más normal que queramos levantar los contenedores una sola vez para todos los tests. En ese caso, utilizaríamos una CollectionFixture.

Otra cosa que hay que destacar es que la librería tiene algunos contenedores ya "precocinados" que nos quitan trabajo, por ejemplo de MySQL, pero en este ejemplo lo vamos a hacer a mano para que se vea que se puede hacer con cualquier imagen de Docker.

⚠ Nota: si queremos ejecutar un script inicial en la base de datos, el contenedor de MySql ya precocinado nos da esa opción con la función ExecScriptAsync.

El código

Vamos a empezar implementando la fixture. El código pinta así:

using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Containers;

namespace Example.Students.Api.Tests.Shared.MySql;

public class MySqlFixture : IDisposable, IAsyncLifetime
{
    private const string MySqlImageName = "mysql:8.0.34";
    private const string MySqlContainerName = "mysql";
    private const int MySqlContainerPort = 3306;
    private const string MySqlRootPassword = "1234";
    private const string MySqlDatabase = "test";

    private readonly IContainer _mysqlContainer;

    public ContainerInfo ContainerInfo => new(_mysqlContainer.Hostname, _mysqlContainer.GetMappedPublicPort(MySqlContainerPort));
    public MySqlFixture()
    {
        _mysqlContainer = new ContainerBuilder()
            .WithImage(MySqlImageName)
            .WithName(MySqlContainerName)
            .WithPortBinding(port: MySqlContainerPort, assignRandomHostPort: true)
            .WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(MySqlContainerPort))
            .WithEnvironment("MYSQL_ROOT_PASSWORD", MySqlRootPassword)
            .WithEnvironment("MYSQL_DATABASE", MySqlDatabase)
            .Build();
    }

    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this);
    }

    public async Task DisposeAsync()
    {
        await _mysqlContainer.StopAsync();
    }

    public async Task InitializeAsync()
    {
        await _mysqlContainer.StartAsync();
    }

    protected virtual void Dispose(bool disposing)
    {

    }
}

Aquí tenemos muchas cosas pero en realidad son tremendamente fáciles si nos paramos a analizar un poco la clase. El contenedor será una instancia de IContainer, que inicializamos en el constructor. Aquí le pasamos la imagen, los puertos, las variables de entorno, y también le decimos cuando puede considerar que está listo para poder ejecutar los tests. En nuestro caso le decimos que espere hasta que el puerto 3306 (el puerto por defecto de MySQL) esté disponible.

⚠ Nota: no se recomienda bindear los puertos hardcodeados. Ten en cuenta que un puerto podría estar disponible en tu entorno pero no en el de un compañero o en una pipeline. Utiliza la propiedad assignRandomHostPort=true y obtén el puerto con GetMappedPublicPort.

El resto de cosas son simplemente para lanzar, parar y destruir el contenedor cuando toque.

En cuanto a la clase ContainerInfo, no es más que un pequeño value object para poder obtener fácilmente el host y el puerto de un contenedor:

public sealed class ContainerInfo
{
    public string Hostname { get; }

    public int Port { get; }

    public ContainerInfo(string hostname, int port)
    {
        Hostname = hostname;
        Port = port;
    }

    public override string ToString()
    {
        return $"{Hostname}:{Port}";
    }
}

Ahora simplemente utilizamos esa fixture en los tests. Para ello tenemos que hacer que la clase de test implemente la interfaz IClassFixture<MySqlFixture> y podemos obtener la instancia mediante el constructor, quedando así la clase de test (un ejemplo muy simplificado):

namespace Example.Students.Api.Tests.Students;

public sealed class StudentDatabaseTests : BaseIntegrationTest,
    IClassFixture<MySqlFixture>
{

    private readonly DatabaseRepository _repository;

    public DeviceCacheServiceControllerTests(MySqlFixture mysqlFixture) : base(redisFixture.ContainerInfo, mysqlFixture.ContainerInfo)
    {
        _repository = new DatabaseRepository(mysqlFixture.ContainerInfo.ToString());
    }

    [Fact]
    public async Task Get_With_Existing_Item_Returns_Ok()
    {
        // Arrange
        Student student = new StudentBuilder().Build();
        await _repository.Insert(student, default);

        // Act
        var result = await HttpClient.GetAsync($"{BaseEndpoint}/{student.Id}");

        // Assert
        Assert.True(result.IsSuccessStatusCode);

        // Cleanup
        await _repository.Delete(student, default);
    }
}

Evidentemente, aquí estamos asumiendo que las funciones de nuestro repositorio funcionan bien (podríamos utilizar DbContext pero si tenemos buenos tests en el repositorio, a este nivel podemos asumir que funciona bien). Esto va a hacer que solamente se cree un contenedor para TODOS los tests de la clase, no un contenedor por cada test (que yo lo veo muy lento y probablemente innecesario). Hay que tener en cuenta eso al implementar los tests (por eso se hace un Delete al final, para que no interfiera con otros tests de la misma clase).

La clase BaseIntegrationTest no es relevante para este ejemplo, solamente la uso para inicializar el HttpClient (y, por ejemplo, saltarnos la autenticación/autorización, pero eso es cosa para otro artículo).

La clase DatabaseRepository por comodidad hemos hecho que reciba por parámetro la cadena de conexión a la base de datos, podría usar Dapper, EntityFramework o lo que sea. No es relevante nuevamente para este ejemplo implementar dicha clase.

Si ahora lanzamos los tests desde Visual Studio o con el comando dotnet test, vamos a ver que se crea un contenedor de Docker, se realizan los tests y luego se elimina dicho contenedor, con lo cual ya tenemos automatizado todo el proceso.

Otros usos

No solamente para los tests de base de datos viene bien esta librería. En mi caso también la he usado para lanzar tests de integración contra una caché de Redis. Y podrías personalizarlo para lanzar tus propias imágenes (por ejemplo, si estás utilizando microservicios), pero como introducción creo que este artículo ha quedado muy completo y suficientemente bien explicado.