🐳 Docker

Testcontainers im Homelab: Saubere Integrationstests mit Docker

Testcontainers im Homelab: Saubere Integrationstests mit Docker
⚠️ Hinweis: Alle Guides auf smoth.me dienen ausschließlich zu Informations- und Lernzwecken. Die Umsetzung erfolgt auf eigene Gefahr. Wir übernehmen keine Haftung für Schäden, Datenverluste oder Systemausfälle, die durch die Anwendung dieser Anleitungen entstehen können. → Vollständiger Haftungsausschluss

Moin, liebe smoth.me-Community!

Als jemand, der seit Jahren mit Proxmox, Docker, Home Assistant und dem ganzen Netzwerk-Kram jongliert, weiß ich, wie wertvoll stabile und zuverlässige Systeme sind. Und dazu gehören auch saubere Tests, wenn man eigene Services oder Anwendungen entwickelt. Integrationstests sind dabei oft ein Krampf: Datenbanken manuell starten, Message Queues konfigurieren, externe APIs mocken – das ist fehleranfällig, langsam und macht selten Spaß.

In meiner Erfahrung ist das der Punkt, wo viele aufgeben oder nur noch rudimentäre Tests schreiben. Aber was, wenn ich dir sage, es gibt eine Lösung, die das alles elegant und zuverlässig macht? Genau hier kommt Testcontainers ins Spiel. Dieses Framework hat in den letzten Jahren meinen Ansatz für Integrationstests fundamental verändert, und ich kann es fast uneingeschränkt empfehlen. Es nimmt dir den Schrecken vor aufwendigen Setups und liefert dir Wegwerf-Instanzen von echten Services – alles in Docker-Containern.

Lass uns gemeinsam einen Deep Dive machen und ich zeige dir, wie du Testcontainers in deinem Heimlabor oder deinen Projekten nutzen kannst, um Integrationstests endlich zu lieben.

Was ist Testcontainers überhaupt und warum solltest du es nutzen?

Stell dir vor, du entwickelst eine Anwendung, die mit einer PostgreSQL-Datenbank, einer Redis-Instanz und vielleicht einem Kafka-Broker kommuniziert. Für deine Integrationstests brauchst du diese Services, aber du willst sie nicht auf deinem lokalen Rechner installieren oder gar eine gemeinsame Testumgebung nutzen, wo sich alle in die Quere kommen. Genau hier setzt Testcontainers an.

Testcontainers ist eine Java-Bibliothek (aber auch für Go, Python, Node.js, .NET verfügbar), die es dir ermöglicht, Docker-Container programmgesteuert aus deinen Tests heraus zu starten. Das Besondere daran: Jeder Test bekommt seine eigene, frische Instanz des benötigten Services. Nach dem Test wird der Container automatisch wieder abgeräumt. Das Ergebnis sind:

  • Isolierte Tests: Jeder Test läuft in einer sauberen Umgebung, ohne Abhängigkeiten von vorherigen Tests oder anderen Entwicklern.
  • Echte Services: Du testest nicht gegen Mocks, sondern gegen echte PostgreSQL-, Redis- oder Kafka-Instanzen. Das erhöht die Aussagekraft deiner Tests enorm.
  • Reproduzierbarkeit: Da alles in Containern läuft, sind deine Tests auf jedem System mit Docker reproduzierbar.
  • Einfache Konfiguration: Keine manuellen Installationen oder komplexe Setup-Skripte mehr. Testcontainers übernimmt das Starten und Konfigurieren der Container für dich.

Für uns Heimlabor-Enthusiasten und Admins ist das Gold wert. Wir lieben Docker für seine Portabilität und Isolation, und Testcontainers bringt genau diese Vorteile in die Welt der Softwaretests. Es ist wie ein privates, temporäres Docker Compose für jeden deiner Tests – nur viel smarter.

Voraussetzungen: Was du brauchst, bevor wir starten

Bevor wir loslegen, stellen wir sicher, dass deine Umgebung bereit ist. Keine Sorge, die Liste ist kurz und die meisten von euch werden das meiste davon bereits installiert haben.

  • Docker Desktop oder Docker Engine: Das ist das Herzstück. Testcontainers benötigt einen laufenden Docker-Daemon, um Container starten zu können. Für Windows und macOS ist Docker Desktop die erste Wahl, auf Linux-Servern oder VMs die Docker Engine.

    Wichtig zu wissen: Testcontainers kommuniziert direkt mit dem Docker-Daemon. Es muss also erreichbar sein. Auf Linux oft über einen Unix-Socket, auf Windows/macOS über TCP. Wenn du Docker schon für Proxmox-LXC oder VMs nutzt, ist das meist schon erledigt.

  • Java Development Kit (JDK) 8 oder neuer: Da wir uns hier auf die Java-Implementierung konzentrieren, brauchst du ein JDK. Ich empfehle immer die aktuelle LTS-Version (z.B. OpenJDK 17 oder 21).
  • Maven oder Gradle: Um unser Projekt zu verwalten und Abhängigkeiten hinzuzufügen. Ich werde im Guide Maven verwenden, da es sehr verbreitet ist.
  • Eine IDE deiner Wahl: IntelliJ IDEA, VS Code mit Java-Erweiterungen oder Eclipse sind gut geeignet. Das macht das Schreiben und Ausführen von Tests angenehmer.

Wenn du all das hast, sind wir startklar!

Schritt-für-Schritt: Dein erster Integrationstest mit Testcontainers

Lass uns ein einfaches Szenario durchspielen: Wir haben eine Anwendung, die Daten in einer PostgreSQL-Datenbank speichert. Wir wollen einen Integrationstest schreiben, der sicherstellt, dass unsere Datenbankzugriffe korrekt funktionieren.

1. Projekt aufsetzen

Zuerst erstellen wir ein einfaches Maven-Projekt. Öffne dein Terminal und gib ein:

mvn archetype:generate -DgroupId=me.smoth.testcontainers -DartifactId=my-app -DarchetypeArtifactId=maven-archetype-quickstart -DinteractiveMode=false
cd my-app

Das erstellt ein einfaches Java-Projekt. Nun passen wir die pom.xml an.

2. Testcontainers-Abhängigkeiten hinzufügen

Öffne die Datei pom.xml in deinem Projektverzeichnis. Wir fügen die notwendigen Testcontainers-Abhängigkeiten und einen Datenbanktreiber (hier PostgreSQL) hinzu. Außerdem brauchen wir JUnit 5 für unsere Tests.

<dependencies>
    <!-- JUnit 5 -->
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter-api</artifactId>
        <version>5.10.0</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter-engine</artifactId>
        <version>5.10.0</version>
        <scope>test</scope>
    </dependency>

    <!-- Testcontainers Core -->
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>testcontainers</artifactId>
        <version>1.19.7</version> <!-- Immer die aktuelle Version prüfen! -->
        <scope>test</scope>
    </dependency>
    <!-- Testcontainers Modul für PostgreSQL -->
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>postgresql</artifactId>
        <version>1.19.7</version> <!-- Muss zur Core-Version passen -->
        <scope>test</scope>
    </dependency>

    <!-- PostgreSQL JDBC Driver -->
    <dependency>
        <groupId>org.postgresql</groupId>
        <artifactId>postgresql</artifactId>
        <version>42.7.3</version> <!-- Aktuelle Version verwenden -->
        <scope>test</scope>
    </dependency>
</dependencies>

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-compiler-plugin</artifactId>
            <version>3.11.0</version>
            <configuration>
                <source>17</source> <!-- Oder deine JDK-Version -->
                <target>17</target>
            </configuration>
        </plugin>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-surefire-plugin</artifactId>
            <version>3.1.2</version>
            <configuration>
                <!-- Wichtig für Testcontainers, um Docker-Umgebung zu finden -->
                <argLine>--add-opens java.base/java.lang=ALL-UNNAMED</argLine>
            </configuration>
        </plugin>
    </plugins>
</build>

Mein Tipp: Achte immer darauf, die neuesten stabilen Versionen der Abhängigkeiten zu verwenden. Die Versionen von Testcontainers Core und den spezifischen Modulen (wie postgresql) müssen übereinstimmen!

3. Den Integrationstest schreiben

Jetzt kommt der spannende Teil! Wir schreiben unseren Test. Lösche die Standard-AppTest.java und erstelle eine neue Datei src/test/java/me/smoth/testcontainers/PostgreSqlIntegrationTest.java.

Ich kann hier keinen vollständigen Java-Codeblock einfügen, da der Fokus auf Shell-Befehlen und Konfigurationen liegt. Aber ich erkläre dir genau, wie der Code aussehen würde und was passiert:

// src/test/java/me/smoth/testcontainers/PostgreSqlIntegrationTest.java
package me.smoth.testcontainers;

import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.Statement;

@Testcontainers // Aktiviert die Testcontainers-Integration für JUnit 5
public class PostgreSqlIntegrationTest {

    // Definiert einen PostgreSQLContainer, der vor jedem Test gestartet wird
    @Container
    public static PostgreSQLContainer<?> postgres =
            new PostgreSQLContainer<>("postgres:15.3") // Image-Name und Tag
                    .withDatabaseName("testdb")
                    .withUsername("testuser")
                    .withPassword("testpass");

    @Test
    void testDatabaseConnectionAndQuery() throws Exception {
        // Die JDBC URL, Benutzername und Passwort werden dynamisch vom Container bereitgestellt
        String jdbcUrl = postgres.getJdbcUrl();
        String username = postgres.getUsername();
        String password = postgres.getPassword();

        // Stelle eine Verbindung zur Datenbank her
        try (Connection connection = DriverManager.getConnection(jdbcUrl, username, password)) {
            Assertions.assertTrue(connection.isValid(10), "Verbindung zur Datenbank ist gültig");

            // Führe eine einfache Abfrage aus
            try (Statement statement = connection.createStatement()) {
                statement.execute("CREATE TABLE IF NOT EXISTS users (id SERIAL PRIMARY KEY, name VARCHAR(255))");
                statement.executeUpdate("INSERT INTO users (name) VALUES ('Alice')");
                statement.executeUpdate("INSERT INTO users (name) VALUES ('Bob')");

                ResultSet rs = statement.executeQuery("SELECT count(*) FROM users");
                Assertions.assertTrue(rs.next());
                Assertions.assertEquals(2, rs.getInt(1), "Es sollten 2 Benutzer eingefügt worden sein");
            }
        }
    }

    @Test
    void testAnotherDatabaseQuery() throws Exception {
        // Jeder Test bekommt eine frische DB-Instanz, daher muss die Tabelle erneut erstellt werden
        String jdbcUrl = postgres.getJdbcUrl();
        String username = postgres.getUsername();
        String password = postgres.getPassword();

        try (Connection connection = DriverManager.getConnection(jdbcUrl, username, password)) {
            Assertions.assertTrue(connection.isValid(10), "Verbindung zur Datenbank ist gültig");
            try (Statement statement = connection.createStatement()) {
                statement.execute("CREATE TABLE IF NOT EXISTS products (id SERIAL PRIMARY KEY, name VARCHAR(255))");
                statement.executeUpdate("INSERT INTO products (name) VALUES ('Laptop')");
                
                ResultSet rs = statement.executeQuery("SELECT name FROM products WHERE id = 1");
                Assertions.assertTrue(rs.next());
                Assertions.assertEquals("Laptop", rs.getString("name"), "Produktname sollte 'Laptop' sein");
            }
        }
    }
}

Erklärung des Codes:

  • @Testcontainers: Diese Annotation aktiviert die Testcontainers-Integration für JUnit 5.
  • @Container public static PostgreSQLContainer<?> postgres = ...;: Hier deklarieren wir unseren PostgreSQL-Container.
    • "postgres:15.3": Wir geben das Docker-Image und den Tag an, den Testcontainers verwenden soll.
    • .withDatabaseName("testdb"), .withUsername("testuser"), .withPassword("testpass"): Hier konfigurieren wir die Datenbank-Parameter. Testcontainers weiß, wie es diese an den PostgreSQL-Container übergeben muss.
  • Wenn die Testklasse geladen wird, startet Testcontainers automatisch diesen PostgreSQL-Container.
  • In unseren Testmethoden (@Test) können wir dann über postgres.getJdbcUrl(), postgres.getUsername() und postgres.getPassword() auf die dynamisch generierten Verbindungsdaten zugreifen.
  • Nachdem alle Tests in dieser Klasse gelaufen sind, wird der Container automatisch gestoppt und entfernt. Sauber!

4. Den Test ausführen

Jetzt kommt der Moment der Wahrheit. Speichere die Dateien und gehe zurück ins Terminal im Projektverzeichnis.

mvn clean install

Maven wird jetzt:

  1. Das Projekt kompilieren.
  2. Die Tests ausführen.
  3. Wenn der Test beginnt, wird Testcontainers den PostgreSQL-Container herunterladen (falls noch nicht vorhanden) und starten.
  4. Der Test verbindet sich mit dem Container, führt die SQL-Befehle aus und prüft die Ergebnisse.
  5. Nach dem Test wird der Container wieder gestoppt und entfernt.

Du solltest eine Ausgabe sehen, die anzeigt, dass die Tests erfolgreich waren. Wenn du während des Tests ein docker ps in einem anderen Terminal ausführst, siehst du, wie der PostgreSQL-Container kurz lebt und dann wieder verschwindet.

Das ist die Magie von Testcontainers! Du hast einen echten PostgreSQL-Server in einem Container gestartet, damit interagiert und ihn wieder abgeräumt – alles vollautomatisch aus deinem Test heraus.

Tiefer eintauchen: Häufige Szenarien und erweiterte Nutzung

Das Beispiel mit PostgreSQL ist nur die Spitze des Eisbergs. Testcontainers bietet Module für unzählige Services, darunter:

  • Datenbanken: MySQL, Oracle, MS SQL Server, Neo4j, Couchbase, uvm.
  • Message Queues: Kafka, RabbitMQ, ActiveMQ.
  • Caches: Redis, Memcached.
  • Cloud-Services: LocalStack (für AWS), Azure, Google Cloud.
  • Allgemeine Container: Mit GenericContainer kannst du jedes beliebige Docker-Image starten, auch deine eigenen.

Eigenes Docker-Image testen mit GenericContainer

Nehmen wir an, du hast eine kleine Microservice-Anwendung, die du in einem Dockerfile verpackt hast. Du kannst dieses Image mit GenericContainer starten und testen.

Zuerst ein Beispiel-Dockerfile für unseren "Hello World"-Service (src/main/docker/Dockerfile):

# src/main/docker/Dockerfile
FROM alpine:latest
CMD echo "Hello from my custom service!" && sleep 3600

Dann könntest du es so in deinem Test verwenden:

// In deiner Testklasse
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.utility.DockerImageName;

// ...
@Container
public static GenericContainer<?> customService =
        new GenericContainer<>(DockerImageName.parse("my-custom-service:latest"))
                .withExposedPorts(8080) // Falls dein Service einen Port exponiert
                .withBuildImageFromDockerfile(
                    Paths.get("src/main/docker/Dockerfile"), "my-custom-service:latest"
                );

@Test
void testCustomServiceStarts() throws Exception {
    // Hier könntest du prüfen, ob der Service gestartet ist und ggf. auf Logs prüfen
    // Beispiel: Log enthält "Hello from my custom service!"
    Assertions.assertTrue(customService.isRunning());
    Assertions.assertTrue(customService.getLogs().contains("Hello from my custom service!"));
}

Wichtig zu wissen: Testcontainers kann Dockerfiles bauen (withBuildImageFromDockerfile), was super praktisch ist, wenn dein Service noch nicht als Image in einer Registry liegt.

Häufige Fehler und Lösungen

Wer das zum ersten Mal einrichtet, stolpert oft über ein paar Hürden. Hier sind die häufigsten, die mir begegnet sind, und wie du sie löst:

1. Docker Daemon nicht erreichbar

Fehlerbild: Tests schlagen fehl mit Meldungen wie "Could not connect to Docker daemon" oder "Cannot connect to the Docker daemon at unix:///var/run/docker.sock".

Ursache: Der Docker-Dienst läuft nicht, oder Testcontainers hat keine Berechtigung, darauf zuzugreifen, oder es ist falsch konfiguriert (z.B. falscher Socket-Pfad).

Lösung:

  • Stelle sicher, dass Docker läuft:
    systemctl status docker # Auf Linux
    # Oder prüfe das Docker Desktop Icon
  • Füge deinen Benutzer zur Docker-Gruppe hinzu (Linux):
    sudo usermod -aG docker $USER
    newgrp docker # Oder neu anmelden
  • Überprüfe die Docker-Konfiguration. Testcontainers versucht automatisch, den Daemon zu finden, aber manchmal muss man DOCKER_HOST setzen:
    export DOCKER_HOST=tcp://localhost:2375 # Beispiel für TCP-Zugriff
    # Oder für einen spezifischen Socket
    export DOCKER_HOST=unix:///var/run/docker.sock

2. Container starten zu langsam / Timeout

Fehlerbild: Tests schlagen fehl, weil ein Container nicht innerhalb der erwarteten Zeit gestartet ist ("Container startup failed after X seconds").

Ursache: Langsame Internetverbindung (Image-Download), zu wenige Ressourcen auf dem Host (CPU/RAM), oder der Container braucht tatsächlich länger zum Initialisieren.

Lösung:

  • Ressourcen prüfen: Stelle sicher, dass dein System genügend CPU und RAM für Docker-Container hat. Gerade auf VMs oder älterer Hardware kann das ein Problem sein.
  • Images vorab laden: Lade die benötigten Docker-Images manuell vor dem Testlauf herunter:
    docker pull postgres:15.3
  • Startup-Timeout erhöhen: Du kannst den Timeout für den Container-Start in deinem Java-Code anpassen:
    // Beispiel für PostgreSQLContainer
    new PostgreSQLContainer<>("postgres:15.3")
        .withStartupAttempts(3) // Versucht es 3 mal
        .withStartupTimeout(Duration.ofSeconds(120)); // 120 Sekunden Timeout
  • Wartestrategien anpassen: Testcontainers hat verschiedene WaitStrategys (z.B. auf Logs warten, auf HTTP-Statuscode warten). Manchmal muss man diese anpassen, wenn der Standard nicht passt.

3. Konflikte mit bereits laufenden Services

Fehlerbild: Tests schlagen fehl, weil Ports belegt sind oder Datenbanken nicht sauber initialisiert werden, obwohl Testcontainers gestartet hat.

Ursache: Du hast vielleicht noch eine lokale PostgreSQL-Instanz auf Port 5432 laufen, und obwohl Testcontainers Ports dynamisch mappt, kann es zu Verwechslungen kommen, wenn deine Anwendung nicht die von Testcontainers bereitgestellten dynamischen Ports nutzt. Oder die Datenbank wird nicht sauber geleert.

Lösung:

  • Immer dynamische Ports nutzen: Das ist der Standard und die Empfehlung. Verbinde dich nie mit einem festen Port wie 5432, sondern frage den Port vom Container ab: postgres.getMappedPort(5432). Testcontainers mappt den internen Port 5432 auf einen zufälligen, freien Port auf deinem Host.
  • Datenbank-Schema initialisieren: Für jeden Test eine saubere Datenbank zu haben ist der Idealfall. Wenn du ein komplexeres Schema hast, nutze .withInitScript("init.sql"), um ein SQL-Skript beim Start auszuführen.
  • Container nach dem Test abräumen: Testcontainers macht das automatisch, aber stelle sicher, dass keine alten Container hängen bleiben. Manchmal hilft ein docker system prune -f, um alte, ungenutzte Ressourcen zu entfernen.

Fazit und nächste Schritte

Testcontainers ist ein echtes Game Changer für Integrationstests. Es ermöglicht uns, echte Services in einer isolierten, reproduzierbaren und automatisierten Weise zu testen. Das spart unendlich viel Zeit und Nerven und führt zu deutlich zuverlässigerer Software. Als Homelab-Enthusiast liebe ich es, wie es die Power von Docker direkt in meine Entwicklungs- und Test-Workflows bringt, ohne dass ich mich um komplizierte Setups kümmern muss.

Mein Tipp: Fang klein an, wie in diesem Guide gezeigt, und erweitere dann. Wenn du die Grundlagen verstanden hast, kannst du tiefer in die verschiedenen Module eintauchen. Probiere es mit Redis, Kafka oder sogar LocalStack aus, um deine Cloud-Integrationen zu testen.

Ich kann dir nur raten, Testcontainers eine Chance zu geben. Du wirst feststellen, dass Integrationstests nicht länger ein notwendiges Übel sind, sondern ein mächtiges Werkzeug, das dir Vertrauen in deine Systeme gibt. Happy testing!

Weitere Guides aus "Docker"

Helm im Homelab: Kubernetes-Anwendungen effizient verwalten
Lerne, wie du Kubernetes-Applikationen mit Helm in deinem Homelab oder als Admin effizient verwaltes…
K3s im Homelab: Dein erster Kubernetes-Cluster mit Proxmox
Erfahre, wie du einen schlanken K3s Kubernetes-Cluster in deinem Homelab auf Proxmox-VMs aufsetzt. E…
LGTM im Homelab: Observability mit Grafana, Loki, Tempo, Mimir
Lerne, wie du mit Grafana, Loki, Tempo und Mimir (LGTM) deinen Homelab-Betrieb überwachst, Logs zent…
Super Productivity 18.0 im Homelab mit Docker
Ich zeige dir, wie du Super Productivity 18.0 in deinem Homelab mit Docker betreibst und seine neuen…