Bild von Stefan Lieser
Stefan Lieser

Integrationstests mit Docker

Update 29.01.2024: Ich habe den Beispielcode auf .NET Core 8 und die jeweils aktuellen NuGet Pakete angepasst. Ferner liegt der Code jetzt bei GitLab statt bei GitHub.

Sind Integrationstests sinnvoll?

Immer wieder steht in unseren Trainings die Frage im Raum, ob Integrationstests sinnvoll oder sogar notwendig sind. Unsere klare Antwort ist: Ja, Integrationstests gehören zu einer gesunden Teststrategie unbedingt dazu. Und Integrationstests mit Docker sind eine ganz einfache Lösung für viele Herausforderungen.

Definition

Mit Integrationstest bezeichnen wir einen Test, der eine Klasse oder Methode inklusive ihrer Abhängigkeiten testet. Dabei werden zunächst keine Attrappen (engl. Mocks) eingesetzt, sondern die realen Abhängigkeiten werden verwendet. Der Grund: Wenn in einem Test die Abhängigkeiten weg gemockt werden, wissen wir am Ende immer noch nicht, ob die Integration der einzelnen Bestandteile erfolgreich verläuft. Es ist also zwingend erforderlich, echte Integrationstests gegen die echten Abhängigkeiten zu schreiben. Spätestens an dieser Stelle kommt das große „Aber“: aber ich kann doch im Test nicht die Datenbank ansprechen. Doch! Genau darum geht es. Es muss eine gewisse Menge an Tests geben, die gegen die auch in Produktion verwendete Datenbank ablaufen. Auch das Ersetzen der real verwendeten Datenbanktechnologie durch eine In-Memory Lösung entspricht nicht dem Konzept eines Integrationstests.

Baumstruktur Integrationstest
Integrationstest

Strategie

Unsere klare Empfehlung lautet: Beginne bei den Integrationstests mit Tests, welche die realen Abhängigkeiten mit testen. Wenn davon eine gewisse Menge vorhanden ist und die Zuversicht steigt, dass die Integration erfolgreich verläuft, spricht grundsätzlich nichts dagegen, Tests gegen Attrappen zu ergänzen. Dies sollte aber erst der zweite Schritt sein. Solange die echten Integrationstests schnell ablaufen und leicht zu erstellen sind, spricht auch nichts dagegen, auf Attrappen in Integrationstests komplett zu verzichten.

Und schon höre ich das nächste „Aber“: aber wie soll ich denn für jeden Test die Datenbank mit definierten Ausgangswerten bereitstellen? Mit Docker! Übrigens verwende ich „Datenbank“ hier stellvertretend für Ressourcenzugriffe. Ob das Softwaresystem nun tatsächlich eine Datenbank benötigt oder andere Ressourcen wie das Dateisystem, Messaging Systeme, etc. spielt für die weitere Betrachtung eine untergeordnete Rolle.

Container to the rescue

Integrationstests mit Docker

Es gibt einen sehr eleganten Weg, benötigte Infrastruktur wie Datenbanken, Messaging Lösungen, o.ä. im Test und der Produktion bereitzustellen: Docker Container. Benötigt eine Anwendung bspw. eine MongoDB Datenbank, kann diese mittels Docker sehr leicht zur Verfügung gestellt werden. Dies geht nicht nur in der Produktion sondern auch bei Tests. Dabei hilft das Projekt TestContainers.

Dieses Projekt stellt Klassen bereit, mit denen Docker Container im Test ganz leicht gestartet werden können. Für .NET kann dazu das NuGet Paket Testcontainers dem Testprojekt hinzugefügt werden.

Beispiel

Unter folgendem Link findest Du ein GitLab Repo mit einem Beispielprojekt, welches die hier vorgestellten Ideen demonstriert. Der hier gezeigt Code stammt aus diesem Beispiel.

https://gitlab.com/ccd-akademie-gmbh/testcontainers

Diese Beispielanwendung stellt über HTTP Kundendaten bereit, die aus einer MongoDB Datenbank kommen. Ferner können neue Kundendaten dort abgelegt werden. Es handelt sich um ein konstruiertes Beispiel, an dem die Vorgehensweise für Integrationstests mit Test Containern gezeigt werden soll. Daher bitte vor allem auf den Testcode schauen und weniger auf die Implementation.

Clean Code Trainings

Geschlossene Firmenkurse

Wir führen alle Seminare als geschlossene Firmenkurse für Sie durch.

Bei Interesse oder Fragen kontaktieren Sie uns gerne.

Basis für Integrationstests mit Docker

Ich habe für die Integrationstests eine Basisklasse erstellt, die einen MongoDB Container jeweils vor einem Test startet und nach dem Test wieder beendet:

				
					using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Containers;
using NUnit.Framework;

namespace testcontainerexample.tests;

public class IntegrationTestsBase
{
    private IContainer? _testcontainers;

    private readonly int _mongoDbPort = 27017;

    [SetUp]
    public async Task Setup() {
        Config.Load(".env");
        var containerBuilder = new ContainerBuilder()
            .WithImage("docker.io/mongo")
            .WithPortBinding(_mongoDbPort, assignRandomHostPort: true)
            .WithEnvironment("MONGO_INITDB_ROOT_USERNAME", "test")
            .WithEnvironment("MONGO_INITDB_ROOT_PASSWORD", "test")
            .WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(_mongoDbPort));

        _testcontainers = containerBuilder.Build();
        await _testcontainers.StartAsync();
        Config.MongoDb.Port = _testcontainers.GetMappedPublicPort(_mongoDbPort);
        Config.MongoDb.Host = _testcontainers.Hostname;
    }

    [TearDown]
    public async Task TeardownBase() {
        await _testcontainers!.StopAsync();
        await _testcontainers.DisposeAsync();
    }
}
				
			

Das NUnit Attribut SetUp führt dazu, dass die so markierte Methode vor jedem Test ausgeführt wird. Gleiches gilt für TearDown: die so attributierte Methode wird nach jedem Test ausgeführt. Im Ergebnis führt oben dargestellter Code dazu, dass jeweils pro Test ein Docker Container mit dem „mongo“ Image bereitgestellt wird. Nach dem Durchlauf jeder Testmethode wird der Container wieder verworfen. So wird eine maximale Testisolation erreicht. Jeder einzelne Test läuft auf einer „frischen“ Instanz von MongoDB.

Das bedeutet allerdings auch, dass die Datenbank jeweils komplett leer ist. Soll ein Test bestehende Daten aus der Datenbank lesen, müssen diese zuvor in die DB geschrieben werden. Es folgen später Beispiele dazu.

Neben MongoDB stehen viele weitere fertige Testcontainer zur Verfügung, bspw. für

  • MS SQL Server
  • MySQL
  • Postgres
  • RabbitMQ
  • Kafka
Letztlich kann jedes beliebige Docker Image verwendet werden. Insbesondere auch eigene Images, die dann bspw. bereits Testdaten enhalten.

ASP.NET Core 8 Integrationstests

Für die Integrationstests der ASP.NET Controller ist es erforderlich, das NuGet Paket Microsoft.AspNetCore.Mvc.Testing einzubinden. Damit können die Controller über HTTP getestet werden, ohne dafür durch den Browser zu laufen. Es wird also die komplette Logik getestet, die innerhalb von ASP.NET zum Aufruf einer Controller Methode führt. Dazu wird die Anwendung gestartet. Damit das funktioniert, muss der Klasse WebApplicationFactory die Program Klasse als Typ zur Verfügung gestellt werden.

				
					_webAppFactory = new WebApplicationFactory<Program>();
_httpClient = _webAppFactory.CreateDefaultClient();
				
			

Neumodischer Kram

Und hier gibt es einen kleinen Fallstrick: neuerdings muss die Main Methode nicht mehr explizit in eine Klassendefinition eingefasst werden. Der Compiler macht dies implizit. Allerdings ist dann die Klasse Program implizit internal. Damit die Program Klasse im Testprojekt sichtbar wird, ist ein kleiner Trick erforderlich: füge eine öffentliche Partial Class hinzu. Alternativ kann man die Klasse wie gewohnt implementieren. Rider bietet auch eine Option zur Konvertierung an.

				
					public partial class Program { }
				
			

Tests

Nach diesen Vorbereitungen können nun Integrationstests geschrieben werden, die eine reale MongoDB Instanz vorfinden.

				
					using System.Text;
using Microsoft.AspNetCore.Mvc.Testing;
using Newtonsoft.Json;
using NUnit.Framework;
using testcontainerexample.providers;

namespace testcontainerexample.tests;

[TestFixture]
public class CustomerControllerTests : IntegrationTestsBase
{
    private WebApplicationFactory<Program> _webAppFactory = null!;
    private HttpClient _httpClient = null!;

    [SetUp]
    public void Setup2() {
        _webAppFactory = new WebApplicationFactory<Program>();
        _httpClient = _webAppFactory.CreateDefaultClient();
    }

    [TearDown]
    public async Task Teardown() {
        await _webAppFactory.DisposeAsync();
        _httpClient.Dispose();
    }
    
    [Test]
    public async Task Get_all_on_empty_database_returns_empty_result() {
        var response = await _httpClient.GetAsync("customer");
        var stringResult = await response.Content.ReadAsStringAsync();

        Assert.That(stringResult, Is.EqualTo("[]"));
    }

    [Test]
    public async Task Customers_can_be_added_to_database() {
        var content = JsonConvert.SerializeObject(TestData.Customers.Customer1);
        var stringContent = new StringContent(content, Encoding.UTF8, "application/json");

        var response = await _httpClient.PostAsync("customer", stringContent);
        var stringResult = await response.Content.ReadAsStringAsync();

        Assert.That(stringResult, Is.EqualTo("Success. Id = 0001"));
    }

    [Test]
    public async Task Added_customers_can_be_retrieved() {
        var customerProvider = new CustomerProvider();
        await customerProvider.Add(TestData.Customers.Customer1);
        await customerProvider.Add(TestData.Customers.Customer2);

        var response = await _httpClient.GetAsync("customer");
        var stringResult = await response.Content.ReadAsStringAsync();

        Assert.That(stringResult, Is.EqualTo("[{\"id\":\"0001\",\"name\":\"Peter\"},{\"id\":\"0002\",\"name\":\"Paul\"}]"));
    }
}
				
			

Implementation des Controllers

Für dieses Beispiel wird ein sehr simpler Controller verwendet:

				
					using Microsoft.AspNetCore.Mvc;
using testcontainerexample.contracts;
using testcontainerexample.domain;

namespace testcontainerexample.Controllers;

[ApiController]
[Route("[controller]")]
public class CustomerController : ControllerBase
{
    private readonly ILogger<CustomerController> _logger;
    private readonly Interactors _interactors;

    public CustomerController(ILogger<CustomerController> logger, Interactors interactors) {
        _logger = logger;
        _interactors = interactors;
    }
    
    [HttpGet]
    public Task<List<Customer>> Get() {
        _logger.LogDebug("Get all customers");
        return _interactors.GetAllCustomers();
    }

    [HttpPost]
    public async Task<string> Post([FromBody] Customer customer) {
        _logger.LogDebug("Post customer '{Name}'", customer.Name);
        var customerId = await _interactors.AddCustomer(customer);
        return $"Success. Id = {customerId}";
    }
}
				
			

Die Klasse Interactors ist ebenfalls sehr einfach. Sie leitet die Aufrufe lediglich an die Klasse CustomerProvider weiter.

				
					using testcontainerexample.contracts;
using testcontainerexample.providers;

namespace testcontainerexample.domain;

public class Interactors
{
    private readonly CustomerProvider _customerProvider;

    public Interactors(CustomerProvider customerProvider) {
        _customerProvider = customerProvider;
    }

    public Task<List<Customer>> GetAllCustomers() {
        return _customerProvider.GetAll();
    }

    public async Task<string> AddCustomer(Customer customer) {
        return await _customerProvider.Add(customer);
    }
}
				
			
				
					using MongoDB.Driver;
using testcontainerexample.contracts;

namespace testcontainerexample.providers;

public class CustomerProvider : MongoDbProviderBase
{
    private const string CustomersCollection = "customers";

    public async Task<List<Customer>> GetAll() {
        var seminars = _database.GetCollection<Customer>(CustomersCollection);
        var asyncCursor = await seminars.FindAsync(_ => true);
        var result = await asyncCursor.ToListAsync();
        return result;
    }

    public async Task<string> Add(Customer customer) {
        var customers = _database.GetCollection<Customer>(CustomersCollection);
        await customers.InsertOneAsync(customer);
        return customer.Id;
    }
}
				
			

Weiterführende Artikel

Im Artikel TDD vs. Test-first findest Du eine Definition der verschiedenen Testkategorien sowie Hinweise zur Testpyramide.

In unserem Training Clean Code Developer Tests erfährst du weitere Details zum Thema Testen. Wenn du daran interessiert bist zu erfahren, wie eine Anwendung aufgebaut sein sollte, damit sie leicht automatisiert getestet werden kann, sind die Seminare Clean Code Developer Basics und Clean Code Developer Advanced das Richtige für Dich.

Fazit

Integrationstests sind ein wesentlicher Bestandteil einer intakten Teststrategie. Wer nur auf Unit Tests der kleinsten Einheiten setzt, erhält keine Rückmeldung darüber, ob das Zusammenwirken dieser kleinen Einheiten korrekt funktioniert. Auch das Ersetzen aller Abhängigkeiten durch Attrappen verhindert echte Integrationstests.

Im Falle von Legacy Code sind Integrationstests eine wesentliche Strategie, um den Code überhaupt unter Tests stellen zu können. Hier sind Unit Tests oft nicht möglich, weil die kleinsten Einheiten häufig zu groß sind und zu viele Verantwortlichkeiten haben. Integrationstests sind also auf der grünen Wiese und im Brownfield essentiell.

Unsere Seminare

course
Clean Code Developer Basics

Prinzipien und Tests – Das Seminar wendet sich an Softwareentwickler, die gerade beginnen, sich mit dem Thema Softwarequalität auseinanderzusetzen. Es werden die wichtigsten Prinzipien und Praktiken der Clean Code Developer Initiative vermittelt.

zum Seminar »
course
Clean Code Developer Advanced

Mit Flow Design von den Anforderungen zum Clean Code – Lernen Sie mit Flow Design einen Softwareentwicklungsprozess kennen, der Sie flüssig von den Anforderungen zum Clean Code führt.

zum Seminar »
course
Clean Code Developer Trainer

Seminare als Trainer durchführen – Dieses Seminar wendet sich an Softwareentwickler, die ihr Wissen über die Clean Code Developer Prinzipien und Praktiken bzw. über Flow Design als Trainer an andere weitergeben möchten.

zum Seminar »
course
Clean Code Developer CoWorking

Online CoWorking inkl. Coaching –
Wir werden häufig gefragt, was man als Entwickler tun könne, um kontinuierlich dran zu bleiben am Thema Clean Code Developer. Unsere Antwort: Treffen Sie sich regelmäßig wöchentlich online mit anderen Clean Code Developern.

zum Seminar »

Kommentar verfassen

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert

de_DEGerman