Efikasno logiranje u funkcionalnim testovima Gradle dodataka
Објављено 07 November 2025
Увод
Tijekom razvoja Gradle plugina, funkcionalni testovi su bitni da se provjeri ponašanje plugina u realnom Gradle okruženju. Međutim, brzo se javlja ključno pitanje:Как да поставим ефикасен систем за логовање без стварања проблемних зависности са GradleRunner?
У овој чланку, ћемо istražiti:
-
Проблематика логовања у функционалним тестовима Gradle
-
Архитектура стабилног решења са SLF4J и Logback
-
Пуна имплементација са конкретним примерима
-
Dobre prakse i greške koje treba izbegnuti
Problem: Zavisnost od GradleRunner
Kontekst razvoja plugina
Kada se razvija Gradle plugin, obično se koristi`GradleRunner`од Gradle TestKit za izvršavanje test buildova :
@Test
fun `test my plugin`() {
val result = GradleRunner.create()
.withProjectDir(projectDir)
.withArguments("myTask")
.build()
// Comment logger efficacement ici ?
}
Dilema logiranja
Glavni problem je sledeći:Логовање не сме да зависи од објекта чија иницијализација нам није сигурна..
@startuml
!define RECTANGLE class
skinparam backgroundColor #FEFEFE
skinparam componentStyle rectangle
package "Problematika arhitekture" {
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
Лажне добре идеје
|
Ne radite to :
|
Rešenje: Nezavisni logger
Preporučena arhitektura
Решење је да се користи логер.potpuno nezavisno du GradleRunner, inicijalizovan na nivou klase testa.
@startuml
!define RECTANGLE class
skinparam backgroundColor #FEFEFE
skinparam componentStyle rectangle
package "Ispravna arhitektura" {
RECTANGLE TestClass {
+ {static} logger: Logger ✓
+ runTest()
}
RECTANGLE SLF4J {
+ LoggerFactory
}
RECTANGLE GradleRunner {
+ build()
+ forwardOutput()
}
RECTANGLE "Izlaz konzole" 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
Tok izvršenja
@startuml
!theme plain
skinparam backgroundColor #FEFEFE
participant "JUnit" as junit
participant "TestClass" as test
participant "Logger (SLF4J)" as logger
participant "GradleRunner" as runner
participant "Gradle Build" 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("Подеси тест...")
junit -> test : @Test
test -> logger : info("TEST: Starting...")
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("Analiziranje izlaza...")
test -> test : analyze output
test -> logger : info("✓ Test prošao")
test --> junit : success
deactivate test
deactivate junit
@enduml
Пуна имплементација
Gradle zavisnosti
Додајте необходиве зависимост у вашем`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()
}
Konfiguracija Logback
Napravite fajl`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>
kompletna test klasa
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
}
}
}
Шема вишестепног логовања
@startuml
!theme plain
skinparam backgroundColor #FEFEFE
package "Нива логирања" {
rectangle "трага" #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 "Предупреди" #FFFFC8 {
note
Avertissements
• Comportements inattendus
• Outcomes inhabituels
end note
}
rectangle "Грешка" #FFC8C8 {
note
Erreurs
• Échecs de test
• Exceptions
• Stack traces
end note
}
}
@enduml
Dobre prakse
Препоручена структура логова
@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")
}
Vizuelni simboli za čitljivost
Koristite Unicode simbole za poboljšanje čitljivosti logova :
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
Extrakcija informacija iz izlaza
// 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") }
Пoreђење пре/пост
Pre : Problematičan pristup
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
}
}
Posle: robustni pristup
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
}
}
дијаграм рекапитулативан
@startuml
!theme plain
skinparam backgroundColor #FEFEFE
title Pun tok logovanja u funkcionalnim testovima
actor "razvojnik" as dev
participant "JUnit" as junit
participant "TestClass" as test
participant "Бележник
(SLF4J)" as logger
participant "Logback" as logback
participant "GradleRunner" as runner
participant "Konzola/Fajl" 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("Иницијализација")
logger -> output : [INFO] Initialisation
junit -> test : @BeforeEach
test -> logger : debug("Тест подешавања")
logger -> output : [DEBUG] Setup test
== Phase de Test ==
junit -> test : @Test
test -> logger : info("TEST: Pokretanje")
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("Analiziranje izlaza")
logger -> output : [DEBUG] Analyzing output
test -> test : assertions
test -> logger : info("✓ Test réussi")
logger -> output : [INFO] ✓ Test passed
== Phase de Cleanup ==
junit -> test : @AfterEach
test -> logger : debug("Čišćenje")
logger -> output : [DEBUG] Cleanup
junit -> test : @AfterAll
test -> logger : info("Kraj testova")
logger -> output : [INFO] Fin des tests
test --> junit : ✓ all tests passed
deactivate test
deactivate junit
dev <-- junit : rapport de tests
@enduml
Ловце које треба избегати
Greška broj 1: Zaboraviti `.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()
❌ Засада број 2 : Logger није иницијализован
// MAUVAIS
class Test {
lateinit var logger: Logger // Peut ne pas être initialisé
}
// BON
class Test {
companion object {
private val logger = LoggerFactory.getLogger(...) // Toujours initialisé
}
}
❌ Ловушка број 3 : Превише логирања
// 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
Rezultati i metrike
Sa ovim pristupom, dobijate:
| metrički | Pre | Posle |
|---|---|---|
Linije koda |
~50 linija |
~80 линија (+60%) |
Време за дебаг |
~30 min |
~5 min (-83%) |
Pronađene greške |
Osnovno |
detaljan |
Održavanje |
тешко |
jednostavan |
Независност |
❌ spojena |
✓ независна |
Закључак
Uvođenje snažnog sistema logiranja u funkcionalnim testovima Gradle pluginova zasniva se na jednostavnom principu :независност.
Кључне тачке за запамтити :
-
Samostalni logger: Inicijalizovan u`companion object`
-
SLF4J + Logbacktestiran i podešiv stek
-
.forwardOutput(): Zabilježavanje izlaza Gradle
-
Strukturovano logiranje: INFO za korake, DEBUG za detalje
-
Analiza izlaza: Izvlačenje informacija iz`result.output`
Ovaj pristup garantuje testoviodržljivi, debuggables et jaki, poštujući principe solidnog dizajna.
Resursi
Idete dalje
У следећем чланку ћемо истражити:
-
Континуусна интеграција са структурираним логовањем
-
HTML izveštaji o testovima sa ugrađenim logovima
-
Напредно дебаговање са JVM attach
-
Performanse i optimizacije logovanja
Ovaj članak je deo serije "Razvoj Gradle pluginova" na mom tehničkom blogu.