In einem vorherigen Blog-Post habe ich gezeigt, wie man mit Hilfe von Testcontainers Integrationstests in einer .NET-Anwendung umsetzen kann.

Möchte man die Docker-Container nicht innerhalb des Test-Codes verwalten, bietet es sich an, die Container unabhängig von der Test-Ausführung zu starten und zu stoppen. Docker Compose und das Azure DevOps-Feature Service-Containers bieten sich hier als Alternativen an.

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

Docker Compose

Docker Compose erlaubt es, mehrere Docker-Container in einer Datei zu definieren und zu verwalten. Diese können dann mit nur einem Befehl gestartet und gestoppt werden. Zudem lassen sich Netzwerke zwischen den Containern definieren, Speicher-Volumes erstellen und Umgebungsvariablen setzen. Für die hier genutzte Demo-Anwendung nutze ich eine MariaDB-Datenbank und einen phpMyAdmin-Container. Etwas verkürzt sieht das entsprechende Docker Compose File so aus (die gesamte Konfiguration findet sich im Github-Repository):

name: "orderapp"

services:
  order-mariadb:
    image: mariadb:11.3
    container_name: order-mariadb
    volumes:
      - order-mariadb:/var/lib/mysql
    networks:
      - order-net
    environment:
      MYSQL_ROOT_PASSWORD: "some-password"
      MYSQL_DATABASE: "orders"

  order-pma:
    image: phpmyadmin
    container_name: order-pma
    ports:
      - "9002:80"
    networks:
      - order-net

networks:
  order-net:

volumes:
  order-mariadb:

Mittels docker compose up bzw. docker compose down startet und stoppt man nun alle Container.

Möchte man einen MariaDB-Container für die Integrations-Tests seiner Anwendung nutzen, gibt es ein paar Dinge zu beachten. Man muss die Datenbank initialisieren und mit entsprechenden Testdaten befüllen. Dies kann man für xUnit etwa mittels Shared Context innerhalb seiner Test-Suite durchführen und vor jeden Test-Run ein Seeding der Datenbank durchführen. Nach dem Test-Run wird die Datenbank dann einfach wieder gelöscht.

public class DatabaseFixture : IAsyncLifetime
{
    private string _databaseName;
    
    public async Task InitializeAsync()
    {
        _databaseName = GetRandomTestDatabaseName();
        await Database.Seed(_databaseName);
    }

    public async Task DisposeAsync()
      => await Database.DeleteDatabase(_databaseName);
}

Alternativ kann man auf eine Funktion des MariaDB-Containers zurückgreifen, die es ermöglicht, beliebiger SQL-Scripte beim Starten des Containers auszuführen. Dazu mountet man einen Ordner in den Container unter /docker-entrypoint-initdb.d. Alle SQL-Dateien in diesem Ordner werden dann beim Starten des Containers ausgeführt.

services:
  order-mariadb:
    image: mariadb:11.3
    volumes:
      - ./initdb:/docker-entrypoint-initdb.d

Neben dem initialen Befüllen der Datenbank muss man nach dem Start des Containers feststellen können, ob der Datenbankserver bereits Anfragen entgegennehmen kann. Für das Ausführen der Integrationstests in einer CI/CD-Pipeline ist dies relevant, damit die Tests erst ausgeführt werden, wenn der Container auch voll funktionsfähig ist. Für Docker-Container bietet sich hier die Option der healthchecks an. MariaDB liefert bereits ein entsprechendes healthcheck.sh-Script mit aus.

services:
  order-mariadb:
    image: mariadb:11.3
    healthcheck:
      interval: 3s
      retries: 3
      test:
        [
          "CMD",
          "healthcheck.sh",
          "--su-mysql",
          "--connect",
          "--innodb_initialized",
        ]
      timeout: 30s

Docker Compose in einer Azure Pipeline

Mit dem zuvor angelegten Docker Compose File kann man die Integrations-Tests lokal und auch in einer Azure DevOps CI/CD-Pipeline ausführen. Zunächst starten wir mittels des DockerCompose@0-Tasks unsere Container:

- task: DockerCompose@0
  displayName: "Docker compose up"
  inputs:
    containerregistrytype: Container Registry
    dockerComposeFile: "./docker/docker-compose.yml"
    action: Run a Docker Compose command
    dockerComposeCommand: "up -d"
    projectName: "orderapp"

Anschliessend muss man noch mittels healthcheck auf die Betriebsbereitschaft des Datenbank-Containers warten.

Führt man seine Azure-Pipeline in einem Container-Job aus, muss man den Datenbank-Container noch mit dem Netzwerk des Pipeline-Containers verbinden. Die Umgebungsvariable $(Agent.ContainerNetwork) enthält den Namen des Netzwerks, in dem der Pipeline-Container läuft.

- task: CmdLine@2
  displayName: "Prepair containers"
  inputs:
    workingDirectory: "./docker"
    script: |
      echo -e "Connect database to network $(Agent.ContainerNetwork) ...\n"
      docker network connect $(Agent.ContainerNetwork) order-mariadb
      
      echo -e "Waiting for container to be healthy ...\n"
      until [ "$(docker inspect -f '{{.State.Health.Status}}' order-mariadb)" == "healthy" ]; do
        sleep 1
      done

Anschliessend können die Tests ausgeführt werden. Da die Konfiguration der Datenbankverbindung in der CI-Pipeline anders ist als auf dem lokalen PC oder in der Produktivumgebung, kann man diese etwa über ein .runsettings-File entsprechend setzen.

<RunSettings>
  <RunConfiguration>
      <EnvironmentVariables>
          <DB_SERVER>order-mariadb</DB_SERVER>
          <DB_PORT>3306</DB_PORT>
          <DB_NAME>orders</DB_NAME>
          <DB_USER>root</DB_USER>
          <DB_PASSWORD>some-password</DB_PASSWORD>
      </EnvironmentVariables>
  </RunConfiguration>
</RunSettings>

In dem DotNetCoreCLI@2-Task gibt man das File dann per Argument an.

- task: DotNetCoreCLI@2
  displayName: "Dotnet test"
  inputs:
    command: test
    arguments: "--settings ./src/ci-tests.runsettings"

Nach dem Test-Run werden die Container wieder gestoppt.

- task: DockerCompose@0
  displayName: "Docker compose down"
  condition: always()
  inputs:
    containerregistrytype: Container Registry
    dockerComposeFile: "./docker/docker-compose.yml"
    currentWorkingDirectory: "./docker"
    action: Run a Docker Compose command
    dockerComposeCommand: "down -v"
    projectName: "orderapp"

Azure DevOps Service-Containers

Als Alternative zum Aufsetzen und Starten von Docker-Containern mittels Docker@2- oder DockerCompose@0-Task bietet Microsoft die Möglichkeit, Container-Ressourcen als Service-Container innerhalb einer Pipeline bereitzustellen. Diese Container laufen parallel zu den Build- und Test-Jobs und können von diesen angesprochen werden. Damit eignen sich auch Service-Container zum Bereitstellen von etwa Datenbanken für Integrationstests.

Die Konfiguration der Service-Container erfolgt direkt in der .yaml-Datei der Pipeline-Definition; Für klassische Azure Pipelines steht das Feature nicht zur Verfügung. Die Syntax ähnelt sehr der von Docker Compose, wer sich hiermit auskennt, wird sich schnell zurechtfinden.

Container werden unter dem Abschnitt resources definiert und anschließend unter services exponiert (Container-Resources können auch für Container Jobs genutzt werden).

resources:
  containers:
    - container: order-mariadb
      image: mariadb:11.3
      ports:
        - 3306:3306
      env:
        MYSQL_ROOT_PASSWORD: "some-password"
        MYSQL_DATABASE: "orders"

services:
  order-mariadb: order-mariadb

Damit ist der MariaDB-Server auch schon unter der Adresse localhost:3306 erreichbar.

Nutzt man zur lokalen Entwicklung keine Docker-Container, deren Konfiguration für Integrationstests innerhalb der CI-Pipeline wiederverwenden kann, bieten Service-Container eine einfache Möglichkeit, die benötigten Ressourcen bereitzustellen.