In der Software-Entwicklung ist es essentiell, den geschriebenen Code ausreichend zu testen. Neben Unit-Tests, die einzelne Komponenten isoliert prüfen, betrachten Integrationstests das Zusammenspiel mehrerer Komponenten. Hat man nun aber externe Abhängigkeiten wie z.B. einen Datenbank oder einen Message Broker, wird das Testen komplexer.

Damit die externen Systeme auch in den Integrations-Tests reproduzierbar und vorhersehbar zur Verfügung stehen, kann man sie in Docker-Containern ausführen. Ein einfaches Setup solcher Service-Container ermöglicht die Bibliothek Testcontainers.

Der Beispielcode zu diesem Blog-Post ist auf Github verfügbar: https://github.com/davull/demo-docker-testcontainers

In einem anschließenden Blog-Post zeige ich, wie man Integrationstests mit Hilfe von Docker Compose und Azure Service-Containers umsetzen kann.

Integrationstests

Integrationstest sollen das Zusammenspiel von mehreren Komponenten eines Softwaresystems prüfen. Dies kann einzelne funktionale Einheiten betreffen aber auch einen gesamten Durchstich durch die Anwendung. So kann man für eine Web-API etwa einen HTTP-Aufruf an die Anwendung schicken, Daten in einer Datenbank manipulieren und das in einem Redis-Cache zwischengespeicherte Ergebnis wieder zurück an den Aufrufer liefern.

Das Verhalten von externen Systeme kann man nun zwar mittels Mocks versuchen nachzubilden, einen wirklichen Mehrwert liefern Integrationstests aber erst dann, wenn sie auch mit echten Systemen interagieren. Damit bewegt man sich näher an einer Produktivumgebung und kann so auch Probleme finden, die für Mocks nicht sichtbar wären.

Die meisten Systeme kann man mittlerweile in einem Docker-Container betreiben. Sollte mal kein vorkonfiguriertes Image zur Verfügung stehen, ist es zumeist einfach möglich, ein eigenes zu erstellen.

Diese Containerisierung von Anwendungen kann man sich beim Testen zu Nutze machen und vor jeden Testdurchlauf, etwas in einer CI/CD-Pipeline, die benötigten Systeme mit einem definierten Zustand starten. Nach dem Ausführen der Tests werden die Container einfach wieder gestoppt und gelöscht.

Testcontainers

Das Projekt Testcontainers bietet für viele Programmiersprachen Bibliotheken an, um das Aufsetzen und Ausführen von Docker Containern in Tests zu vereinfachen. Für .NET steht ein entsprechendes Nuget-Paket zur Verfügung: Testcontainers for .NET.

Ein Testcontainer wird einfach vor der Ausführung der Test-Methoden definiert und gestartet und danach wieder gestoppt und gelöscht. Die ContainerBuilder-Klasse bietet eine Fluent-API, um die Container zu konfigurieren.

var container = new ContainerBuilder()
    .WithImage("mariadb:11.3")
    .WithHostname("mariadb-test-container")
    .Build();

await container.StartAsync();

// run integration tests

await container.StopAsync();

Um die Tests lokal ausführen zu können, muss Docker auf dem Rechner installiert sein und der Docker-Dienst laufen.

Best practice ist es, Container-Ports auf zufällige Host-Ports zu mappen, um Konflikte mit anderen laufenden Services und Containern zu vermeiden.

var containerBuilder = new ContainerBuilder()
    .WithPortBinding(3306, assignRandomHostPort: true);

var port = container.GetMappedPublicPort(3306);

Um nach dem Start auf die Verfügbarkeit eines Dienstes innerhalb des Containers zu warten, stellt Testcontainers eine Reihe von Wait-Strategien zur Verfügung. So kann man etwa auf den Health-Status des Containers warten oder aber einen beliebigen TCP-Port auf Erreichbarkeit prüfen. Auch das Warten auf einen HTTP-Endpunkt ist möglich.

// Wait for MySQL to be ready
var containerBuilder = new ContainerBuilder()
    .WithImage("mariadb:11.3")
    .WithWaitStrategy(Wait.ForUnixContainer()
        .UntilPortIsAvailable(3306));

// Wait for HTTP service to be ready
var containerBuilder = new ContainerBuilder()
    .WithImage("productservice:latest")
    .WithWaitStrategy(Wait.ForUnixContainer()
        .UntilHttpRequestIsSucceeded(strategy => strategy
            .ForPort(443)
            .ForPath("/api/products")
            .WithBasicAuthentication("user", "password")));

Viele Container-Images lassen sich per Umgebungsvariablen konfigurieren. Einem Testcontainer kann man diese einfach über die Methode WithEnvironment(env) mitgeben.

var env = new Dictionary<string, string>
{
    { "MYSQL_ROOT_PASSWORD", "some-password" },
    { "MYSQL_DATABASE", "products" }
};

var containerBuilder = new ContainerBuilder()
    .WithEnvironment(env);

Vorkonfigurierte Container

Testcontainers bietet eine Reihe von vorkonfigurierten Containern an, die die Nutzung unterschiedlicher Services vereinfacht. Hier entfallen dann etwa die Angabe des Images oder ein Port-Mapping. Ein MySqlContainer bietet direkt die Möglichkeit, den passenden Connection-String für die Datenbank zu erhalten.

var container = new MySqlBuilder().Build();
var connectionString = container.GetConnectionString();

Test-Fixtures

Um die Konfiguration von Testcontainern zu zentralisieren und nicht für jeden Aufruf einer Test-Methode einen neuen Container zu erzeugen und zu starten, kann man die Möglichkeiten der unterschiedlichen Test-Frameworks nutzen, sogenannte Test-Fixtures zu erstellen.

Für xUnit kann man etwa eine Klasse DatabaseFixture erstellen, welche das Initialisieren und Starten des Containers übernimmt. Eine Klasse, die das Interface ICollectionFixture<DatabaseFixture> implementiert, kann dann steuern, welche Tests sich eine Container-Instanz teilen.

public class DatabaseFixture : IAsyncLifetime
{
    public async Task InitializeAsync()
    {
        _container = BuildContainer();
        await _container.StartAsync();
    }

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

    // ...
}

[CollectionDefinition(Name)]
public class DatabaseCollectionFixture : ICollectionFixture<DatabaseFixture>
{
    public const string Name = "DatabaseCollection";
}

Die entsprechenden Tests annotiert man mit dem Collection-Attribut und dem Collection-Namen, das den Fixture-Container bereitstellt.

[Collection(DatabaseCollectionFixture.Name)]
public class DatabaseTests
{
    // ...
}

Das Projekt in dem Github-Repository zu diesem Post zeigt ein vollständiges Beispiel: https://github.com/davull/demo-docker-testcontainers

CI/CD-Pipeline

Testcontainers lassen sich in einer CI/CD-Pipeline auf die gleiche Art ausführen wie lokal. Voraussetzung ist lediglich, dass auf dem Build Server ebenfalls Docker und die Docker CLI installiert sind. Im Falle von Azure DevOps ist dies für von Microsoft gehosteten Build-Agents bereits der Fall. Nutzt man eigene Build-Container, lässt sich die Docker CLI über den Task DockerInstaller@0 installieren.

- task: DockerInstaller@0
  displayName: "Install docker cli"
  inputs:
    dockerVersion: 26.1.0

Beim Ausführen des Test-Steps werden dann die benötigten Images heruntergeladen, falls noch nicht vorhanden, und die Container gestartet.

Azure DevOps Output