26 Generieren und Analysieren von Testberichten

26.1 Test-Report-Formate und Generierung

Gradle generiert standardmäßig Test-Reports in verschiedenen Formaten, die unterschiedliche Analyse-Szenarien unterstützen. Der HTML-Report bietet eine interaktive Übersicht mit Navigation durch Packages, Klassen und einzelne Test-Methoden. XML-Reports im JUnit-Format ermöglichen Tool-Integration und automatisierte Analyse. Binary-Reports speichern vollständige Test-Execution-Daten für nachträgliche Analyse. Diese Multi-Format-Strategie bedient sowohl menschliche Review-Prozesse als auch automatisierte Quality-Gates.

Die Report-Generierung erfolgt automatisch nach Test-Execution, kann aber durch explizite Konfiguration angepasst werden. Output-Directories, Report-Details und Aggregation-Level sind konfigurierbar. Die Balance zwischen Report-Vollständigkeit und Generierungs-Performance erfordert bewusste Entscheidungen. Detaillierte Reports mit Stack-Traces und System-Output unterstützen Debugging, erhöhen aber Speicherbedarf und Generierungszeit erheblich.

tasks.withType<Test>().configureEach {
    reports {
        html.required.set(true)
        xml.required.set(true)
        
        // Custom Report-Locations
        html.outputLocation.set(layout.buildDirectory.dir("test-reports/${name}/html"))
        xml.outputLocation.set(layout.buildDirectory.dir("test-reports/${name}/xml"))
        
        // JUnit Platform Report
        junitXml.required.set(true)
        junitXml.outputLocation.set(layout.buildDirectory.dir("test-reports/${name}/junit-platform"))
    }
    
    // Test Logging für Report-Details
    testLogging {
        events = setOf(
            TestLogEvent.FAILED,
            TestLogEvent.PASSED,
            TestLogEvent.SKIPPED,
            TestLogEvent.STANDARD_ERROR
        )
        
        exceptionFormat = TestExceptionFormat.FULL
        showExceptions = true
        showCauses = true
        showStackTraces = true
        
        // Capture Output für Failed Tests
        showStandardStreams = false
        
        afterSuite { suite, result ->
            if (suite.parent == null) {
                val summary = "Test Results: ${result.resultType} " +
                    "(${result.testCount} tests, " +
                    "${result.successfulTestCount} passed, " +
                    "${result.failedTestCount} failed, " +
                    "${result.skippedTestCount} skipped)"
                
                val reportFile = file("${buildDir}/test-summary.txt")
                reportFile.appendText("$summary\n")
                
                if (result.failedTestCount > 0) {
                    logger.error(summary)
                } else {
                    logger.lifecycle(summary)
                }
            }
        }
    }
}

Custom Report Formats erweitern die Standard-Reports um projektspezifische Requirements. Test-Execution-Listener sammeln zusätzliche Metriken während der Test-Ausführung. Performance-Daten, Memory-Usage oder Custom Assertions werden in strukturierten Formaten persistiert. JSON-Reports ermöglichen flexible Tool-Integration und JavaScript-basierte Visualisierungen. Diese Custom Reports ergänzen Standard-Reports mit domänenspezifischen Informationen.

26.2 Aggregierte Reports über Subprojekte

Multi-Projekt-Builds erfordern konsolidierte Test-Reports über alle Module. Die Aggregation erfolgt durch dedizierte Tasks, die Reports aus Subprojekten sammeln und zu einer Gesamt-Übersicht vereinen. Diese Konsolidierung bietet Projekt-weite Test-Statistiken und identifiziert Problembereiche über Modul-Grenzen hinweg.

Die Test-Report-Aggregation-Task sammelt XML-Reports aus allen Subprojekten und generiert einen kombinierten HTML-Report. Die Herausforderung liegt in der korrekten Zuordnung von Test-Results zu Source-Locations über Projekt-Grenzen. Relative Pfade müssen zu absoluten Pfaden aufgelöst werden, um korrekte Source-Links in aggregierten Reports zu gewährleisten.

// Root-Level Test Report Aggregation
val testReportData = configurations.create("testReportData") {
    isCanBeConsumed = false
    isCanBeResolved = true
    attributes {
        attribute(Category.CATEGORY_ATTRIBUTE, objects.named(Category.VERIFICATION))
        attribute(VerificationType.VERIFICATION_TYPE_ATTRIBUTE, objects.named(VerificationType.TEST_RESULTS))
    }
}

dependencies {
    subprojects.forEach { subproject ->
        if (subproject.plugins.hasPlugin("java")) {
            testReportData(project(subproject.path)) {
                capabilities {
                    requireCapability("${subproject.group}:${subproject.name}-test-results")
                }
            }
        }
    }
}

tasks.register<TestReport>("aggregatedTestReport") {
    description = "Generates an aggregated test report for all subprojects"
    group = "verification"
    
    destinationDirectory.set(layout.buildDirectory.dir("reports/tests/aggregated"))
    
    // Collect test results from all subprojects
    testResults.from(subprojects.map { subproject ->
        subproject.tasks.withType<Test>().map { it.binaryResultsDirectory }
    })
    
    // Generate aggregated metrics
    doLast {
        val metricsFile = file("${destinationDirectory.get()}/metrics.json")
        val metrics = calculateAggregatedMetrics()
        metricsFile.writeText(groovy.json.JsonOutput.toJson(metrics))
        
        logger.lifecycle("Aggregated Test Metrics:")
        logger.lifecycle("  Total Tests: ${metrics.totalTests}")
        logger.lifecycle("  Success Rate: ${metrics.successRate}%")
        logger.lifecycle("  Total Duration: ${metrics.totalDuration}ms")
        logger.lifecycle("  Slowest Module: ${metrics.slowestModule}")
    }
}

// Subproject configuration for test result publication
subprojects {
    if (plugins.hasPlugin("java")) {
        configurations.create("testResultsElements") {
            isCanBeConsumed = true
            isCanBeResolved = false
            attributes {
                attribute(Category.CATEGORY_ATTRIBUTE, objects.named(Category.VERIFICATION))
                attribute(VerificationType.VERIFICATION_TYPE_ATTRIBUTE, objects.named(VerificationType.TEST_RESULTS))
            }
            outgoing {
                capability("${group}:${name}-test-results:${version}")
                artifacts(tasks.named<Test>("test").map { it.binaryResultsDirectory })
            }
        }
    }
}

Cross-Module-Test-Coverage aggregiert Coverage-Daten über Projekt-Grenzen. Integration-Tests in einem Modul können Code in anderen Modulen ausführen. Die aggregierte Coverage zeigt die tatsächliche Code-Abdeckung über das gesamte System. JaCoCo’s Merge-Task kombiniert Execution-Daten aus verschiedenen Test-Runs. Der aggregierte Report identifiziert untestete Code-Bereiche, die in modularen Tests übersehen wurden.

26.3 Visualisierung und Dashboards

Die Visualisierung von Test-Metriken transformiert rohe Daten in actionable Insights. HTML-Reports bieten grundlegende Visualisierungen, aber dedizierte Dashboards ermöglichen tiefere Analyse. Grafana, Kibana oder Custom Web-Dashboards konsumieren Test-Report-Daten und visualisieren Trends, Verteilungen und Anomalien. Diese Visualisierungen unterstützen datengetriebene Entscheidungen über Test-Strategie und Code-Qualität.

Time-Series-Visualisierungen zeigen Test-Metriken über Zeit. Success-Rates, Test-Counts und Execution-Times werden als Linien-Diagramme dargestellt. Diese Trends identifizieren Degradation früh und korrelieren Test-Probleme mit Code-Changes. Heatmaps visualisieren Test-Failures über Module und Zeit, was Problem-Cluster aufdeckt. Distribution-Charts zeigen Test-Duration-Verteilungen und identifizieren Performance-Outliers.

// Test Metrics Export für Visualisierung
tasks.register("exportTestMetrics") {
    description = "Export test metrics for visualization"
    dependsOn(tasks.withType<Test>())
    
    val metricsDir = file("${buildDir}/test-metrics")
    outputs.dir(metricsDir)
    
    doLast {
        metricsDir.mkdirs()
        
        // Prometheus-Format Export
        val prometheusFile = file("${metricsDir}/test_metrics.prom")
        prometheusFile.writeText(buildString {
            appendLine("# HELP test_duration_seconds Test execution duration")
            appendLine("# TYPE test_duration_seconds histogram")
            
            tasks.withType<Test>().forEach { testTask ->
                val results = testTask.reports.xml.outputLocation.get().asFile
                    .walkTopDown()
                    .filter { it.extension == "xml" }
                    .flatMap { parseTestResults(it) }
                    .toList()
                
                results.forEach { result ->
                    appendLine("test_duration_seconds{module=\"${project.name}\",test=\"${result.name}\"} ${result.duration}")
                }
            }
            
            appendLine("# HELP test_success_total Number of successful tests")
            appendLine("# TYPE test_success_total counter")
            appendLine("test_success_total{project=\"${project.name}\"} ${countSuccessfulTests()}")
            
            appendLine("# HELP test_failure_total Number of failed tests")
            appendLine("# TYPE test_failure_total counter")
            appendLine("test_failure_total{project=\"${project.name}\"} ${countFailedTests()}")
        })
        
        // InfluxDB Line Protocol Export
        val influxFile = file("${metricsDir}/test_metrics.influx")
        val timestamp = System.currentTimeMillis() * 1000000 // nanoseconds
        
        influxFile.writeText(buildString {
            tasks.withType<Test>().forEach { testTask ->
                val moduleName = testTask.project.name.replace(" ", "\\ ")
                
                appendLine("test_execution,module=${moduleName},task=${testTask.name} " +
                    "duration=${testTask.duration}i," +
                    "passed=${testTask.passedTestCount}i," +
                    "failed=${testTask.failedTestCount}i," +
                    "skipped=${testTask.skippedTestCount}i " +
                    timestamp)
            }
        })
    }
}

Dashboard-Integration erfolgt über REST APIs oder File-basierte Imports. Elasticsearch konsumiert JSON-Reports für Full-Text-Search über Test-Results. Grafana-Dashboards visualisieren Prometheus-Metrics mit Alerts für Threshold-Violations. Jenkins oder GitLab zeigen Test-Trends in Build-Pipelines. Diese Integrationen machen Test-Metriken zum integralen Teil des Development-Workflows.

26.4 Failure-Analysis und Reporting

Test-Failure-Analysis identifiziert Muster und Root-Causes von Test-Problemen. Die Analyse beginnt mit Failure-Categorization: Compilation-Errors, Assertion-Failures, Timeouts oder Infrastructure-Problems. Jede Kategorie erfordert unterschiedliche Debugging-Strategien. Automated Categorization basiert auf Exception-Types und Error-Messages in Test-Reports.

Failure-Reports fokussieren auf actionable Information für Entwickler. Stack-Traces werden auf relevante Frames reduziert, Framework-Code wird ausgeblendet. Assertion-Failures zeigen Expected versus Actual Values mit Diff-Visualisierung. Screenshots oder DOM-Dumps von UI-Tests dokumentieren den Failure-State. Diese kontextreiche Information beschleunigt Problem-Resolution erheblich.

tasks.register("analyzeTestFailures") {
    description = "Analyze and categorize test failures"
    mustRunAfter(tasks.withType<Test>())
    
    val analysisReport = file("${buildDir}/reports/test-failure-analysis.html")
    outputs.file(analysisReport)
    
    doLast {
        val failures = mutableMapOf<String, MutableList<TestFailure>>()
        
        // Parse test results
        tasks.withType<Test>().forEach { testTask ->
            testTask.reports.xml.outputLocation.get().asFile
                .walkTopDown()
                .filter { it.extension == "xml" }
                .forEach { xmlFile ->
                    val testResults = XmlParser().parse(xmlFile)
                    testResults.depthFirst().filter { 
                        it.name() == "testcase" && it.children().any { child -> 
                            (child as? Node)?.name() in listOf("failure", "error")
                        }
                    }.forEach { testCase ->
                        val failure = parseTestFailure(testCase as Node)
                        failures.getOrPut(failure.category) { mutableListOf() }.add(failure)
                    }
                }
        }
        
        // Generate HTML Report
        analysisReport.writeText("""
            <!DOCTYPE html>
            <html>
            <head>
                <title>Test Failure Analysis</title>
                <style>
                    body { font-family: Arial, sans-serif; margin: 20px; }
                    .category { margin-bottom: 30px; }
                    .failure { background: #fff3cd; padding: 10px; margin: 10px 0; border-left: 4px solid #ffc107; }
                    .critical { border-left-color: #dc3545; background: #f8d7da; }
                    pre { background: #f4f4f4; padding: 10px; overflow-x: auto; }
                </style>
            </head>
            <body>
                <h1>Test Failure Analysis Report</h1>
                <p>Generated: ${Instant.now()}</p>
                
                ${failures.map { (category, failureList) -> """
                    <div class="category">
                        <h2>$category (${failureList.size} failures)</h2>
                        ${failureList.take(10).joinToString("\n") { failure -> """
                            <div class="failure ${if (failure.critical) "critical" else ""}">
                                <h3>${failure.testName}</h3>
                                <p><strong>Module:</strong> ${failure.module}</p>
                                <p><strong>Message:</strong> ${failure.message}</p>
                                <pre>${failure.stackTrace.take(500)}...</pre>
                            </div>
                        """}}
                        ${if (failureList.size > 10) "<p>... and ${failureList.size - 10} more</p>" else ""}
                    </div>
                """}.joinToString("\n")}
            </body>
            </html>
        """.trimIndent())
        
        logger.lifecycle("Test Failure Analysis: ${analysisReport.toURI()}")
        failures.forEach { (category, failureList) ->
            logger.lifecycle("  $category: ${failureList.size} failures")
        }
    }
}

Intelligent Failure-Grouping identifiziert Related Failures. Failures mit ähnlichen Stack-Traces oder Error-Messages werden gruppiert. Diese Grouping reduziert Noise und fokussiert auf Unique Problems. Machine-Learning-Ansätze können Historical Failure-Data nutzen, um Known Issues zu identifizieren und Resolution-Suggestions zu bieten.

26.5 Historische Trend-Analyse

Test-History-Tracking ermöglicht Long-Term Quality-Monitoring. Jeder Build persistiert Test-Metrics in einer Time-Series-Database oder strukturierten Logs. Diese Historical Data unterstützt Trend-Analysis, Regression-Detection und Performance-Baseline-Establishment. Die Correlation zwischen Test-Metrics und Code-Changes identifiziert Quality-Impact von Features oder Refactorings.

Trend-Reports visualisieren Test-Suite-Evolution über Zeit. Test-Count-Growth zeigt Increasing Coverage oder Technical Debt. Execution-Time-Trends identifizieren Performance-Degradation. Flakiness-Trends tracken Test-Stability. Diese Metriken informieren Decisions über Test-Infrastructure-Investment und Refactoring-Priorities.

tasks.register("generateTrendReport") {
    description = "Generate test trend analysis report"
    
    val historyDir = file("${rootProject.buildDir}/test-history")
    val trendReport = file("${buildDir}/reports/test-trends.html")
    
    inputs.dir(historyDir)
    outputs.file(trendReport)
    
    doLast {
        // Collect historical data
        val history = historyDir.walkTopDown()
            .filter { it.name.endsWith(".json") }
            .map { file ->
                val data = groovy.json.JsonSlurper().parseText(file.readText()) as Map<String, Any>
                TestRunHistory(
                    timestamp = Instant.parse(data["timestamp"] as String),
                    totalTests = data["totalTests"] as Int,
                    passRate = data["passRate"] as Double,
                    duration = data["duration"] as Long,
                    modules = data["modules"] as Map<String, Any>
                )
            }
            .sortedBy { it.timestamp }
            .toList()
        
        // Calculate trends
        val trends = calculateTrends(history)
        
        // Generate visualization
        trendReport.writeText("""
            <!DOCTYPE html>
            <html>
            <head>
                <title>Test Trend Analysis</title>
                <script src="https://cdn.plot.ly/plotly-latest.min.js"></script>
            </head>
            <body>
                <h1>Test Suite Trend Analysis</h1>
                
                <div id="testCountChart"></div>
                <div id="passRateChart"></div>
                <div id="durationChart"></div>
                
                <script>
                    const dates = ${history.map { it.timestamp }.toJson()};
                    
                    // Test Count Trend
                    Plotly.newPlot('testCountChart', [{
                        x: dates,
                        y: ${history.map { it.totalTests }.toJson()},
                        type: 'scatter',
                        name: 'Total Tests'
                    }], {
                        title: 'Test Count Over Time',
                        xaxis: { title: 'Date' },
                        yaxis: { title: 'Number of Tests' }
                    });
                    
                    // Pass Rate Trend
                    Plotly.newPlot('passRateChart', [{
                        x: dates,
                        y: ${history.map { it.passRate }.toJson()},
                        type: 'scatter',
                        name: 'Pass Rate',
                        line: { color: 'green' }
                    }], {
                        title: 'Test Pass Rate Trend',
                        xaxis: { title: 'Date' },
                        yaxis: { title: 'Pass Rate (%)', range: [0, 100] }
                    });
                    
                    // Duration Trend
                    Plotly.newPlot('durationChart', [{
                        x: dates,
                        y: ${history.map { it.duration / 1000 }.toJson()},
                        type: 'scatter',
                        name: 'Test Duration',
                        line: { color: 'blue' }
                    }], {
                        title: 'Test Execution Duration',
                        xaxis: { title: 'Date' },
                        yaxis: { title: 'Duration (seconds)' }
                    });
                </script>
                
                <h2>Trend Summary</h2>
                <ul>
                    <li>Test Growth Rate: ${trends.growthRate}% per week</li>
                    <li>Average Pass Rate: ${trends.avgPassRate}%</li>
                    <li>Pass Rate Trend: ${trends.passRateTrend}</li>
                    <li>Performance Trend: ${trends.performanceTrend}</li>
                </ul>
            </body>
            </html>
        """.trimIndent())
    }
}

// Persist current test run for historical tracking
tasks.withType<Test>().configureEach {
    finalizedBy(tasks.register("persistTestHistory${name.capitalize()}") {
        val historyDir = file("${rootProject.buildDir}/test-history")
        val historyFile = file("${historyDir}/${project.name}-${name}-${System.currentTimeMillis()}.json")
        
        doLast {
            historyDir.mkdirs()
            
            val metrics = mapOf(
                "timestamp" to Instant.now().toString(),
                "project" to project.name,
                "task" to name,
                "totalTests" to testCount,
                "passRate" to if (testCount > 0) (passedTestCount * 100.0 / testCount) else 100.0,
                "duration" to duration,
                "passedTests" to passedTestCount,
                "failedTests" to failedTestCount,
                "skippedTests" to skippedTestCount
            )
            
            historyFile.writeText(groovy.json.JsonOutput.toJson(metrics))
        }
    })
}

Predictive Analytics nutzen Historical Data für Forecasting. Machine-Learning-Models trainieren auf Test-History und predicten Failure-Likelihood, Execution-Time oder Required Resources. Diese Predictions optimieren Test-Selection, Resource-Allocation und Pipeline-Scheduling. Anomaly-Detection identifiziert Unusual Test-Behavior früh und triggert Investigations bevor Problems escalieren.