تسجيل فعال في الاختبارات الوظيفية للمكونات الإضافية لـ Gradle
نُشر في 07 November 2025
مقدمة
أثناء تطوير إضافة Gradle، تُعد الاختبارات الوظيفية ضرورية لتحقق سلوك الإضافة في بيئة Gradle حقيقية. ومع ذلك، يطرح سؤال حاسم نفسه بسرعة:كيف يمكن إعداد نظام تسجيل فعال دون إنشاء تبعات مشكّلة مع 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 : يعتمد على
Logger ..> GradleRunner : يعتمد على ❌
}
note right of Logger
المشكلة: يعتمد الـ logger على GradleRunner الذي قد لا يتم تهيئته
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 : يستخدم ✓
TestClass --> GradleRunner : استخدم
GradleRunner --> Output : يولّد
TestClass --> Output : تحليل
}
note top of TestClass
يتم تهيئة المسجّل بشكل مستقل عن GradleRunner
end note
note bottom of GradleRunner
.forwardOutput() يسمح
لتقاط المخرج
للتحليل
end note
@enduml
تدفق التنفيذ
@startuml
!theme plain
skinparam backgroundColor #FEFEFE
participant "جيونيت" as junit
participant "TestClass" 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
التنفيذ الكامل
تبعيات Gradle
أضف التبعيات الضرورية في`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 "TRACE" #E8E8E8 {
note
التفاصيل الدقيقة للتنفيذ • قيم المتغيرات • استدعاءات الطرق الداخلية
end note
}
rectangle "DEBUG" #D0E8FF {
note
معلومات التصحيح
• الخطوات الوسيطة
• محتوى الملفات التي تم إنشاؤها
• تحليل المخرج Gradle
end note
}
rectangle "INFO" #C8FFC8 {
note
المعلومات الرئيسية
• début/fin للاختبار
• نتائج التحقق
• مدة التنفيذ
end note
}
rectangle "WARN" #FFFFC8 {
note
تحذيرات
• سلوكيات غير متوقعة
• نتائج غير عادية
end note
}
rectangle "ERROR" #FFC8C8 {
note
أخطاء
• فشل الاختبار
• الاستثناءات
• تتبع المكدس
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")
}
الرموز البصرية للوضوح
استخدم رموز يونيكود لتحسين وضوح السجلات:
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 "Logger\n(SLF4J)" as logger
participant "Logback" as logback
participant "GradleRunner" as runner
participant "Console/File" as output
dev -> junit : نفذ الاختبارات
activate junit
junit -> test : تحميل الفئة
activate test
test -> logger : تهيئة (المُرافق)
activate logger
logger -> logback : ضبط
activate logback
logback --> logger : جاهز
deactivate logback
logger --> test : ✓
deactivate logger
== Phase de Setup ==
junit -> test : @BeforeAll
test -> logger : info("إعداد")
logger -> output : [INFO] التهيئة
junit -> test : @BeforeEach
test -> logger : debug("اختبار الإعداد")
logger -> output : [DEBUG] اختبار الإعداد
== 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 : نفّذ Gradle
runner --> test : BuildResult
deactivate runner
test -> logger : debug("تحليل المخرجات")
logger -> output : [DEBUG] تحليل الناتج
test -> test : ادعاءات
test -> logger : info("✓ اختبار ناجح")
logger -> output : [INFO] ✓ اختبار نجح
== Phase de Cleanup ==
junit -> test : @AfterEach
test -> logger : debug("تنظيف")
logger -> output : [DEBUG] تنظيف
junit -> test : @AfterAll
test -> logger : info("نهاية الاختبارات")
logger -> output : [INFO] نهاية الاختبارات
test --> junit : ✓ جميع الاختبارات نجحت
deactivate test
deactivate junit
dev <-- junit : تقرير اختبارات
@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()
�❌ فخ رقم 2 : مسجّل غير مهيأ
// 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
نتائج و مقاييس
مع هذا النهج، تحصل على :
| مترية | قبل | بعد |
|---|---|---|
أسطر الكود |
�≈ 50 سطرًا |
~80 أسطر (+60%) |
وقت تصحيح الأخطاء |
~30 دقيقة |
تقريبًا 5 دقائق (-83%) |
الأخطاء المكتشفة |
أساسي |
مفصل |
صيانة |
صعب |
بسيط |
استقلال |
�❌ مربوطة |
✓ مستقلة |
خاتمة
إنشاء نظام تسجيل قوي في اختبارات الوظائف للمكونات الإضافية لـ Gradle يقوم على مبدأ بسيط :الاستقلال.
النقاط الرئيسية التي يجب تذكرها:
-
مسجل مستقلمُهيّأ في`companion object`
-
SLF4J + Logbackمكدس مجرب وقابل للتكوين
-
.forwardOutput()التقاط مخرجات Gradle
-
التسجيل المنظم: معلومات للخطوات، تصحيح للتفاصيل
-
تحليل المخرجات: استخراج المعلومات من`result.output`
هذا النهج يضمن اختباراتقابلة للصيانة, القابلة للتصحيح et قوية, مع الالتزام بمبادئ التصميم السليمة
اذهب أبعد
في مقال قادم، سنستكشف:
-
التكامل المستمر مع التسجيل المنظم
-
تقارير HTML للاختبارات مع السجلات المدمجة
-
التصحيح المتقدم مع إرفاق JVM
-
الأداء وتحسينات التسجيل
هذه المقالة هي جزء من السلسلة "تطوير إضافات Gradle" على المدونتي التقنية.