Automatische Integrations- und Systemtests mit Testcontainers

Im letzten Artikel [1] haben wir uns mit automatischen Integrationstests mit ASP.NET Core beschäftigt. In diesem Artikel baue ich darauf auf und ergänze das Thema um eine sehr spannende Variante zum Mocking fremder Services. Die meisten Entwickler kennen das Problem. Sobald man auf der Integrations- oder Systemtestebene testen möchte, muss man sich über die Abhängigkeiten zu anderen Services Gedanken machen. Das fängt bereits bei der Datenbank an und hört dort leider nicht auf. Es geht i. d. R. auch um andere Services, die während des Testens benötigt werden. Zur Lösung des Problems gibt es grundsätzlich zwei Varianten: Man bindet externe Services während des Tests an oder man versteckt sie hinter einer Schnittstelle und baut einen Mock darum. Die erste Variante ist zwar realitätsnäher, hat aber einige gewichtige Nachteile. So ist die Laufzeit von Tests länger, man hängt vom Status eines externen Service ab und mehrere, konkurrierend laufende Tests können zum Problem werden. Mit Testcontainers können wir die meisten dieser Nachteile Streichen. Wie genau erkläre ich in diesem Artikel.

Was sind Testcontainer?

Testcontainer (bzw. „Testcontainers“) ist der Name eines OpenSource Projekts. Die Website des Projekts ist unter [2] zu finden. In kurzen Worten ist Testcontainers ein Wrapper um die API von Docker. Das Projekt verfolgt das Ziel, die Interaktion mit der Docker Engine im Rahmen von Tests deutlich zu vereinfachen. Und genau dort geht die Reise hin. Der Grundgedanke hinter Testcontainers ist es, die für einen Test notwendigen externen Services als Docker Container zu starten. Nach einem Test werden sie entsprechend wieder gestoppt und restlos vom System entfernt. Das hat viele Vorteile: Externe Services starten für den Test so in einem definierten Status (quasi „Initial“), hinterlassen nach dem Test keinen Datenmüll und verhalten sich wie die in der Realität verwendeten Services.

Unser Testobjekt

Auf GitHub unter [3] habe ich das Beispielprojekt für diesen Artikel hochgeladen. Es handelt sich um eine einfache REST-API, welche in ASP.NET Core umgesetzt ist. Sie bietet folgende Endpunkte:

  • GET /Persons (alle Person-Datensätze abrufen)
  • GET /Persone/{personId} (einen bestimmten Person-Datensatz abrufen)
  • POST /Persons (neuen Person-Datensatz anlegen)
  • PUT /Persons (bestehenden Person-Datensatz ändern)
  • DELETE /Persons (bestehenden Person-Datensatz löschen)

Im Hintergrund persistieren wir die Datensätze per Entity Framework Core. Nichts weltbewegendes also.

Integrationstest mit WebApplicationFactory und Testcontainers

Der Integrationstest hat eine ähnliche Struktur wie im letzten Artikel unter [1]. Ich nutze das Framework xUnit und verwende die Klasse WebApplicationFactory, um die ASP.NET Core Application „InMemory“ zu starten. Die Klasse TestApplicationFixture im nachfolgenden Codeausschnitt erbt von WebApplicationFactory und übernimmt damit diese Aufgabe. Der große Unterschied zum letzten Artikel ist im unteren Drittel in der Methode EnsureContainerStarted(). Hier verwenden wir Testcontainers, um eine Instanz von „Azure SQL Edge“ zu starten. Azure SQL Edge ist eine Variante des SQL Servers, die auch auf ARM-Systemen läuft. Mein Laptop ist ein Mac Book Pro, darum verwende ich diese Variante. Ansonsten könnte man jederzeit den echten SQL Server verwenden. Doch das ist Nebensache, was ich mit dem Beispiel zeigen will, ist das Starten eines Containers. Wir konfigurieren Azure SQL Edge über Umgebungsvariablen ab Zeile 46, setzen eine Port-Bindung in Zeile 50 und sagen in Zeile 51, dass beim Start des Containers auf den Port 1433 gewartet werden soll. Abschließend wird in Zeile 56 der ConnectionString zusammengebaut, den die zu testende Applikation (das Testobjekt) verwenden kann, um auf die Datenbank zuzugreifen. Zu beachten ist hier der Aufruf von GetMappedPublicPort(1433). Hier bekommen wir den Port, wie wir auf den Port 1433 des Containers zugreifen können.

public class TestApplicationFixture : WebApplicationFactory<Program>
{
    private static readonly string HostEnvironment = "IntegrationTests";

    private string? _sqlConnectionString;
    private IContainer? _sqlEdgeContainer;
    
    /// <summary>
    /// Cleanup all data in databases.
    /// </summary>
    public async Task CleanupDatabaseAsync()
    {
        if (string.IsNullOrEmpty(_sqlConnectionString))
        {
            throw new InvalidOperationException("Container not started. Create a client before calling this method!");
        }
        
        await using var connection = new SqlConnection(_sqlConnectionString);
        await connection.OpenAsync();

        await using var command = connection.CreateCommand();
        command.CommandText = "DELETE FROM dbo.Persons";
        await command.ExecuteNonQueryAsync();
    }
    
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        this.EnsureContainersStarted();
        
        builder.UseEnvironment(HostEnvironment);
        builder.UseSetting("ConnectionStrings:PersonDb", _sqlConnectionString);
    }

    /// <summary>
    /// Start all required containers for testing the target application.
    /// </summary>
    private void EnsureContainersStarted()
    {
        if (_sqlEdgeContainer != null) { return; }
        
        // Start sql edge container
        var azureSqlTag = ":2.0.0";
        if (OperatingSystem.IsMacOS()) { azureSqlTag = "";}
        _sqlEdgeContainer = new ContainerBuilder()
            .WithImage($"mcr.microsoft.com/azure-sql-edge{azureSqlTag}")
            .WithEnvironment("ACCEPT_EULA", "Y")
            .WithEnvironment("MSSQL_USER", "SA")
            .WithEnvironment("MSSQL_SA_PASSWORD", "MySecret@Password123?")
            .WithEnvironment("MSSQL_PID", "Developer")
            .WithPortBinding(1433, true)
            .WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(1433))
            .Build();
        _sqlEdgeContainer.StartAsync().Wait();

        var sqlPort = _sqlEdgeContainer.GetMappedPublicPort(1433);
        _sqlConnectionString =
            $"Data Source=localhost,{sqlPort};Initial Catalog=HappyCoding_TestingWithContainers;Integrated Security=SSPI;User Id=SA;Password=MySecret@Password123?;Trusted_Connection=False;Encrypt=False;";
    }
}

Die Integrationstests selbst sehen sehr ähnlich aus, wie im letzten Artikel. Nachfolgender Codeausschnitt zeigt ein Beispiel, bei dem wir den Aufruf des Endpunkts GET /Persons prüfen. Die Webapplikation wird im Hintergrund beim Aufruf der CreateClient() Methode in Zeile 10 gestartet. Der Container wird ebenfalls im Hintergrund an dieser Stelle hochgefahren. Im Test selbst werden nun die üblichen Schritte durchgelaufen:

  • Arrange (hier das Zurücksetzen der Datenbank, da der gleiche Container für nachfolgende Tests wiederverwendet wird)
  • Act (Aufruf des Testobjekts / der Webapplikation)
  • Assert (Prüfen des Ergebnisses)
[Collection(nameof(WebApplicationTestCollection))]
public class PersonsControllerTests
{
    private readonly TestApplicationFixture _fixture;
    private readonly HttpClient _httpClient;

    public PersonsControllerTests(TestApplicationFixture fixture)
    {
        _fixture = fixture;
        _httpClient = _fixture.CreateClient();
    }
    
    [Fact]
    public async Task Get_persons_after_startup_returns_empty_result()
    {
        // Arrange
        await _fixture.CleanupDatabaseAsync();
        
        // Act
        var response = await _httpClient.GetAsync("Persons");
        
        // Assert
        Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
    }

    // ...
}

Systemtests mit dem Image unserer Webapplikation

Das Gezeigte lässt sich noch um eine Ebene steigern. Wir haben bisher unser Testobjekt / die Webapplikation mithilfe der WebApplicationFactory gestartet. Die nächste Stufe wäre, gleich das Docker Image aus der eigenen Applikation zu bauen und die Tests gegen einen daraus gestarteten Container laufen zu lassen. Die gute Nachricht: Auch das ist mit Testcontainers möglich. Im Nachfolgenden sehen wir eine alternative Implementierung der Klasse TestApplicationFixture. Wir erben nicht mehr von WebApplicationFactory, stattdessen bauen wir unsere Webapplikation (das Testobjekt) als Image und starten damit einen Container. Die dafür verantwortliche Logik beginnt in Zeile 48. Ein wichtiger Hinweis noch zur Zeile 41 und dem Argument „RESOURCE_REAPER_SESSION_ID“. Hierbei handelt es sich um ein Argument für das Dockerfile, welches insbesondere bei Multi-stage builds als Label an die erzeugten Images gehängt wird. Diese Labels werden von Testcontainers benötigt, um Images nach dem Test auch wieder aufräumen zu können.

public class TestApplicationFixture
{
    private IContainer[]? _containers;

    private string? _sqlConnectionString;
    private string? _sqlConnectionStringFromPublic;
    private string? _applicationBaseUrl;

    public string SqlConnectionString => _sqlConnectionString ?? string.Empty;

    public string ApplicationBaseUrl => _applicationBaseUrl ?? string.Empty;

    public async Task EnsureContainersStartedAsync()
    {
        if (_containers != null)
        {
            await CleanupDatabaseAsync();
            return;
        }

        const string DATABASE_HOST_NAME = "sql_database";

        var dockerNetwork = new NetworkBuilder()
            .Build();

        var azureSqlTag = ":2.0.0";
        if (OperatingSystem.IsMacOS())
        {
            azureSqlTag = "";
        }

        var sqlEdgeContainer = new ContainerBuilder()
            .WithImage($"mcr.microsoft.com/azure-sql-edge{azureSqlTag}")
            .WithNetwork(dockerNetwork)
            .WithNetworkAliases(DATABASE_HOST_NAME)
            .WithEnvironment("ACCEPT_EULA", "Y")
            .WithEnvironment("MSSQL_USER", "SA")
            .WithEnvironment("MSSQL_SA_PASSWORD", "MySecret@Password123?")
            .WithEnvironment("MSSQL_PID", "Developer")
            .WithPortBinding(1433, true)
            .WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(1433))
            .Build();

        _sqlConnectionString =
            $"Data Source={DATABASE_HOST_NAME},1433;Initial Catalog=HappyCoding_TestingWithContainers;Integrated Security=SSPI;User Id=SA;Password=MySecret@Password123?;Trusted_Connection=False;Encrypt=False;";

        var solutionDirectory = TestUtil.GetSolutionDirectory();
        var applicationImage = new ImageFromDockerfileBuilder()
            .WithDockerfileDirectory(solutionDirectory)
            .WithDockerfile("HappyCoding.TestingWithContainers/Dockerfile")
            .WithBuildArgument("RESOURCE_REAPER_SESSION_ID", ResourceReaper.DefaultSessionId.ToString("D"))
            .Build();
        await applicationImage.CreateAsync();

        var applicationContainer = new ContainerBuilder()
            .WithImage(applicationImage)
            .WithNetwork(dockerNetwork)
            .WithPortBinding(80, true)
            .WithEnvironment("Kestrel__Endpoints__Http__Url", "http://+:80")
            .WithEnvironment("ASPNETCORE_URLS", "http://+:80")
            .WithEnvironment("ConnectionStrings__PersonDb", _sqlConnectionString)
            .WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(80))
            .Build();

        // Start all containers
        await sqlEdgeContainer.StartAsync();
        await applicationContainer.StartAsync();

        _sqlConnectionStringFromPublic =
            $"Data Source=localhost,{sqlEdgeContainer.GetMappedPublicPort(1433)};Initial Catalog=HappyCoding_TestingWithContainers;Integrated Security=SSPI;User Id=SA;Password=MySecret@Password123?;Trusted_Connection=False;Encrypt=False;";

        var applicationPort = applicationContainer.GetMappedPublicPort(80);
        _applicationBaseUrl = $"http://localhost:{applicationPort}";

        _containers = new[] { sqlEdgeContainer, applicationContainer };
    }

    public async Task CleanupDatabaseAsync()
    {
        if (string.IsNullOrEmpty(_sqlConnectionStringFromPublic))
        {
            throw new InvalidOperationException("Container not started. Create a client before calling this method!");
        }

        await using var connection = new SqlConnection(_sqlConnectionStringFromPublic);
        await connection.OpenAsync();

        await using var command = connection.CreateCommand();
        command.CommandText = "DELETE FROM dbo.Persons";
        await command.ExecuteNonQueryAsync();
    }
}

Die Tests modifizieren wir ebenfalls (leicht), da wir keine WebApplicationFactory mehr verwenden. Da wir nun keine „CreateClient()“ Methode mehr haben, rufen wir die Methode EnsureContainersStartedAsync() selbst im Arrange Block auf. Die Basis-URL bekommen wir hier als Variable aus der Klasse TestApplicationFixture mit rein, da hier ähnlich wie bei der Datenbank jetzt eine Port-Bindung zum Container dahinter steckt. Jeder Durchlauf des Tests kann also einen anderen Port am Host bekommen.

[Collection(nameof(TestEnvironmentCollection))]
public class PersonApiTests
{
    private readonly TestApplicationFixture _fixture;

    public PersonApiTests(TestApplicationFixture fixture)
    {
        _fixture = fixture;
    }

    [Fact]
    public async Task Get_persons_after_startup_returns_empty_result()
    {
        // Arrange
        await _fixture.EnsureContainersStartedAsync();

        // Act
        var httpClient = new HttpClient();
        var response = await httpClient.GetAsync($"{_fixture.ApplicationBaseUrl}/Persons");

        // Assert
        Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
    }

    // ...
}

Fazit

In diesem Artikel haben wir gesehen, wie mithilfe von Testcontainern mit externen Services getestet werden kann, ohne die meisten typischen Nachteile in Kauf nehmen zu müssen. Tests sind reproduzierbar, wir haben keine Abhängigkeit zu externen Services mehr und selbst konkurrierend laufende Tests wären denkbar. Ein Nachteil aber bleibt: Die Laufzeit der Tests. Die Laufzeit können wir primär damit begegnen, dass wir die gleichen Container für mehrere Tests verwenden. Aus diesem Grund der CleanupDatabaseAsync() Aufruf vor jedem Test (in der SystemTest-Variante steckt der Aufruf innerhalb EnsureContainersStartedAsync()). Bei der SystemTest-Variante entsteht noch der Nachteil, dass die Testabdeckung nicht mehr so einfach analysiert werden kann. Schließlich laufen die Tests hier gegen einen Container und nicht mehr gegen die im gleichen Prozess geladene Applikation. Für mich persönlich überwiegen die Vorteile in den meisten Fällen aber, da man realitätsnahe Tests und damit aussagekräftige Testergebnisse bekommt.

Downloads

  1. Quellcode des Beispiels aus diesem Artikel
    https://www.rolandk.de/files/2023/HappyCoding.TestingWithContainers.zip

Verweise

  1. Automatische Integrationstests mit ASP.NET Core
    https://www.rolandk.de/wp-posts/2023/08/automatische-integrationstests-mit-asp-net-core/
  2. Website des Projekts „Testcontainers“
    https://testcontainers.com/
  3. Quellcode des Beispiels aus diesem Artikel auf GitHub
    https://github.com/RolandKoenig/HappyCoding/tree/main/2023/HappyCoding.TestingWithContainers

Ebenfalls Interessant

  1. MSTest, oder wie ich es verwende
    https://www.rolandk.de/wp-posts/2014/08/mstest-oder-wie-ich-es-verwende/

Schreibe einen Kommentar

Diese Website verwendet Akismet, um Spam zu reduzieren. Erfahre mehr darüber, wie deine Kommentardaten verarbeitet werden.