Introducción

Durante el desarrollo de un plugin Gradle, las pruebas funcionales son esenciales para validar el comportamiento del plugin en un entorno Gradle real. Sin embargo, surge rápidamente una pregunta crucial:¿cómo implementar un sistema de registro eficaz sin crear dependencias problemáticas con el GradleRunner?

En este artículo, vamos a explorar:

  • La problemática del logging en las pruebas funcionales de Gradle

  • La arquitectura de una solución robusta con SLF4J y Logback

  • La implementación completa con ejemplos concretos

  • Las buenas prácticas y trampas a evitar

El Problema : Dependencia al GradleRunner

Contexto del desarrollo de plugin

Cuando se desarrolla un plugin Gradle, se suele utilizar el`GradleRunner`del Gradle TestKit para ejecutar compilaciones de prueba :

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

    // Comment logger efficacement ici ?
}

El dilema del registro

El problema principal es el siguiente:el registro no debe depender de un objeto del que no estemos seguros de su inicialización.

@startuml
!define RECTANGLE class

skinparam backgroundColor #FEFEFE
skinparam componentStyle rectangle

package "Arquitectura problemática" {
    RECTANGLE Test {
        + runTest()
    }

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

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

    Test --> GradleRunner : depende de
    Logger ..> GradleRunner : depende de ❌
}

note right of Logger
Problema:
El logger depende del GradleRunner que puede no estar inicializado
end note

@enduml

Las Falsas Buenas Ideas

No hagas esto:

// ❌ 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(...)
}

La Solución : Logger Independiente

Arquitectura Recomendada

La solución consiste en utilizar un loggertotalmente independiente du GradleRunner, inicializado a nivel de la clase de prueba.

@startuml
!define RECTANGLE class

skinparam backgroundColor #FEFEFE
skinparam componentStyle rectangle

package "Arquitectura Correcta" {
    RECTANGLE TestClass {
        + {static} logger: Logger ✓
        + runTest()
    }

    RECTANGLE SLF4J {
        + LoggerFactory
    }

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

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

    TestClass --> SLF4J : utiliza ✓
    TestClass --> GradleRunner : utiliza
    GradleRunner --> Output : genera
    TestClass --> Output : análisis
}

note top of TestClass
El logger está inicializado independientemente de GradleRunner
end note

note bottom of GradleRunner
.forwardOutput() permite
para capturar la salida
para análisis
end note

@enduml

Flujo de ejecución

@startuml
!theme plain
skinparam backgroundColor #FEFEFE

participant "JUnit" as junit
participant "TestClass" as test
participant "Logger (SLF4J)" as logger
participant "GradleRunner" as runner
participant "Construcción Gradle" 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("Prueba de instalación...")

junit -> test : @Test
test -> logger : info("TEST: Iniciando...")

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("Analizando salida...")
test -> test : analyze output
test -> logger : info("�✓ Prueba superada")

test --> junit : success
deactivate test
deactivate junit

@enduml

Implementación Completa

Dependencias de Gradle

Añade las dependencias necesarias en su`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()
}

Configuración Logback

Cree el archivo`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>

Clase de Prueba Completa

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
        }
    }
}

Esquema de registro multinivel

@startuml
!theme plain
skinparam backgroundColor #FEFEFE

package "Niveles de registro" {

    rectangle "TRACE" #E8E8E8 {
        note
Detalles de ejecución muy finos
• Valores de variables
• Llamadas a métodos internos
        end note
    }

    rectangle "DEBUG" #D0E8FF {
        note
Información de depuración • Pasos intermedios • Contenido de los archivos creados • Análisis de la salida Gradle
        end note
    }

    rectangle "INFO" #C8FFC8 {
        note
Información principal
• Inicio/fin de prueba
• Resultados de validación
• Duración de ejecución
        end note
    }

    rectangle "WARN" #FFFFC8 {
        note
Advertencias
• Comportamientos inesperados
• Resultados inusuales
        end note
    }

    rectangle "ERROR" #FFC8C8 {
        note
Errores
• Fallos de prueba
• Excepciones
• Trazas de pila
        end note
    }
}

@enduml

Buenas Prácticas

Estructura de Logging recomendada

@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")
}

Símbolos Visuales para la Legibilidad

Utilice símbolos Unicode para mejorar la legibilidad de los registros:

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

Extracción de Información de la Salida

// 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") }

Comparación Antes/Después

Antes : Enfoque problemático

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
    }
}

Después: Enfoque Robusto

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
    }
}

Diagrama Resumen

@startuml
!theme plain
skinparam backgroundColor #FEFEFE

title Flujo completo de registro en pruebas funcionales
actor "Desarrollador" 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 : ejecuta las pruebas

activate junit
junit -> test : carga la clase
activate test

test -> logger : inicializar (compañero)
activate logger
logger -> logback : configura
activate logback
logback --> logger : listo
deactivate logback
logger --> test : ¿Podrías proporcionar el fragmento en francés que deseas traducir al español?
deactivate logger

== Phase de Setup ==

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

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

== Phase de Test ==

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

test -> runner : create()
activate runner

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

runner -> runner : ejecutar Gradle
runner --> test : BuildResult + texto de salida
deactivate runner

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

test -> test : afirmaciones
test -> logger : info("✓ Prueba aprobada")
logger -> output : [INFO] ✓ Prueba aprobada

== Phase de Cleanup ==

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

junit -> test : @AfterAll
test -> logger : info("Fin de las pruebas")
logger -> output : [INFO] Fin de las pruebas

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

dev <-- junit : informe de pruebas

@enduml

Trampas a Evitar

❌ Trampa nº1: Olvidar `.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()

❌ Trampa n°2 : Logger No Inicializado

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

❌ Trampa n°3 : Registro excesivo

// 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

Resultados y Métricas

Con este enfoque, obtienes:

métrico Antes Después

Líneas de código

~50 líneas

~80 líneas (+60%)

Tiempo de depuración

~30 minutos

~5 min (-83%)

Errores detectados

Básico

Detallado

Mantenimiento

Difícil

simple

Independencia

�❌ Acoplada

�✓ Independiente

Conclusión

La puesta en marcha de un sistema de registro robusto en las pruebas funcionales de los plugins de Gradle se basa en un principio simple :la independencia.

Puntos clave a tener en cuenta:

  1. Logger independiente: Iniciado en un`companion object`

  2. SLF4J + Logback: Stack probada y configurable

  3. .forwardOutput(): Captura de la salida de Gradle

  4. Logging estructurado: INFO para los pasos, DEBUG para los detalles

  5. Análisis de salida: Extracción de información desde`result.output`

Este enfoque garantiza pruebasmantenibles, depurables et robustos, manteniendo los principios de diseño sólidos.

Ir Más Lejos

En un próximo artículo, exploraremos :

  • Integración continua con registro estructurado

  • Los informes HTML de pruebas con registros integrados

  • Depuración avanzada con JVM attach

  • El rendimiento y las optimizaciones de registro

Este artículo forma parte de la serie "Desarrollo de complementos Gradle" en mi blog técnico.

Artículos relacionados