25 Testkonfigurationen und Best Practices

25.1 Test-Profile und Umgebungskonfiguration

Test-Profile ermöglichen die Ausführung verschiedener Test-Sets für unterschiedliche Szenarien. Ein Fast-Feedback-Profil führt nur kritische Unit-Tests während der Entwicklung aus, während ein Full-Regression-Profil alle Tests für CI/CD-Pipelines umfasst. Diese Differenzierung balanciert Feedback-Geschwindigkeit mit Test-Vollständigkeit. Gradle implementiert Profile durch Custom Tasks, System Properties oder Project Properties, die Test-Execution beeinflussen.

Die Konfiguration erfolgt über Gradle Properties, die verschiedene Test-Aspekte steuern. Properties definieren, welche Test-Kategorien ausgeführt werden, welche Ressourcen verfügbar sind und wie Tests parallelisiert werden. Environment-spezifische Properties passen Tests an lokale Entwicklungsumgebungen, CI-Server oder Cloud-Infrastrukturen an. Die Hierarchie von Properties ermöglicht Defaults mit gezielten Overrides.

// Test-Profile Definition
val testProfiles = mapOf(
    "fast" to TestProfile(
        includeTags = setOf("fast", "unit"),
        excludeTags = setOf("slow", "integration", "external"),
        parallel = true,
        maxForks = 4,
        timeout = Duration.ofMinutes(5)
    ),
    "integration" to TestProfile(
        includeTags = setOf("integration"),
        excludeTags = setOf("external"),
        parallel = false,
        maxForks = 1,
        timeout = Duration.ofMinutes(30)
    ),
    "full" to TestProfile(
        includeTags = setOf(),
        excludeTags = setOf(),
        parallel = true,
        maxForks = 8,
        timeout = Duration.ofHours(2)
    )
)

val activeProfile = project.findProperty("testProfile") as String? ?: "fast"
val profile = testProfiles[activeProfile] ?: error("Unknown test profile: $activeProfile")

tasks.test {
    useJUnitPlatform {
        if (profile.includeTags.isNotEmpty()) {
            includeTags(*profile.includeTags.toTypedArray())
        }
        if (profile.excludeTags.isNotEmpty()) {
            excludeTags(*profile.excludeTags.toTypedArray())
        }
    }
    
    maxParallelForks = if (profile.parallel) profile.maxForks else 1
    timeout.set(profile.timeout)
    
    // Profile-spezifische System Properties
    systemProperty("test.profile", activeProfile)
    systemProperty("test.db.url", 
        if (activeProfile == "integration") 
            "jdbc:postgresql://localhost:5432/test" 
        else 
            "jdbc:h2:mem:test"
    )
}

External Service Configuration unterscheidet zwischen echten Services und Mocks. Development-Tests verwenden WireMock oder embedded Services für Isolation. Integration-Tests verbinden zu dedizierten Test-Instanzen externer Services. Production-like Tests nutzen Staging-Umgebungen mit realistischen Daten und Last. Die Konfiguration erfolgt über Environment-Variablen oder Service-Discovery-Mechanismen.

25.2 Test-Kategorisierung und Tagging-Strategien

Systematisches Test-Tagging ermöglicht granulare Kontrolle über Test-Execution. Tags kategorisieren Tests nach verschiedenen Dimensionen wie Geschwindigkeit, Stabilität, Feature-Bereich oder Required Resources. JUnit 5’s @Tag Annotation markiert Test-Klassen oder -Methoden. Diese Kategorisierung unterstützt selective Test-Execution basierend auf Context und Requirements.

Die Tag-Hierarchie sollte orthogonale Dimensionen abbilden. Speed-Tags (@Tag("fast"), @Tag("slow")) klassifizieren nach Ausführungszeit. Stability-Tags (@Tag("stable"), @Tag("flaky")) identifizieren problematische Tests. Feature-Tags (@Tag("authentication"), @Tag("payment")) gruppieren nach Funktionalität. Resource-Tags (@Tag("database"), @Tag("network")) deklarieren externe Dependencies.

// Tag-basierte Test-Tasks
val tagBasedTasks = mapOf(
    "fast" to setOf("fast"),
    "slow" to setOf("slow"),
    "stable" to setOf("stable"),
    "database" to setOf("database"),
    "external" to setOf("external")
)

tagBasedTasks.forEach { (name, tags) ->
    tasks.register<Test>("test${name.capitalize()}") {
        group = "verification"
        description = "Run ${name} tests"
        
        useJUnitPlatform {
            includeTags(*tags.toTypedArray())
        }
        
        testClassesDirs = sourceSets.test.get().output.classesDirs
        classpath = sourceSets.test.get().runtimeClasspath
    }
}

// Composite Tasks für Test-Suites
tasks.register("testCritical") {
    description = "Run critical tests for pre-commit validation"
    dependsOn("testFast", "testStable")
}

tasks.register("testNightly") {
    description = "Run comprehensive nightly test suite"
    dependsOn(tasks.withType<Test>())
}

Meta-Annotations reduzieren Tagging-Boilerplate und standardisieren Test-Kategorien. Custom Annotations kombinieren mehrere Tags und JUnit-Annotations zu semantischen Test-Markern. @IntegrationTest impliziert @Tag("integration"), @Tag("slow") und @TestInstance(Lifecycle.PER_CLASS). Diese Abstraktion vereinfacht Test-Authoring und enforced Conventions.

25.3 Flaky-Test-Management

Flaky Tests unterminieren Vertrauen in Test-Suites und verzögern Development. Diese Tests schlagen intermittierend ohne Code-Änderungen fehl, typischerweise durch Race Conditions, Timing-Issues oder External Dependencies. Die Identifikation erfolgt durch Test-History-Analysis und Retry-Patterns. Gradle’s Test-Retry-Plugin wiederholt fehlgeschlagene Tests und markiert Flaky-Kandidaten.

Die Isolation flaky Tests verhindert Build-Blockierung während der Stabilisierung. Ein separates flaky-Tag excludiert diese Tests von Standard-Builds. Ein dedizierter testFlaky-Task führt nur diese Tests aus, möglicherweise mit mehreren Retries. CI/CD-Pipelines können Flaky-Test-Results als Warnings statt Failures behandeln. Diese Strategie balanciert Stabilität mit der Notwendigkeit, Problems zu identifizieren.

// Flaky Test Management
tasks.test {
    useJUnitPlatform {
        excludeTags("flaky")
    }
}

val testFlaky = tasks.register<Test>("testFlaky") {
    description = "Run flaky tests with retries"
    group = "verification"
    
    useJUnitPlatform {
        includeTags("flaky")
    }
    
    // Aggressive Retry-Strategie für Flaky Tests
    retry {
        maxRetries = 5
        maxFailures = 20
        failOnPassedAfterRetry = false
    }
    
    // Logging für Flaky-Analysis
    testLogging {
        events("failed", "passed", "skipped", "standard_error")
        showExceptions = true
        showCauses = true
        showStackTraces = true
    }
    
    // Report Flaky-Results separat
    reports.html.outputLocation.set(layout.buildDirectory.dir("reports/flaky-tests"))
}

// Flaky-Test-Detection Task
tasks.register("detectFlakyTests") {
    description = "Analyze test results for flaky behavior"
    
    doLast {
        val testResults = fileTree(buildDir) {
            include("test-results/*/TEST-*.xml")
        }
        
        val flakyTests = analyzeFlakyPatterns(testResults)
        
        if (flakyTests.isNotEmpty()) {
            logger.warn("Detected ${flakyTests.size} potentially flaky tests:")
            flakyTests.forEach { logger.warn("  - $it") }
            
            file("${buildDir}/reports/flaky-tests.txt").writeText(
                flakyTests.joinToString("\n")
            )
        }
    }
}

Root-Cause-Analysis für Flaky Tests erfordert systematisches Debugging. Thread-Dumps während Test-Execution identifizieren Deadlocks. Increased Logging Level reveal Timing-Dependencies. Test-Isolation durch Fresh JVMs oder Containers eliminiert State-Pollution. Deterministic Test-Data und Clock-Mocking addressieren Time-Based Flakiness.

25.4 Test-Performance-Optimierung

Test-Performance bestimmt Developer-Productivity und CI/CD-Efficiency. Langsame Tests verzögern Feedback und reduzieren Iteration-Speed. Die Optimierung beginnt mit Performance-Profiling zur Identifikation von Bottlenecks. Gradle Build Scans visualisieren Test-Duration und identifizieren die langsamsten Tests. Diese Information priorisiert Optimierungs-Efforts.

In-Memory-Databases beschleunigen datenbankintensive Tests erheblich. H2 oder HSQLDB ersetzen PostgreSQL oder MySQL für Unit-Tests. Die Schema-Compatibility wird durch gemeinsame SQL-Standards oder Compatibility-Modi sichergestellt. Integration-Tests verwenden weiterhin Real Databases via Testcontainers, aber mit Optimierungen wie Shared Containers oder Database Snapshots.

// Performance-optimierte Test-Konfiguration
tasks.withType<Test>().configureEach {
    // JVM-Optimierungen
    jvmArgs(
        "-XX:+UseG1GC",
        "-XX:MaxGCPauseMillis=100",
        "-XX:+ParallelRefProcEnabled",
        "-XX:+UseStringDeduplication",
        "-Djava.security.egd=file:/dev/./urandom",  // Faster random generation
        "-Dspring.test.context.cache.maxSize=128"   // Spring Context Caching
    )
    
    // Disable unnötige Features während Tests
    systemProperty("spring.jmx.enabled", "false")
    systemProperty("spring.devtools.restart.enabled", "false")
    systemProperty("logging.level.root", "WARN")
    
    // Build-Cache für Test-Results
    outputs.cacheIf { true }
    outputs.upToDateWhen { false }  // Force cache validation
    
    // Test-Class-Ordering für bessere Parallelisierung
    systemProperty("junit.jupiter.testclass.order.default", 
        "org.junit.jupiter.api.ClassOrderer\$Random")
}

// Shared Test-Resources
val sharedTestResources = tasks.register("setupSharedTestResources") {
    outputs.dir("${buildDir}/shared-test-resources")
    
    doLast {
        // Start shared services (DB, Redis, etc.)
        startSharedTestContainers()
        
        // Generate expensive test data once
        generateExpensiveTestData()
    }
}

tasks.test {
    dependsOn(sharedTestResources)
    
    doFirst {
        // Pass shared resource locations to tests
        systemProperty("shared.db.url", getSharedDbUrl())
        systemProperty("shared.redis.url", getSharedRedisUrl())
    }
}

Test-Parallelization auf verschiedenen Levels maximiert Resource-Utilization. Class-Level-Parallelization via maxParallelForks nutzt Multiple CPUs. Method-Level-Parallelization via JUnit Platform Configuration erhöht Concurrency innerhalb JVMs. Die Balance zwischen Parallelization-Levels hängt von Test-Characteristics und Available Resources ab.

25.5 Test-Data-Lifecycle und Cleanup

Test-Data-Lifecycle-Management verhindert State-Pollution und garantiert Test-Isolation. Transactional Tests mit Automatic Rollback sind der Standard für Database-Tests. Spring’s @Transactional oder JUnit’s @Rollback Annotations managen Transaction-Boundaries. Diese Strategie funktioniert für die meisten Tests, versagt aber bei Multi-Transaction-Scenarios oder External Service Calls.

Explicit Cleanup-Strategies adressieren Non-Transactional Resources. @AfterEach Methods bereinigen Test-spezifische Daten. @AfterAll Methods cleanup Class-Level Resources. Try-Finally-Blocks garantieren Cleanup auch bei Test-Failures. Resource-Management mit try-with-resources oder Kotlin’s use automatisiert Lifecycle-Management.

// Test-Cleanup-Tasks
tasks.register("cleanTestData") {
    description = "Clean up test data and resources"
    
    doLast {
        // Clean test databases
        file("${buildDir}/test-db").deleteRecursively()
        
        // Clean test file uploads
        file("${buildDir}/test-uploads").deleteRecursively()
        
        // Clean test caches
        file("${System.getProperty("java.io.tmpdir")}/test-cache").deleteRecursively()
    }
}

tasks.test {
    finalizedBy("cleanTestData")
}

// Global Test-Listener für Cleanup
dependencies {
    testImplementation(files("${buildDir}/generated/test-listeners"))
}

tasks.register("generateTestListeners") {
    val outputDir = file("${buildDir}/generated/test-listeners")
    outputs.dir(outputDir)
    
    doLast {
        outputDir.mkdirs()
        
        // Generate JUnit Platform Launcher Listener
        file("$outputDir/META-INF/services/org.junit.platform.launcher.TestExecutionListener")
            .apply { parentFile.mkdirs() }
            .writeText("com.company.test.GlobalTestCleanupListener")
        
        // Generate Listener Implementation
        file("$outputDir/com/company/test/GlobalTestCleanupListener.java").apply {
            parentFile.mkdirs()
            writeText("""
                package com.company.test;
                
                public class GlobalTestCleanupListener implements TestExecutionListener {
                    @Override
                    public void executionFinished(TestIdentifier testIdentifier, TestExecutionResult result) {
                        if (testIdentifier.isTest()) {
                            TestDataCleaner.cleanTestSpecificData(testIdentifier);
                        }
                    }
                }
            """.trimIndent())
        }
    }
}

Database-Seeding-Strategies initialisieren Consistent Test-States. Liquibase oder Flyway Migrations erstellen Schema und Reference-Data. DBUnit oder SQL-Scripts laden Test-specific Data. Database Snapshots via Docker Volumes ermöglichen Fast Reset zu Known States. Diese Kombinationen balancieren Setup-Speed mit Data-Complexity.

25.6 Continuous Testing und Watch-Mode

Continuous Testing führt relevante Tests automatisch bei Code-Änderungen aus. Gradle’s --continuous Flag überwacht Source-Files und triggert Test-Execution bei Änderungen. Die Test-Selection basiert auf Dependency-Analysis zwischen Source und Test-Files. Diese Immediate Feedback Loop beschleunigt Test-Driven Development und reduziert Context-Switching.

Die Test-Watch-Konfiguration optimiert für schnelles Feedback. Nur affected Tests werden ausgeführt, basierend auf geänderten Klassen und deren Dependents. Test-Prioritization führt Failed Tests zuerst aus. Fast Tests laufen vor Slow Tests. Diese Strategien maximieren den Wert frühen Feedbacks während der Entwicklung.

// Continuous Testing Configuration
tasks.register("watchTest") {
    description = "Run tests in watch mode with smart selection"
    
    doLast {
        gradle.startParameter.isContinuous = true
        
        // Configure for optimal watch mode
        project.extra["test.watch.mode"] = true
        project.extra["test.watch.strategy"] = "affected"
        project.extra["test.watch.priority"] = "failed-first"
    }
    
    finalizedBy(tasks.test)
}

// Test Selection basierend auf Git-Änderungen
tasks.register<Test>("testChanged") {
    description = "Run tests affected by uncommitted changes"
    
    val changedFiles = providers.exec {
        commandLine("git", "diff", "--name-only", "HEAD")
    }.standardOutput.asText.get().lines()
    
    val affectedTests = calculateAffectedTests(changedFiles)
    
    useJUnitPlatform()
    
    // Dynamische Test-Filter
    filter {
        affectedTests.forEach { includeTestsMatching(it) }
    }
    
    // Fail fast bei ersten Fehler
    failFast = true
}

IDE-Integration verbessert Continuous Testing Experience. IntelliJ IDEA’s Gradle Integration visualisiert Test-Results inline. Test-Runners zeigen Coverage-Information in Editors. Debugging funktioniert nahtlos mit Breakpoints in Test und Production Code. Diese Integration macht Continuous Testing zum natürlichen Teil des Development Workflows.