Testcontainers: Integrationstests mit echten Datenbanken
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.
Geschrieben von
Patrick HütterGrü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.
Das könnte dich auch interessieren
Vom Manifest zur Produktion: Wie ADL, A2A und das Inference Gateway die Agenten-Infrastruktur revolutionieren
05.07.2026 · 12 Min. Lesezeit
Agent-Orchestrierung mit Java: Wie Sie LLM-Agenten produktionsreif in die JVM bringen
04.07.2026 · 6 Min. Lesezeit
Spring AI: Wie Java-Entwickler endlich KI-Features ohne Umwege integrieren
25.03.2026 · 5 Min. Lesezeit