Testcontainers: Integrationstests mit echten Datenbanken
Zurück zum Blog

Testcontainers: Integrationstests mit echten Datenbanken

6 Min. Lesezeit
Lesen auf English

Das Problem mit H2

Wir bei encircle360 setzen seit Jahren auf Spring Boot für unsere Backend-Services -- das haben wir bereits in unseren früheren Beiträgen zu Spring Boot und Spring Boot 2.0 ausführlich beschrieben. Und wie in den meisten Spring-Boot-Projekten üblich, haben wir lange Zeit H2 als In-Memory-Datenbank für unsere Integrationstests verwendet. Die Idee klingt bestechend: H2 startet schnell, braucht keine externe Infrastruktur und läuft überall. In der Praxis hat uns dieses Setup jedoch immer wieder Probleme bereitet.

Das Kernproblem ist simpel: H2 ist nicht PostgreSQL. Und wenn die Produktionsdatenbank PostgreSQL ist, testet man mit H2 gegen eine andere Datenbank als die, die in Produktion läuft. Die SQL-Dialekte unterscheiden sich. Features wie JSONB-Spalten, ON CONFLICT-Klauseln, spezifische Index-Typen oder Window Functions verhalten sich in H2 anders oder existieren schlicht nicht. Constraint-Validierung, Transaktionsisolation, Typkonvertierungen -- überall lauern subtile Unterschiede.

Das Ergebnis: Tests, die grün sind, aber Code validieren, der in Produktion scheitert. Oder umgekehrt: Tests, die fehlschlagen, obwohl der Code korrekt ist, weil H2 eine SQL-Syntax nicht unterstützt, die PostgreSQL problemlos akzeptiert. Wer Flyway oder Liquibase für Datenbankmigrationen einsetzt, kennt das Problem noch verschärft -- Migrationsskripte, die für PostgreSQL geschrieben sind, müssen für H2 angepasst oder im Kompatibilitätsmodus ausgeführt werden. Das untergräbt den eigentlichen Zweck der Tests.

Testcontainers: Die echte Datenbank im Test

Testcontainers löst dieses Problem auf elegante Weise. Die Java-Bibliothek startet vor den Tests einen Docker-Container mit der echten Datenbank -- PostgreSQL, MySQL, MariaDB, MongoDB, Redis, Kafka oder was auch immer der Service in Produktion nutzt. Der Container ist kurzlebig: Er wird vor den Tests gestartet und danach wieder entfernt. Jeder Testlauf beginnt mit einer sauberen Instanz.

Das Projekt existiert seit 2015 und ist mittlerweile gut etabliert. Die aktuelle Version zum Zeitpunkt dieses Artikels liegt bei 1.17.x, die Dokumentation ist solide, und die Community wächst stetig. Für uns war der Umstieg überfällig.

Die Voraussetzung ist simpel: Docker muss auf dem Rechner installiert sein. Da wir ohnehin Docker für unsere lokale Entwicklung und in unseren CI-Pipelines einsetzen, war das keine Hürde. Es müssen keine Datenbanken lokal installiert oder konfiguriert werden -- Docker übernimmt alles.

Setup mit Gradle und Spring Boot

Die Integration in ein bestehendes Spring-Boot-Projekt mit Gradle -- unserem Build-Tool der Wahl, wie wir in unserem Beitrag zu Gradle beschrieben haben -- ist unkompliziert. Man fügt die Testcontainers-Abhängigkeiten zur build.gradle hinzu:

dependencies {
    testImplementation 'org.testcontainers:testcontainers:1.17.3'
    testImplementation 'org.testcontainers:junit-jupiter:1.17.3'
    testImplementation 'org.testcontainers:postgresql:1.17.3'
}

Das junit-jupiter-Modul bietet die JUnit-5-Integration, das postgresql-Modul den speziell vorkonfigurierten PostgreSQL-Container. Für andere Datenbanken gibt es entsprechende Module -- MySQL, MariaDB, Oracle, MSSQL und viele weitere.

JUnit 5: @Testcontainers und @Container

Die Integration mit JUnit 5 ist durchdacht und minimal-invasiv. Zwei Annotationen genügen, um den Container-Lebenszyklus an den Test-Lebenszyklus zu koppeln.

@Testcontainers auf der Testklasse aktiviert die Testcontainers-Extension für JUnit 5. Sie kümmert sich darum, dass mit @Container annotierte Felder vor den Tests gestartet und danach gestoppt werden.

@Container markiert das Container-Feld. Ist das Feld static, wird der Container einmal pro Testklasse gestartet (Shared Container). Ist es nicht-statisch, startet für jede Testmethode ein frischer Container. Für die meisten Integrationstests ist ein Shared Container pro Klasse der sinnvolle Kompromiss zwischen Isolation und Geschwindigkeit.

Hier ist ein vollständiges Beispiel für eine Spring-Boot-Testklasse mit PostgreSQL:

@SpringBootTest
@Testcontainers
class OrderRepositoryIntegrationTest {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:14-alpine")
            .withDatabaseName("testdb")
            .withUsername("test")
            .withPassword("test");

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
    }

    @Autowired
    private OrderRepository orderRepository;

    @Test
    void shouldPersistAndRetrieveOrder() {
        Order order = new Order();
        order.setCustomerId("C-123");
        order.setStatus(OrderStatus.PENDING);
        order.setCreatedAt(LocalDateTime.now());

        Order saved = orderRepository.save(order);

        assertThat(saved.getId()).isNotNull();
        assertThat(orderRepository.findById(saved.getId()))
                .isPresent()
                .get()
                .extracting(Order::getCustomerId)
                .isEqualTo("C-123");
    }

    @Test
    void shouldHandleJsonbColumn() {
        Order order = new Order();
        order.setCustomerId("C-456");
        order.setMetadata("{\"source\": \"api\", \"priority\": \"high\"}");

        Order saved = orderRepository.save(order);

        // Dieser Test würde mit H2 fehlschlagen, weil JSONB nicht unterstützt wird
        assertThat(orderRepository.findByMetadataContaining("api"))
                .hasSize(1);
    }
}

DynamicPropertySource: Die Brücke zu Spring

Das Schlüsselelement ist @DynamicPropertySource. Diese seit Spring Boot 2.2.6 verfügbare Annotation löst ein zentrales Problem: Der PostgreSQL-Container wird auf einem zufälligen Port gestartet -- bei jedem Testlauf ein anderer. Die JDBC-URL, die Spring für die DataSource braucht, steht also erst zur Laufzeit fest.

@DynamicPropertySource erlaubt es, Spring-Properties dynamisch zu setzen, nachdem der Container gestartet wurde, aber bevor der ApplicationContext initialisiert wird. Die Methode postgres::getJdbcUrl liefert die tatsächliche JDBC-URL des laufenden Containers -- inklusive Host, Port und Datenbankname. Spring bekommt damit exakt die Verbindungsdaten des Containers.

Vor @DynamicPropertySource musste man dafür ApplicationContextInitializer oder ähnlich umständliche Konstrukte verwenden. Die aktuelle Lösung ist deutlich eleganter.

Flyway-Migrationen: Endlich gegen die echte Datenbank

Ein besonders großer Gewinn zeigt sich bei Datenbankmigrationen. Unsere Flyway-Skripte sind für PostgreSQL geschrieben -- mit PostgreSQL-spezifischer Syntax, spezifischen Datentypen und spezifischen Features. Mit H2 mussten wir entweder separate Migrationsskripte pflegen oder den H2-Kompatibilitätsmodus nutzen, der aber nie vollständig kompatibel war.

Mit Testcontainers laufen die exakt gleichen Flyway-Skripte wie in Produktion. Wenn ein Migrationsskript in den Tests funktioniert, funktioniert es auch in Staging und Production. Das eliminiert eine ganze Klasse von Fehlern, die uns in der Vergangenheit immer wieder Ärger gemacht haben.

Reusable Containers: Schnellere Feedback-Zyklen lokal

Ein berechtigter Einwand gegen Testcontainers: Der Container-Start kostet Zeit. Ein PostgreSQL-Container braucht je nach Rechner zwei bis fünf Sekunden zum Hochfahren. Bei einer großen Testsuite mit vielen Testklassen summiert sich das.

Für die CI-Pipeline ist das akzeptabel -- Korrektheit geht vor Geschwindigkeit. Für die lokale Entwicklung bietet Testcontainers seit Version 1.15 das Feature Reusable Containers. Dazu aktiviert man in der Datei ~/.testcontainers.properties:

testcontainers.reuse.enable=true

Und markiert den Container als wiederverwendbar:

@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:14-alpine")
        .withDatabaseName("testdb")
        .withUsername("test")
        .withPassword("test")
        .withReuse(true);

Der Container bleibt nach dem Testlauf am Leben und wird beim nächsten Lauf wiederverwendet, sofern die Konfiguration identisch ist. Das reduziert die Startzeit auf Millisekunden. Für die CI-Pipeline deaktiviert man das Feature -- dort will man für jeden Lauf einen sauberen Container.

Über PostgreSQL hinaus

Testcontainers beschränkt sich nicht auf relationale Datenbanken. Die Bibliothek bietet Module für eine Vielzahl von Infrastruktur-Komponenten:

  • Redis für Cache-Tests
  • Kafka und RabbitMQ für Messaging-Tests
  • Elasticsearch für Suche
  • LocalStack für AWS-Services (S3, SQS, DynamoDB)
  • GenericContainer für alles, was als Docker-Image verfügbar ist

Der GenericContainer ist besonders mächtig: Jedes beliebige Docker-Image lässt sich als Testcontainer starten. Wir nutzen das unter anderem, um gegen eine MinIO-Instanz zu testen, die in einigen Kundenprojekten als S3-kompatibler Objektspeicher dient.

Best Practices aus unserer Erfahrung

Nach einigen Monaten mit Testcontainers haben sich bei uns folgende Muster bewährt:

Shared Container pro Testklasse: Ein statischer Container, der für alle Tests einer Klasse genutzt wird. Das spart Startzeit und ist für die meisten Szenarien ausreichend isoliert. Zwischen den Tests wird die Datenbank über @Transactional mit Rollback zurückgesetzt oder über ein @BeforeEach explizit bereinigt.

Feste Image-Versionen: Statt postgres:latest verwenden wir postgres:14-alpine -- die gleiche Version wie in Produktion. Damit testen wir nicht nur gegen den gleichen Datenbanktyp, sondern gegen die gleiche Version. Subtile Verhaltensunterschiede zwischen PostgreSQL 13 und 14 werden so zuverlässig erkannt.

Abstrakte Basisklasse: Für Projekte mit vielen Integrationstests lohnt sich eine abstrakte Basisklasse, die den Container und die DynamicPropertySource definiert. Das vermeidet Duplizierung und stellt sicher, dass alle Tests die gleiche Konfiguration nutzen.

@SpringBootTest
@Testcontainers
abstract class AbstractIntegrationTest {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:14-alpine")
            .withDatabaseName("testdb")
            .withUsername("test")
            .withPassword("test");

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
    }
}

Konkrete Testklassen erben davon und haben sofort Zugriff auf eine echte PostgreSQL-Instanz -- ohne eigene Container-Konfiguration.

Fazit

Testcontainers hat unsere Teststrategie grundlegend verändert. Der Wechsel von H2 zu echten Datenbanken in Docker-Containern eliminiert eine ganze Kategorie von Problemen: falsch-positive Tests, Kompatibilitätsmodus-Hacks und separate Migrationsskripte. Die Einrichtung ist minimal, die JUnit-5-Integration elegant, und die Auswirkung auf die Testlaufzeit ist mit Shared Containers und Reusable Containers gut beherrschbar.

Für uns ist Testcontainers mittlerweile Standard in jedem neuen Spring-Boot-Projekt. Wer Integrationstests schreibt -- und das sollte jedes Team tun --, sollte auf In-Memory-Ersatzdatenbanken verzichten und stattdessen gegen die echte Infrastruktur testen. Docker macht es möglich, Testcontainers macht es einfach.

Teilen

Patrick Hütter

Geschrieben von

Patrick Hütter

Gründer & Software-Architekt

Softwarearchitekt, Ingenieur und Unternehmer. Patrick baut seit über einem Jahrzehnt Produkte und Plattformen — von Enterprise-Backends über Cloud-Native-Infrastruktur bis hin zu KI-gestützten Anwendungen. Als Gründer von encircle360 verbindet er technische Tiefe mit unternehmerischem Denken und treibt Open-Source-Projekte voran, die echten Impact haben.