ثبت لاگ影响 در تستهای عملکردی پلاگینهای Gradle
منتشر شده در 07 November 2025
معرفی
در طول توسعه یک پلاگین Gradle، تستهای عملکردی برای تأیید رفتار پلاگین در یک محیط Gradle واقعی ضروری هستند. با این حال، یک سؤال مهم به سرعت مطرح میشود:چگونه میتوانیم یک سیستم لاگگیری مؤثر را راهاندازی کنیم بدون ایجاد وابستگیهای Problematic با GradleRunner؟
در این مقاله، ما به بررسی میپردازیم:
-
مسئله لاگینگ در تستهای تابعی Gradle
-
معماری یک راهحل قوی با SLF4J و Logback
-
پیادهسازی کامل با مثالهای ملموس
-
روشهای خوب و خطراتی که باید اجتناب شوند
مشکل: وابستگی به GradleRunner
پیشزمینه توسعه پلاگین
هنگامی که یک پلاگین Gradle را توسعه میدهیم، معمولاً از`GradleRunner`با استفاده از Gradle TestKit برای اجرای ساختهای آزمایشی :
@Test
fun `test my plugin`() {
val result = GradleRunner.create()
.withProjectDir(projectDir)
.withArguments("myTask")
.build()
// Comment logger efficacement ici ?
}
دایلم لاگینگ
مسئله اصلی به این شکل است:لاگگیری نباید به یک شیء وابسته باشد که از مقداردهی اولیه آن مطمئن نباشیم.
@startuml
!define RECTANGLE class
skinparam backgroundColor #FEFEFE
skinparam componentStyle rectangle
package "معماری مشکلدار" {
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
ایدههای نادرست خوب
|
این کار را نکنید:
|
حل : لاگر مستقل
معماری توصیهشده
حل این است که از یک لاگر استفاده شود.کاملاً مستقل du GradleRunner, مقداردهی اولیه شده در سطح کلاس تست.
@startuml
!define RECTANGLE class
skinparam backgroundColor #FEFEFE
skinparam componentStyle rectangle
package "معماری صحیح" {
RECTANGLE TestClass {
+ {static} logger: Logger ✓
+ runTest()
}
RECTANGLE SLF4J {
+ LoggerFactory
}
RECTANGLE GradleRunner {
+ build()
+ forwardOutput()
}
RECTANGLE "خروجی کنسول" 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
جريان اجرا
@startuml
!theme plain
skinparam backgroundColor #FEFEFE
participant "JUnit" as junit
participant "کلاس تست" as test
participant "لاگر (SLF4J)" as logger
participant "GradleRunner" as runner
participant "ساخت 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("پیکربندی تست...")
junit -> test : @Test
test -> logger : info("تست: شروع...")
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("تحلیل خروجی...")
test -> test : analyze output
test -> logger : info("✓ تست موفق")
test --> junit : success
deactivate test
deactivate junit
@enduml
پیادهسازی کامل
وابستگیهای گرادل
افزودید وابستگیهای ضروری را در …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
فایل را ایجاد کنید`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>
کلاس تست کامل
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 "رفع اشکال" #D0E8FF {
note
Informations de débogage
• Étapes intermédiaires
• Contenu des fichiers créés
• Analyse de la sortie Gradle
end note
}
rectangle "اطلاعات" #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
بهترین روشها
ساختار لاگینگ پیشنهادی
@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")
}
نمادهای بصری برای خوانایی
از نمادهای Unicode برای افزایش خوانایی لاگها استفاده کنید:
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
استخراج اطلاعات از خروجی
// 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") }
مقایسه قبل/بعد
قبل : رویکرد مشکلمحور
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
}
}
پس : رویکرد مقاوم
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 جریان کامل لاگ در تستهای عملکردی
actor "توسعهدهنده" as dev
participant "JUnit" as junit
participant "TestClass" as test
participant "لاگر
(SLF4J)" as logger
participant "Logback" as logback
participant "GradleRunner" as runner
participant "کنسل/فایل" 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: شروع")
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("تحلیل خروجی")
logger -> output : [DEBUG] Analyzing output
test -> test : assertions
test -> logger : info("✓ تست موفق")
logger -> output : [INFO] ✓ Test passed
== Phase de Cleanup ==
junit -> test : @AfterEach
test -> logger : debug("پاکسازی")
logger -> output : [DEBUG] Cleanup
junit -> test : @AfterAll
test -> logger : info("پایان تستها")
logger -> output : [INFO] Fin des tests
test --> junit : ✓ all tests passed
deactivate test
deactivate junit
dev <-- junit : rapport de tests
@enduml
اجتناب از دامها
❌ پنچهٔ شماره یک : فراموش کردن `.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()
❌ فخ ۲ : لاگِر راهاندازی نشده
// MAUVAIS
class Test {
lateinit var logger: Logger // Peut ne pas être initialisé
}
// BON
class Test {
companion object {
private val logger = LoggerFactory.getLogger(...) // Toujours initialisé
}
}
❌ فخ شماره ۳ : لاگینگ مفرط
// 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
نتایج و معیارها
با این روش، شما دریافت میکنید:
| متریک | قبل | پس |
|---|---|---|
خطوط کد |
تقریباً ۵۰ خط |
~80 خطوط (+60%) |
زمان دیباگ |
تقریباً ۳۰ دقیقه |
~5 دقیقه (-83%) |
خطاهای شناسایی شده |
أساسي |
دقیق |
نگهداری |
سخت |
ساده |
استقلال |
پیوسته |
مستقل |
نتیجه
قرار دادن یک سیستم لاگینگ مقاوم در تستهای عملکردی پلاگینهای Gradle بر پایهٔ یک اصل ساده است :استقلال.
نکات کلیدی که باید به خاطر سپرد:
-
لاگر مستقل: در یک`companion object`
-
SLF4J و Logback: استاک آزمایششده و قابل تنظیم
-
.forwardOutput()گرفتن خروجی Gradle
-
لاگ منظم: INFO برای مراحل، DEBUG برای جزئیات
-
تحلیل خروجی: استخراج اطلاعات از`result.output`
این روش تستها را تضمین میکند.قابل نگهداری, قابل رفع اشکال et مستحکم, در حالی که به اصول طراحی پایدار پایبند است.
دورتر برو
در یک مقالهی آتی، ما مورد بررسی قرار خواهیم داد :
-
یکپارچهسازی متمرکز با لاگگیری ساختار یافته
-
گزارشهای HTML تستها با لاگهای ادغامشده
-
دیبگ پیشرفته با پیوستن JVM
-
عمل بهینهسازی و عملکرد لاگینگ
این مقاله بخشی از سری «توسعه پلاگین Gradle» در وبلاگ تکنیکی من است.