14 Debugging und Testen von Gradle-Skripten

14.1 Debugging-Strategien für Build-Skripte

Gradle-Skripte sind vollwertige Programme, die komplexe Logik enthalten können. Fehler in Build-Skripten manifestieren sich oft subtil: Tasks produzieren falsche Outputs, Dependencies werden nicht korrekt aufgelöst, oder die Build-Performance degradiert ohne erkennbaren Grund. Effektives Debugging erfordert systematische Herangehensweise und Kenntnis der verfügbaren Werkzeuge.

Die grundlegendste Debugging-Technik ist gezieltes Logging. Gradle bietet verschiedene Log-Level, die mit Command-Line-Parametern aktiviert werden. Der Parameter --info zeigt zusätzliche Informationen über Task-Ausführung und Dependency-Resolution. Mit --debug wird umfangreiches Debugging-Output generiert, das jeden Schritt der Build-Ausführung dokumentiert. Die Ausgabe ist verbose, enthält aber wertvolle Details über Gradle-Interna.

Println-Debugging bleibt trotz seiner Einfachheit effektiv für Build-Skripte:

tasks.register("debugConfiguration") {
    doFirst {
        println("=== Configuration Debug ===")
        println("Project: ${project.name}")
        println("Version: ${project.version}")
        println("BuildDir: ${layout.buildDirectory.get()}")
        
        configurations.compileClasspath.get().resolvedConfiguration.resolvedArtifacts.forEach {
            println("Dependency: ${it.id}")
        }
    }
}

Diese Ausgaben erscheinen im Build-Output und helfen bei der Verifikation von Konfigurationswerten. Die Platzierung in doFirst oder doLast Blocks stellt sicher, dass die Ausgabe während der Execution-Phase erfolgt, wenn alle Werte aufgelöst sind.

Stack Traces bei Exceptions liefern wichtige Kontext-Information. Der Parameter --stacktrace zeigt den vollständigen Stack Trace bei Fehlern. Die Option --full-stacktrace inkludiert auch Gradle-interne Frames, was bei der Diagnose von Plugin-Problemen hilft. Diese Informationen sind essentiell für die Lokalisierung von Fehlern in Custom Tasks oder Plugin-Code.

14.2 Remote Debugging mit IDE-Integration

Die IDE-basierte Debugging ermöglicht Breakpoints, Variable-Inspection und Step-by-Step-Execution in Build-Skripten. Gradle startet dazu in einem Debug-Modus, der externe Debugger-Verbindungen akzeptiert. IntelliJ IDEA und Eclipse unterstützen Remote-Debugging von Gradle-Builds nativ.

Die Aktivierung erfolgt über JVM-Argumente:

export GRADLE_OPTS="-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=5005"
./gradlew build

Der suspend=y Parameter pausiert Gradle beim Start, bis ein Debugger verbunden ist. Dies ermöglicht Debugging der frühen Konfigurationsphase. Für Debugging der Execution-Phase kann suspend=n verwendet werden.

Die IDE-Konfiguration für Remote-Debugging verbindet sich mit Port 5005:

// Breakpoint in Custom Task
abstract class DebugTask : DefaultTask() {
    @TaskAction
    fun execute() {
        val config = project.configurations.getByName("implementation")
        // Breakpoint hier - Inspection von config möglich
        config.resolvedConfiguration.resolvedArtifacts.forEach { artifact ->
            println("Processing: ${artifact.name}")
        }
    }
}

Conditional Breakpoints reduzieren Noise bei häufig ausgeführtem Code. Ein Breakpoint, der nur für bestimmte Task-Namen triggert, fokussiert auf relevante Ausführungen. Die IDE-Watch-Expressions evaluieren Gradle-Properties und Provider-Werte während der Ausführung.

14.3 Build Scans als Diagnose-Werkzeug

Build Scans sind Gradles umfassendstes Diagnose-Tool. Sie erfassen detaillierte Informationen über jeden Build und präsentieren diese in einer Web-Oberfläche. Die Aktivierung erfolgt mit dem --scan Parameter, wobei die Daten an scans.gradle.com übertragen werden. Für sensible Projekte bietet Gradle Enterprise selbst-gehostete Build Scans.

Ein Build Scan dokumentiert die komplette Build-Ausführung: Task-Execution-Times, Dependency-Resolution, Configuration-Time, Test-Results und Console-Output. Die Timeline-Visualisierung zeigt parallele Task-Ausführung und identifiziert Bottlenecks. Die Dependency-Ansicht visualisiert den vollständigen Dependency-Graph mit Konflikt-Markierungen.

Custom Values erweitern Build Scans um projekt-spezifische Informationen:

buildScan {
    value("Git Commit", executeGitCommand("rev-parse HEAD"))
    value("CI Build", System.getenv("CI") ?: "false")
    
    if (hasTask("test")) {
        tag("tests")
    }
    
    buildFinished {
        value("Build Duration", "${buildDuration}ms")
    }
}

Diese Werte erscheinen im Build Scan und ermöglichen Filterung und Analyse über mehrere Builds. Tags kategorisieren Builds für spätere Suche. Die Integration in CI/CD-Pipelines dokumentiert automatisch jeden Build mit reichhaltigen Kontext-Informationen.

14.4 Unit-Testing von Custom Tasks

Custom Tasks und Plugin-Logik erfordern automatisierte Tests für Zuverlässigkeit und Refactoring-Sicherheit. Gradle TestKit bietet eine Test-Infrastruktur für funktionale Tests von Build-Logik. Unit-Tests validieren Task-Behavior isoliert, während Integration-Tests komplette Build-Szenarien verifizieren.

Ein Unit-Test für einen Custom Task nutzt Gradles Project-Test-Fixtures:

class CustomTaskTest {
    @Test
    fun `task processes input correctly`() {
        val project = ProjectBuilder.builder().build()
        val task = project.tasks.create("testTask", ProcessingTask::class.java)
        
        // Konfiguration
        task.inputFile.set(project.layout.projectDirectory.file("test.txt"))
        task.outputFile.set(project.layout.buildDirectory.file("output.txt"))
        
        // Test-Datei erstellen
        val testFile = project.file("test.txt")
        testFile.writeText("test content")
        
        // Ausführung
        task.execute()
        
        // Verifikation
        val output = project.layout.buildDirectory.file("output.txt").get().asFile
        assertEquals("PROCESSED: test content", output.readText())
    }
}

Der ProjectBuilder erstellt eine Test-Projekt-Instanz ohne vollständige Gradle-Infrastruktur. Dies ermöglicht schnelle, isolierte Tests von Task-Logik. Die Test-Umgebung unterstützt File-Operations, Configuration und Task-Creation, aber keine Cross-Project-Dependencies oder Plugin-Application.

Mock-Objects simulieren externe Dependencies:

class TaskWithExternalServiceTest {
    @Test
    fun `task handles service failure gracefully`() {
        val project = ProjectBuilder.builder().build()
        val task = project.tasks.create("apiTask", ApiTask::class.java)
        
        val mockService = mock<ExternalService> {
            on { fetchData() } doThrow RuntimeException("Service unavailable")
        }
        
        task.service = mockService
        
        val result = assertDoesNotThrow {
            task.executeWithFallback()
        }
        
        assertEquals("FALLBACK_DATA", result)
    }
}

14.5 Functional Testing mit TestKit

TestKit ermöglicht End-to-End-Tests von Build-Skripten. Ein TestKit-Test erstellt temporäre Projekte, führt Gradle-Builds aus und verifiziert Ergebnisse. Diese Tests validieren Plugin-Behavior, Task-Integration und Build-Configuration in realistischen Szenarien.

Ein funktionaler Test mit TestKit:

class BuildLogicFunctionalTest {
    @TempDir
    lateinit var testProjectDir: File
    private lateinit var buildFile: File
    
    @BeforeEach
    fun setup() {
        buildFile = File(testProjectDir, "build.gradle.kts")
    }
    
    @Test
    fun `custom task generates expected output`() {
        buildFile.writeText("""
            tasks.register("generateReport") {
                val outputFile = layout.buildDirectory.file("report.txt")
                outputs.file(outputFile)
                
                doLast {
                    outputFile.get().asFile.writeText("Report Generated")
                }
            }
        """.trimIndent())
        
        val runner = GradleRunner.create()
            .withProjectDir(testProjectDir)
            .withArguments("generateReport", "--stacktrace")
            .withPluginClasspath()
        
        val result = runner.build()
        
        assertTrue(result.output.contains("BUILD SUCCESSFUL"))
        
        val reportFile = File(testProjectDir, "build/report.txt")
        assertTrue(reportFile.exists())
        assertEquals("Report Generated", reportFile.readText())
    }
}

TestKit-Tests können verschiedene Gradle-Versionen testen, was Kompatibilität sicherstellt:

@ParameterizedTest
@ValueSource(strings = ["7.0", "7.6", "8.0", "8.5"])
fun `plugin works with Gradle version`(gradleVersion: String) {
    val runner = GradleRunner.create()
        .withProjectDir(testProjectDir)
        .withGradleVersion(gradleVersion)
        .withArguments("build")
    
    val result = runner.build()
    assertEquals(TaskOutcome.SUCCESS, result.task(":build")?.outcome)
}

14.6 Best Practices für wartbare Build-Logik

Die Strukturierung von Build-Logik in testbare Einheiten verbessert Wartbarkeit. Komplexe Logik gehört in buildSrc oder separate Plugins, nicht in Build-Skripte. Funktionen extrahieren wiederverwendbare Patterns und ermöglichen isoliertes Testing:

// buildSrc/src/main/kotlin/BuildUtils.kt
object BuildUtils {
    fun calculateVersion(baseVersion: String, isSnapshot: Boolean): String {
        return if (isSnapshot) "$baseVersion-SNAPSHOT" else baseVersion
    }
    
    fun detectEnvironment(): String {
        return when {
            System.getenv("CI") != null -> "ci"
            System.getProperty("idea.active") != null -> "ide"
            else -> "local"
        }
    }
}

Diese Utilities sind unit-testbar ohne Gradle-Kontext:

class BuildUtilsTest {
    @Test
    fun `version calculation respects snapshot flag`() {
        assertEquals("1.0.0", BuildUtils.calculateVersion("1.0.0", false))
        assertEquals("1.0.0-SNAPSHOT", BuildUtils.calculateVersion("1.0.0", true))
    }
}

Assertions in Build-Skripten validieren Vorbedingungen und Invarianten:

tasks.register("validateConfiguration") {
    doFirst {
        require(project.hasProperty("deployTarget")) {
            "Property 'deployTarget' must be set for deployment"
        }
        
        val target = project.property("deployTarget").toString()
        check(target in listOf("dev", "staging", "prod")) {
            "Invalid deploy target: $target"
        }
    }
}

Diese Assertions produzieren klare Fehlermeldungen bei Konfigurationsproblemen.

Continuous Testing von Build-Logik integriert Tests in die CI/CD-Pipeline. Ein dedizierter Job führt TestKit-Tests aus und verifiziert Build-Skript-Änderungen. Die Test-Coverage von Build-Logik wird gemessen und Mindest-Coverage enforced. Diese Praktiken behandeln Build-Code als First-Class-Code mit entsprechenden Qualitätsstandards.

14.7 Debugging-Demo-Projekt

14.7.1 Projekt initialisieren

mkdir gradle-debug-demo && cd gradle-debug-demo
gradle init --type basic --dsl kotlin --project-name debug-demo
mkdir -p src/main/java/demo
mkdir -p src/test/java/demo

14.7.2 build.gradle.kts

plugins {
    java
    application
}

java {
    toolchain {
        languageVersion.set(JavaLanguageVersion.of(17))
    }
}

repositories {
    mavenCentral()
}

application {
    mainClass.set("demo.Main")
}

// Dependencies absichtlich gemischt, um Debugging zu zeigen
dependencies {
    implementation("com.google.guava:guava:32.1.3-jre")
    implementation("org.apache.commons:commons-lang3:3.14.0")
    testImplementation("junit:junit:4.13.2")
    testImplementation("org.junit.jupiter:junit-jupiter:5.10.0")
}

// Beispiel-Task mit Debug-Ausgaben
tasks.register("debugConfiguration") {
    doFirst {
        println("=== DEBUG CONFIGURATION ===")
        println("Project: ${project.name}")
        println("Version: ${project.version}")
        println("Java Toolchain: ${java.toolchain.languageVersion.get()}")

        println("\n-- Dependencies (compileClasspath) --")
        configurations.compileClasspath.get().resolvedConfiguration.resolvedArtifacts.forEach {
            println("  ${it.id}")
        }
    }
}

// Task mit absichtlichem Fehler
tasks.register("failingTask") {
    doLast {
        println(">>> This will fail")
        throw GradleException("Intentional error for debugging")
    }
}

// Task für File-Operations mit Layout-API
abstract class GenerateFileTask : DefaultTask() {
    @get:OutputFile
    abstract val outputFile: RegularFileProperty

    @TaskAction
    fun generate() {
        outputFile.get().asFile.writeText("Generated at ${System.currentTimeMillis()}")
        println("File generated: ${outputFile.get().asFile}")
    }
}

val genFile = tasks.register<GenerateFileTask>("generateFile") {
    outputFile.set(layout.buildDirectory.file("demo/generated.txt"))
}

// Task, der das Ergebnis konsumiert
tasks.register("readGeneratedFile") {
    val inputFile = genFile.flatMap { it.outputFile }
    inputs.file(inputFile)

    doLast {
        println("Content of generated file: ${inputFile.get().asFile.readText()}")
    }
}

14.7.3 src/main/java/demo/Main.java

package demo;

import com.google.common.base.Joiner;
import org.apache.commons.lang3.StringUtils;

public class Main {
    public static void main(String[] args) {
        String joined = Joiner.on(", ").join("Gradle", "Debug", "Demo");
        String capitalized = StringUtils.capitalize(joined);
        System.out.println("Output: " + capitalized);
    }
}

14.7.4 src/test/java/demo/MainTest.java

package demo;

import org.junit.Test;
import static org.junit.Assert.*;

public class MainTest {
    @Test
    public void testString() {
        String input = "gradle";
        assertEquals("Gradle", 
            org.apache.commons.lang3.StringUtils.capitalize(input));
    }
}

14.7.5 Typische Debugging-Kommandos

14.7.5.1 Logging-Levels

./gradlew build --info
./gradlew build --debug
./gradlew build --warn

14.7.5.2 Stacktraces

./gradlew failingTask --stacktrace
./gradlew failingTask --full-stacktrace

14.7.5.3 Dependency-Resolution

./gradlew dependencies
./gradlew dependencies --configuration compileClasspath
./gradlew dependencyInsight --dependency guava
./gradlew dependencyInsight --dependency commons-lang3 --configuration runtimeClasspath

14.7.5.4 Build-Scan (öffentlich)

./gradlew build --scan

14.7.5.5 Profiling

./gradlew build --profile

Erzeugt einen HTML-Report unter build/reports/profile/.

14.7.5.6 Remote Debugging (IDE)

export GRADLE_OPTS="-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=5005"
./gradlew build

Dann mit der IDE über Port 5005 verbinden.


14.7.6 Ergebnis

Mit diesem Projekt kannst du alle Debugging-Strategien aus dem Kapitel reproduzieren: