9 Praxisbeispiel: Integration-Test-Configuration aufsetzen

9.1 Motivation für separate Integrationstests

Integrationstests validieren das Zusammenspiel mehrerer Komponenten und externe Abhängigkeiten wie Datenbanken oder Web-Services. Im Gegensatz zu Unit-Tests benötigen sie längere Ausführungszeiten, spezielle Testumgebungen und zusätzliche Dependencies. Die Vermischung mit Unit-Tests führt zu langsamen Feedback-Zyklen und komplexen Test-Setups. Eine dedizierte Integration-Test-Configuration löst diese Probleme durch klare Trennung.

Die Standardkonfiguration von Gradle kennt nur die test Task für Unit-Tests. Integrationstests in derselben Configuration unterzubringen bedeutet, dass jeder gradle test Aufruf auch zeitintensive Integrationstests ausführt. Entwickler umgehen dies oft durch @Disabled Annotations oder Test-Categories, was zu inkonsistenter Test-Ausführung führt. Eine separate Configuration ermöglicht gezieltes Ausführen: gradle test für schnelles Feedback, gradle integrationTest für vollständige Validierung.

Die technische Trennung erfolgt durch eigene Source Sets, Configurations und Tasks. Integrationstests erhalten ein eigenes Verzeichnis src/integrationTest, separate Dependencies und eine unabhängige Test-Task. Diese Struktur spiegelt die unterschiedlichen Anforderungen wider und macht den Build-Prozess transparenter.

9.2 Source Set und Verzeichnisstruktur einrichten

Der erste Schritt ist die Definition eines neuen Source Sets für Integrationstests. Dies erstellt die notwendige Verzeichnisstruktur und Configurations:

sourceSets {
    create("integrationTest") {
        java.srcDir("src/integrationTest/java")
        resources.srcDir("src/integrationTest/resources")
        compileClasspath += sourceSets.main.get().output + configurations.testCompileClasspath.get()
        runtimeClasspath += output + compileClasspath
    }
}

Diese Konfiguration etabliert src/integrationTest/java für Testcode und src/integrationTest/resources für Testressourcen wie Konfigurationsdateien oder SQL-Skripte. Der Compile-Classpath inkludiert den kompilierten Hauptcode und alle Test-Dependencies. Der Runtime-Classpath fügt die kompilierten Integrationstests hinzu.

Die Verzeichnisse müssen manuell erstellt werden, da Gradle sie nicht automatisch generiert. Die Struktur folgt der Konvention anderer Source Sets:

src/
├── main/
│   ├── java/
│   └── resources/
├── test/
│   ├── java/
│   └── resources/
└── integrationTest/
    ├── java/
    └── resources/

IDEs wie IntelliJ IDEA erkennen das neue Source Set nach einem Gradle-Sync automatisch. Die Verzeichnisse werden als Test-Sources markiert und erhalten entsprechende Icons und Syntax-Highlighting. Dies ermöglicht nahtlose Entwicklung von Integrationstests mit voller IDE-Unterstützung.

9.3 Configuration-Hierarchie definieren

Die Configurations für Integrationstests müssen korrekt strukturiert werden, um Dependency-Management und Classpath-Resolution zu ermöglichen:

val integrationTestImplementation by configurations.getting {
    extendsFrom(configurations.implementation.get())
    extendsFrom(configurations.testImplementation.get())
}

val integrationTestRuntimeOnly by configurations.getting {
    extendsFrom(configurations.runtimeOnly.get())
    extendsFrom(configurations.testRuntimeOnly.get())
}

val integrationTestCompileClasspath by configurations.getting {
    extendsFrom(integrationTestImplementation)
}

val integrationTestRuntimeClasspath by configurations.getting {
    extendsFrom(integrationTestImplementation)
    extendsFrom(integrationTestRuntimeOnly)
}

Die integrationTestImplementation Configuration erbt von implementation und testImplementation. Dadurch stehen alle Produktiv-Dependencies und Test-Frameworks zur Verfügung. Zusätzliche Integrationtest-spezifische Dependencies werden direkt zu dieser Configuration hinzugefügt.

Die Vererbungshierarchie stellt sicher, dass Integrationstests Zugriff auf alle notwendigen Bibliotheken haben. Unit-Test-Utilities wie Mockito oder AssertJ sind automatisch verfügbar. Gleichzeitig bleiben Integrationtest-spezifische Dependencies wie Testcontainers oder REST-Assured isoliert vom normalen Test-Scope.

9.4 Dependencies für Integrationstests verwalten

Integrationstests benötigen oft zusätzliche Dependencies für Datenbankzugriffe, HTTP-Clients oder Container-Management. Diese werden zur integrationTestImplementation Configuration hinzugefügt:

dependencies {
    integrationTestImplementation("org.testcontainers:testcontainers:1.19.0")
    integrationTestImplementation("org.testcontainers:postgresql:1.19.0")
    integrationTestImplementation("io.rest-assured:rest-assured:5.3.2")
    integrationTestImplementation("org.springframework.boot:spring-boot-starter-test")
    integrationTestRuntimeOnly("org.postgresql:postgresql:42.6.0")
}

Testcontainers ermöglicht das Starten von Docker-Containern für Datenbanken oder andere Services direkt aus Tests. REST Assured vereinfacht das Testen von REST-APIs mit einer fluent API. Der PostgreSQL-Driver wird nur zur Laufzeit benötigt und landet daher in integrationTestRuntimeOnly.

Die Dependency-Verwaltung folgt denselben Prinzipien wie bei normalen Dependencies. Version Catalogs können verwendet werden, um Versionen zentral zu verwalten. Dependency Constraints gelten auch für Integrationtest-Dependencies und sorgen für konsistente Versionen über alle Scopes hinweg.

Ein wichtiger Aspekt ist die Vermeidung von Dependency-Konflikten zwischen Test- und Integrationtest-Scope. Wenn unterschiedliche Versionen derselben Bibliothek benötigt werden, müssen explizite Excludes oder Force-Direktiven verwendet werden. Die gradle integrationTestDependencies Task zeigt den vollständigen Dependency-Tree für Debugging-Zwecke.

9.5 Test-Task konfigurieren

Die Integrationtest-Task muss explizit definiert und konfiguriert werden:

tasks.register<Test>("integrationTest") {
    description = "Runs integration tests"
    group = "verification"
    
    testClassesDirs = sourceSets["integrationTest"].output.classesDirs
    classpath = sourceSets["integrationTest"].runtimeClasspath
    
    shouldRunAfter(tasks.test)
    
    useJUnitPlatform()
    
    testLogging {
        events("passed", "skipped", "failed")
        exceptionFormat = TestExceptionFormat.FULL
        showStandardStreams = true
    }
    
    systemProperty("spring.profiles.active", "integration-test")
    systemProperty("testcontainers.reuse.enable", "true")
    
    maxHeapSize = "2g"
    maxParallelForks = 1
}

Die Task wird der verification Gruppe zugeordnet, was sie im gradle tasks Output unter Verification-Tasks auflistet. Die shouldRunAfter Direktive stellt sicher, dass Unit-Tests vor Integrationstests laufen, wenn beide ausgeführt werden. Dies folgt dem Prinzip schneller Tests vor langsamen Tests.

Test-spezifische Konfigurationen optimieren die Ausführung. Der erhöhte Heap-Space accommodiert speicherintensive Integrationstests. Die Parallelität wird auf 1 begrenzt, da Integrationstests oft shared Resources wie Datenbank-Ports verwenden. System-Properties konfigurieren Test-Frameworks und aktivieren Features wie Testcontainer-Reuse.

Die Integration in den Build-Lifecycle erfolgt optional:

tasks.check {
    dependsOn(tasks.named("integrationTest"))
}

Diese Konfiguration führt Integrationstests bei gradle check aus, nicht aber bei gradle build. Dies balanciert zwischen vollständiger Validierung und schnellen Build-Zeiten.

9.6 CI/CD-Integration und Best Practices

In CI/CD-Pipelines werden Integrationstests typischerweise als separate Stage ausgeführt. Die Trennung von Unit- und Integrationstests ermöglicht parallele Ausführung und frühe Fehlererkennung. Eine Jenkins-Pipeline könnte folgendermaßen strukturiert sein:

stage('Unit Tests') {
    sh './gradlew test'
}

stage('Integration Tests') {
    sh './gradlew integrationTest'
}

Die separaten Test-Reports ermöglichen granulare Analyse. Gradle generiert Reports unter build/reports/tests/test und build/reports/tests/integrationTest. Diese können von CI-Tools aggregiert und visualisiert werden. Die Trennung hilft bei der Identifikation, ob Fehler in Unit- oder Integrationstests auftreten.

Caching-Strategien unterscheiden sich zwischen den Test-Typen. Unit-Test-Ergebnisse können aggressiv gecacht werden, da sie deterministisch sind. Integrationstests mit externen Dependencies müssen vorsichtiger gecacht werden. Die Task-Configuration kann Build-Cache explizit deaktivieren:

tasks.named<Test>("integrationTest") {
    outputs.upToDateWhen { false }
}

Test-Datenmanagement erfordert besondere Aufmerksamkeit bei Integrationstests. Testcontainers mit Flyway oder Liquibase migrieren Datenbank-Schemas automatisch. Test-Fixtures in src/integrationTest/resources/fixtures stellen konsistente Testdaten bereit. Die @Sql Annotation in Spring Boot Tests lädt SQL-Skripte für Testdaten-Setup.

Performance-Optimierungen reduzieren Ausführungszeiten erheblich. Testcontainer-Reuse vermeidet wiederholtes Container-Starten. Parallele Test-Ausführung auf Klassen-Ebene statt Methoden-Ebene reduziert Konflikte. Das Gruppieren verwandter Tests in Test-Suites minimiert Setup-Overhead.

Die Dokumentation der Test-Struktur ist essentiell für Team-Onboarding. Ein README im src/integrationTest Verzeichnis erklärt Voraussetzungen, Umgebungsvariablen und Debugging-Tipps. Beispiel-Tests demonstrieren Patterns für Datenbank-Tests, API-Tests und Message-Queue-Tests. Diese Dokumentation reduziert Einarbeitungszeit und fördert konsistente Test-Praktiken.