Giriş

Gradle eklentisi geliştirilirken, fonksiyonel testler, eklentinin gerçek bir Gradle ortamındaki davranışını doğrulamak için çok önemlidir. Ancak, kritik bir sorun hızla ortaya çıkar:GradleRunner` ile sorunlu bağımlılıklar oluşturmadan etkili bir loglama sistemi nasıl kurulur?

Bu makalede, keşfedeceğiz:

  • Gradle fonksiyonel testlerdeki loglama problematiği

  • SLF4J ve Logback ile sağlam bir çözümün mimarisi

  • Somut örneklerle tam uygulama

  • İyi uygulamalar ve kaçınılması gereken hatalar

Sorun: GradleRunner bağımlılığı

Eklenti Geliştirme Bağlamı

Bir Gradle eklentisi geliştirirken genellikle kullandığımız`GradleRunner`_Gradle TestKit’i test buildlerini yürütmek için :

@Test
fun `test my plugin`() {
    val result = GradleRunner.create()
        .withProjectDir(projectDir)
        .withArguments("myTask")
        .build()

    // Comment logger efficacement ici ?
}

Logging Dilemmasi

Ana sorun şudur:Logging başlatılması kesin olmayan bir nesneye bağımlı olmamalı.

@startuml
!define RECTANGLE class

skinparam backgroundColor #FEFEFE
skinparam componentStyle rectangle

package "Architecture Problématique" {
    RECTANGLE Test {
        + runTest()
    }

    RECTANGLE GradleRunner {
        + build()
        + getLogger() ❌
    }

    RECTANGLE Logger {
        + info()
        + debug()
    }

    Test --> GradleRunner : dépend de
    Logger ..> GradleRunner : dépend de ❌
}

note right of Logger
  Problème: Le logger dépend
  du GradleRunner qui peut
  ne pas être initialisé
end note

@enduml

Yanlış İyi İdealar

Bunu yapmayın:

// ❌ MAUVAIS: Tentative d'obtenir un logger du runner
val runner = GradleRunner.create()
val logger = runner.logger // N'existe pas !
// ❌ MAUVAIS: Logger partagé initialisé tardivement
lateinit var logger: Logger
@BeforeEach
fun setup() {
    logger = LoggerFactory.getLogger(...)
}

Çözüm: Bağımsız Logger

Önerilen Mimari

Çözüm, bir logger kullanmaktır.tamamen bağımsız du GradleRunner, test sınıfı düzeyinde başlatılmış.

@startuml
!define RECTANGLE class

skinparam backgroundColor #FEFEFE
skinparam componentStyle rectangle

package "Architecture Correcte" {
    RECTANGLE TestClass {
        + {static} logger: Logger ✓
        + runTest()
    }

    RECTANGLE SLF4J {
        + LoggerFactory
    }

    RECTANGLE GradleRunner {
        + build()
        + forwardOutput()
    }

    RECTANGLE "Console Output" as Output {
        + captured text
    }

    TestClass --> SLF4J : utilise ✓
    TestClass --> GradleRunner : utilise
    GradleRunner --> Output : génère
    TestClass --> Output : analyse
}

note top of TestClass
  Le logger est initialisé
  indépendamment du GradleRunner
end note

note bottom of GradleRunner
  .forwardOutput() permet
  de capturer la sortie
  pour analyse
end note

@enduml

Yürütme akışı

@startuml
!theme plain
skinparam backgroundColor #FEFEFE

participant "JUnit" as junit
participant "TestClass" as test
participant "Kayıt tutucu (SLF4J)" as logger
participant "GradleRunner" as runner
participant "Gradle derlemesi" as gradle

activate junit
junit -> test : load class
activate test

test -> logger : initialize (companion)
activate logger
logger --> test : logger ready ✓
deactivate logger

junit -> test : @BeforeAll
test -> logger : info("Initialisation...")

junit -> test : @BeforeEach
test -> logger : debug("Kurulum testi...")

junit -> test : @Test
test -> logger : info("Başlıyor...")

test -> runner : create()
activate runner
test -> runner : .forwardOutput()
test -> runner : .withArguments(...)
test -> runner : .build()

runner -> gradle : execute build
activate gradle
gradle --> runner : output text
deactivate gradle

runner --> test : BuildResult + output
deactivate runner

test -> logger : debug("Çıktı analiz ediliyor...")
test -> test : analyze output
test -> logger : info("✓ Test başarılı")

test --> junit : success
deactivate test
deactivate junit

@enduml

Tam Uygulama

Gradle Bağımlılıkları

Gereken bağımlılıkları ekleyin`build.gradle.kts`:

dependencies {
    // Pour les tests fonctionnels
    testImplementation(gradleTestKit())
    testImplementation("org.junit.jupiter:junit-jupiter:5.10.2")

    // Pour le logging
    testImplementation("org.slf4j:slf4j-api:2.0.17")
    testRuntimeOnly("ch.qos.logback:logback-classic:1.4.14")

    // Pour les assertions
    testImplementation("org.assertj:assertj-core:3.27.6")
}

tasks.test {
    useJUnitPlatform()
}

Logback yapılandırması

Dosyayı oluşturun`src/test/resources/logback-test.xml`:

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <!-- Appender console avec format lisible -->
    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
        </encoder>
    </appender>

    <!-- Appender fichier pour conservation -->
    <appender name="FILE" class="ch.qos.logback.core.FileAppender">
        <file>build/test-results/functional-tests.log</file>
        <encoder>
            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n</pattern>
        </encoder>
    </appender>

    <!-- Logger pour vos tests -->
    <logger name="com.cheroliv.bakery" level="DEBUG"/>

    <!-- Réduire le bruit de Gradle -->
    <logger name="org.gradle" level="WARN"/>

    <!-- Configuration racine -->
    <root level="INFO">
        <appender-ref ref="STDOUT"/>
        <appender-ref ref="FILE"/>
    </root>
</configuration>

Tam Test Sınıfı

package com.cheroliv.bakery

import org.gradle.testkit.runner.GradleRunner
import org.gradle.testkit.runner.TaskOutcome
import org.junit.jupiter.api.*
import org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS
import org.junit.jupiter.api.io.TempDir
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import java.io.File
import kotlin.test.assertEquals
import kotlin.test.assertTrue

@TestInstance(PER_CLASS)
class BakeryPluginFunctionalTest {

    @field:TempDir
    lateinit var projectDir: File

    private val buildFile by lazy { projectDir.resolve("build.gradle.kts") }
    private val settingsFile by lazy { projectDir.resolve("settings.gradle.kts") }

    companion object {
        // ✓ Logger indépendant, initialisé au chargement de la classe
        private val logger: Logger = LoggerFactory.getLogger(
            BakeryPluginFunctionalTest::class.java
        )

        @JvmStatic
        @BeforeAll
        fun globalSetup() {
            logger.info("═".repeat(60))
            logger.info("DÉMARRAGE DE LA SUITE DE TESTS FONCTIONNELS")
            logger.info("═".repeat(60))
        }

        @JvmStatic
        @AfterAll
        fun globalTeardown() {
            logger.info("═".repeat(60))
            logger.info("FIN DE LA SUITE DE TESTS")
            logger.info("═".repeat(60))
        }
    }

    @BeforeEach
    fun setup() {
        logger.info("─".repeat(60))
        logger.info("Préparation de l'environnement de test")
        logger.debug("Répertoire de test: ${projectDir.absolutePath}")

        settingsFile.writeText("""
            rootProject.name = "test-bakery-project"
        """.trimIndent())

        logger.debug("✓ settings.gradle.kts créé")
    }

    @AfterEach
    fun teardown(testInfo: TestInfo) {
        logger.info("✓ Test terminé: ${testInfo.displayName}")
        logger.info("─".repeat(60))
    }

    @Test
    fun `plugin can be applied successfully`() {
        logger.info("TEST: Application du plugin Bakery")

        buildFile.writeText("""
            plugins {
                id("com.cheroliv.bakery")
            }
        """.trimIndent())

        logger.debug("Fichier build.gradle.kts créé")
        logger.debug("Lancement du build Gradle...")

        val startTime = System.currentTimeMillis()

        val result = GradleRunner.create()
            .forwardOutput() // ← Important : capture la sortie
            .withPluginClasspath()
            .withArguments("tasks", "--group=bakery", "--stacktrace")
            .withProjectDir(projectDir)
            .build()

        val duration = System.currentTimeMillis() - startTime
        logger.info("Build terminé en ${duration}ms")

        // Analyse de la sortie
        logger.debug("Analyse de la sortie Gradle...")
        val outputLines = result.output.lines()
        logger.debug("Nombre de lignes capturées: ${outputLines.size}")

        // Validation
        val expectedTasks = listOf("bake", "printConfigPath", "printJBakeClasspath")
        logger.info("Vérification des tâches attendues:")

        expectedTasks.forEach { task ->
            val found = result.output.contains(task)
            logger.debug("  ${if (found) "✓" else "✗"} $task")
            assertTrue(found, "Task '$task' should be available")
        }

        logger.info("✓ Plugin appliqué avec succès - ${expectedTasks.size} tâches trouvées")
    }

    @Test
    fun `bake task executes and prints version`() {
        logger.info("TEST: Exécution de la tâche 'bake'")

        buildFile.writeText("""
            plugins {
                id("com.cheroliv.bakery")
            }
        """.trimIndent())

        logger.debug("Exécution de 'gradle bake'...")

        val result = GradleRunner.create()
            .forwardOutput()
            .withPluginClasspath()
            .withArguments("bake", "--info") // --info pour plus de détails
            .withProjectDir(projectDir)
            .build()

        // Extraction d'informations depuis la sortie
        logger.debug("Recherche du message 'Baking site'...")
        val hasBakingMessage = result.output.contains("Baking site with Bakery Plugin!")

        logger.debug("Recherche de la version JBake...")
        val hasVersionMessage = result.output.contains("Using JBake version:")

        // Extraction de la version (si présente)
        val versionRegex = Regex("Using JBake version: (.+)")
        val versionMatch = versionRegex.find(result.output)
        if (versionMatch != null) {
            val version = versionMatch.groupValues[1]
            logger.info("Version JBake détectée: $version")
        }

        // Validation
        assertTrue(hasBakingMessage, "Message 'Baking site' attendu")
        assertTrue(hasVersionMessage, "Message de version attendu")
        assertEquals(TaskOutcome.SUCCESS, result.task(":bake")?.outcome)

        logger.info("✓ Tâche 'bake' exécutée avec succès")
    }

    @Test
    fun `analyze output with structured logging`() {
        logger.info("TEST: Analyse structurée de la sortie")

        buildFile.writeText("""
            plugins {
                id("com.cheroliv.bakery")
            }

            task("diagnostics") {
                doLast {
                    println("[DIAG] Project: ${'$'}{project.name}")
                    println("[DIAG] Build dir: ${'$'}{project.buildDir}")
                    println("[DIAG] Plugin applied: true")

                    val config = configurations.findByName("jbakeRuntime")
                    println("[DIAG] JBake config exists: ${'$'}{config != null}")

                    if (config != null) {
                        println("[DIAG] Dependencies count: ${'$'}{config.dependencies.size}")
                    }
                }
            }
        """.trimIndent())

        logger.debug("Exécution de la tâche diagnostics...")

        val result = GradleRunner.create()
            .forwardOutput()
            .withPluginClasspath()
            .withArguments("diagnostics", "--quiet")
            .withProjectDir(projectDir)
            .build()

        // Analyse structurée
        logger.info("Extraction des informations de diagnostic:")

        val diagLines = result.output.lines()
            .filter { it.startsWith("[DIAG]") }

        logger.debug("Nombre de lignes de diagnostic: ${diagLines.size}")

        diagLines.forEach { line ->
            logger.info("  $line")

            // Analyse spécifique par type de ligne
            when {
                line.contains("Project:") -> {
                    val projectName = line.substringAfter("Project:").trim()
                    logger.debug("    → Nom du projet extrait: '$projectName'")
                }
                line.contains("Dependencies count:") -> {
                    val count = line.substringAfter("count:").trim()
                    logger.debug("    → Nombre de dépendances: $count")
                }
            }
        }

        assertTrue(diagLines.isNotEmpty(), "Des lignes de diagnostic devraient être présentes")
        logger.info("✓ Analyse structurée terminée - ${diagLines.size} lignes analysées")
    }

    @Test
    fun `test with error handling`() {
        logger.info("TEST: Gestion d'erreurs avec logging")

        buildFile.writeText("""
            plugins {
                id("com.cheroliv.bakery")
            }
        """.trimIndent())

        try {
            logger.debug("Tentative d'exécution...")

            val result = GradleRunner.create()
                .forwardOutput()
                .withPluginClasspath()
                .withArguments("printJBakeClasspath")
                .withProjectDir(projectDir)
                .build()

            val outcome = result.task(":printJBakeClasspath")?.outcome
            logger.info("Outcome: $outcome")

            if (outcome == TaskOutcome.SUCCESS) {
                logger.info("✓ Exécution réussie")
            } else {
                logger.warn("⚠ Outcome inattendu: $outcome")
            }

            assertEquals(TaskOutcome.SUCCESS, outcome)

        } catch (e: Exception) {
            logger.error("✗ Échec du test", e)
            logger.error("Type d'exception: ${e.javaClass.simpleName}")
            logger.error("Message: ${e.message}")

            // Log de la stack trace si nécessaire
            if (logger.isDebugEnabled) {
                logger.debug("Stack trace complète:", e)
            }

            throw e
        }
    }
}

Çok seviyeli logging şeması

@startuml
!theme plain
skinparam backgroundColor #FEFEFE

package "Niveaux de Logging" {

    rectangle "TRACE" #E8E8E8 {
        note
            Détails d'exécution très fins
            • Valeurs de variables
            • Appels de méthodes internes
        end note
    }

    rectangle "DEBUG" #D0E8FF {
        note
            Informations de débogage
            • Étapes intermédiaires
            • Contenu des fichiers créés
            • Analyse de la sortie Gradle
        end note
    }

    rectangle "INFO" #C8FFC8 {
        note
            Informations principales
            • Début/fin de test
            • Résultats de validation
            • Durée d'exécution
        end note
    }

    rectangle "WARN" #FFFFC8 {
        note
            Avertissements
            • Comportements inattendus
            • Outcomes inhabituels
        end note
    }

    rectangle "ERROR" #FFC8C8 {
        note
            Erreurs
            • Échecs de test
            • Exceptions
            • Stack traces
        end note
    }
}

@enduml

En İyi Uygulamalar

Önerilen Kayıt Yapısı

@Test
fun `mon test`() {
    // 1. LOG: Début du test
    logger.info("TEST: Description du test")

    // 2. LOG: Préparation
    logger.debug("Préparation des fichiers...")
    buildFile.writeText("...")
    logger.debug("✓ Fichiers préparés")

    // 3. LOG: Exécution
    logger.debug("Lancement du build...")
    val startTime = System.currentTimeMillis()

    val result = GradleRunner.create()
        .forwardOutput()
        .withArguments("task")
        .build()

    val duration = System.currentTimeMillis() - startTime
    logger.info("Build terminé en ${duration}ms")

    // 4. LOG: Analyse
    logger.debug("Analyse des résultats...")
    // ... validations ...

    // 5. LOG: Conclusion
    logger.info("✓ Test réussi")
}

Görsel Simgeler Okunabilirliği İçin

Günlüklerin okunabilirliğini artırmak için Unicode sembolleri kullanın:

logger.info("✓ Succès")
logger.info("✗ Échec")
logger.info("⚠ Attention")
logger.info("→ Étape suivante")
logger.info("═".repeat(60)) // Séparateur principal
logger.info("─".repeat(60)) // Séparateur secondaire

Çıktıdan bilgi çıkarma

// Recherche simple
val hasMessage = result.output.contains("Expected message")

// Extraction avec regex
val versionRegex = Regex("Version: (.+)")
val version = versionRegex.find(result.output)?.groupValues?.get(1)

// Filtrage de lignes
val errorLines = result.output.lines()
    .filter { it.contains("ERROR") }

errorLines.forEach { logger.error("Gradle error: $it") }

Önce/Sonra Karşılaştırması

Önce: Sorunlu Yaklaşım

class MyTest {
    lateinit var logger: Logger // ❌ Initialisé tardivement

    @BeforeEach
    fun setup() {
        logger = LoggerFactory.getLogger(...) // ❌ Dépendance
    }

    @Test
    fun test() {
        val runner = GradleRunner.create()
        logger.info("Testing...") // ⚠ Peut échouer
    }
}

Sonra : dayanıklı yaklaşım

class MyTest {
    companion object {
        private val logger = LoggerFactory.getLogger(...) // ✓ Indépendant
    }

    @Test
    fun test() {
        logger.info("TEST: Starting...") // ✓ Toujours disponible

        val result = GradleRunner.create()
            .forwardOutput() // ✓ Capture la sortie
            .build()

        logger.debug("Output: ${result.output}") // ✓ Analyse
    }
}

Özet Diyagramı

@startuml
!theme plain
skinparam backgroundColor #FEFEFE

title Flux Complet de Logging dans les Tests Fonctionnels

actor "Développeur" as dev
participant "JUnit" as junit
participant "TestClass" as test
participant "Logger\n(SLF4J)" as logger
participant "Logback" as logback
participant "GradleRunner" as runner
participant "Console/File" as output

dev -> junit : lance les tests

activate junit
junit -> test : charge la classe
activate test

test -> logger : initialize (companion)
activate logger
logger -> logback : configure
activate logback
logback --> logger : ready
deactivate logback
logger --> test : ✓
deactivate logger

== Phase de Setup ==

junit -> test : @BeforeAll
test -> logger : info("Initialisation")
logger -> output : [INFO] Initialisation

junit -> test : @BeforeEach
test -> logger : debug("Setup test")
logger -> output : [DEBUG] Setup test

== Phase de Test ==

junit -> test : @Test
test -> logger : info("TEST: Starting")
logger -> output : [INFO] TEST: Starting

test -> runner : create()
activate runner

test -> runner : .forwardOutput()
test -> runner : .build()

runner -> runner : execute Gradle
runner --> test : BuildResult + output text
deactivate runner

test -> logger : debug("Analyzing output")
logger -> output : [DEBUG] Analyzing output

test -> test : assertions
test -> logger : info("✓ Test passed")
logger -> output : [INFO] ✓ Test passed

== Phase de Cleanup ==

junit -> test : @AfterEach
test -> logger : debug("Cleanup")
logger -> output : [DEBUG] Cleanup

junit -> test : @AfterAll
test -> logger : info("Fin des tests")
logger -> output : [INFO] Fin des tests

test --> junit : ✓ all tests passed
deactivate test
deactivate junit

dev <-- junit : rapport de tests

@enduml

Tuzaktan Kaçınma

❌ Tuzak 1: Unutmak `.forwardOutput()

// MAUVAIS: La sortie Gradle n'est pas capturée
val result = GradleRunner.create()
    .withArguments("task")
    .build()

// result.output sera vide !
// BON: La sortie est capturée
val result = GradleRunner.create()
    .forwardOutput() // ✓
    .withArguments("task")
    .build()

❌ Tuzak n°2 : Logger Başlatılmamış

// MAUVAIS
class Test {
    lateinit var logger: Logger // Peut ne pas être initialisé
}
// BON
class Test {
    companion object {
        private val logger = LoggerFactory.getLogger(...) // Toujours initialisé
    }
}

❌ Tuzak n°3 : Aşırı Günlükleme

// MAUVAIS: Trop de logs DEBUG en production
logger.debug("Variable a = $a")
logger.debug("Variable b = $b")
logger.debug("Variable c = $c")
// ... 100 lignes de logs
// BON: Logging ciblé
logger.info("Traitement de ${items.size} éléments")
logger.debug("Détails: ${items.take(5)}...") // Échantillon seulement

Sonuçlar ve Metrikler

Bu yaklaşımla şunları elde edersiniz:

metrik Önce Sonra

kod satırları

~50 satır

~80 satır (%60 artış)

debug süresi

~30 dakika

~5 dakika (-83%)

Tespit edilen hatalar

temel

detaylı

Bakım

zor

basit

bağımsızlık

❌ bağlantılı

✓ bağımsız

Sonuç

Gradle eklentilerinin fonksiyonel testlerinde sağlam bir logging sistemi kurmak, basit bir prensibe dayanır.bağımsızlık.

Önemli noktalar:

  1. Bağımsız Logger: bir içinde başlatılmış`companion object`

  2. SLF4J + Logback: Test edilmiş ve yapılandırılabilir yığın

  3. .forwardOutput(): Gradle çıktısı yakalama

  4. Yapılandırılmış günlük: INFO adımlar için, DEBUG ayrıntılar için

  5. Çıkış AnaliziBilgi çıkarımı`result.output`

Bu yaklaşım testleri garantiler.sürdürülebilir, hata ayıklanabilir et güçlü, sağlam tasarım ilkelerini saygı göstererek

Daha ileri git

Bir sonraki makalede araştıracağız :

  • Yapılandırılmış günlüklemeyle sürekli entegrasyon

  • Yerleşik loglarla test HTML raporları

  • JVM attach ile gelişmiş hata ayıklama

  • Günlük kaydı performansları ve iyileştirmeleri

Bu makale teknik blogumda yer alan "Gradle Eklenti Geliştirme" serisinin bir parçasıdır.

İlgili Makaleler