การบันทึก Log อย่างมีประสิทธิภาพในการทดสอบฟังก์ชันของปลั๊กอิน 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 ?
}
ความขัดแย้งของการบันทึก
ปัญหาหลักคือดังต่อไปนี้ :การบันทึก log ไม่ควรพึ่งพาวัตถุที่เราไม่แน่ใจว่ามีการเริ่มต้น (initialization) แล้ว.
@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
ความคิดดี ๆ ที่ผิด
|
อย่าทำอย่างนี้ :
|
แนวทางแก้ไข : ล็อกเกอร์อิสระ
สถาปัตยกรรมที่แนะนำ
วิธีแก้ปัญหาคือการใช้ loggerอย่างสมบูรณ์เป็นอิสระ 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
logger ถูกเริ่มต้น โดยไม่ขึ้นอยู่กับ `GradleRunner
end note
note bottom of GradleRunner
.forwardOutput() อนุญาตให้
จับผลลัพธ์
เพื่อการวิเคราะห์
end note
@enduml
การไหลของการดำเนินการ
@startuml
!theme plain
skinparam backgroundColor #FEFEFE
participant "JUnit" as junit
participant "TestClass" as test
participant "บันทึก (SLF4J)" as logger
participant "GradleRunner" as runner
participant "Gradle สร้าง" as gradle
activate junit
junit -> test : โหลดคลาส
activate test
test -> logger : เริ่มต้น (เพื่อนร่วม)
activate logger
logger --> test : logger พร้อม ✓
deactivate logger
junit -> test : @BeforeAll
test -> logger : info("Initialisation...")
junit -> test : @BeforeEach
test -> logger : debug("ตั้งค่าการทดสอบ...")
junit -> test : @Test
test -> logger : info("TEST: กำลังเริ่มต้น...")
test -> runner : create()
activate runner
test -> runner : .forwardOutput()
test -> runner : .withArguments(...)
test -> runner : .build()
runner -> gradle : ดำเนินการบิลด์
activate gradle
gradle --> runner : เอาต์พุต ข้อความ
deactivate gradle
runner --> test : BuildResult + output
deactivate runner
test -> logger : debug("กำลังวิเคราะห์ผลลัพธ์...")
test -> test : วิเคราะห์เอาท์พุต
test -> logger : info("✓ การทดสอบผ่าน")
test --> junit : ความสำเร็จ
deactivate test
deactivate junit
@enduml
การดำเนินการอย่างสมบูรณ์
การพึ่งพา Gradle
เพิ่มการพึ่งพาที่จำเป็นในของคุณ`build.gradle.kts`(Empty)
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 "ระดับการบันทึก log" {
rectangle "TRACE" #E8E8E8 {
note
รายละเอียดการทำงานอย่างละเอียด
• ค่าตัวแปร
• การเรียกเมธอดภายใน
end note
}
rectangle "DEBUG" #D0E8FF {
note
ข้อมูลการดีบัก
• ขั้นตอนกลาง
• เนื้อหาของไฟล์ที่สร้าง
• การวิเคราะห์ของเอาต์พุต Gradle
end note
}
rectangle "INFO" #C8FFC8 {
note
ข้อมูลหลัก
• เริ่ม/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("Setup test")
logger -> output : [DEBUG] ทดสอบการตั้งค่า
== Phase de Test ==
junit -> test : @Test
test -> logger : info("TEST: เริ่ม")
logger -> output : [INFO] TEST: เริ่มต้น
test -> runner : create()
activate runner
test -> runner : .forwardOutput()
test -> runner : .build()
runner -> runner : ดำเนินการ Gradle
runner --> test : BuildResult
deactivate runner
test -> logger : debug("Analyzing output")
logger -> output : [DEBUG] การวิเคราะห์ผลลัพธ์
test -> test : ข้อความยืนยัน
test -> logger : info("✓ ทดสอบผ่าน")
logger -> output : [INFO] ✓ การทดสอบผ่าน
== Phase de Cleanup ==
junit -> test : @AfterEach
test -> logger : debug("ทำความสะอาด")
logger -> output : [DEBUG] Cleanup
junit -> test : @AfterAll
test -> logger : info("สิ้นสุดการทดสอบ")
logger -> output : [INFO] การทดสอบสิ้นสุด
test --> junit : ✓ การทดสอบทั้งหมดผ่าน
deactivate test
deactivate junit
dev <-- junit : รายงานการทดสอบ
@enduml
กับดักที่ควรหลีกเลี่ยง
กับดักข้อที่ 1: ลืม `.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é
}
}
กับดัก n°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
ผลลัพธ์และตัวชี้วัด
ด้วยวิธีนี้ คุณจะได้ :
| เมทริค | ก่อน | หลังจาก |
|---|---|---|
บรรทัดของโค้ด |
(empty) |
ประมาณ 80 บรรทัด (+60%) |
เวลาการแก้ไขบั๊ก |
ประมาณ 30 นาที |
~5 นาที (-83%) |
พบข้อผิดพลาด |
พื้นฐาน |
โดยละเอียด |
การบำรุงรักษา |
ยาก |
ง่าย |
ความเป็นอิสระ |
คู่ |
เป็นอิสระ |
สรุป
การจัดทำระบบบันทึกที่แข็งแกร่งภายในการทดสอบฟังก์ชันของปลั๊กอิน Gradle ขึ้นอยู่กับหลักการง่ายๆ :อิสรภาพ.
ประเด็นสำคัญที่ต้องจำ :
-
Logger อิสระ: เริ่มต้นใน`companion object`
-
SLF4J + Logbackสแต็คที่ผ่านการทดสอบแล้วและสามารถกำหนดค่าได้
-
.forwardOutput()การบันทึกผลลัพธ์ของ Gradle
-
บันทึกแบบมีโครงสร้าง: ข้อมูล สำหรับ ขั้นตอน , แก้ไข สำหรับ รายละเอียด
-
การวิเคราะห์เอาต์พุตการดึงข้อมูลตั้งแต่`result.output`
วิธีการนี้รับประกันการทดสอบสามารถบำรุงได้, ที่สามารถดีบั๊กได้ et แข็งแรง, โดยเคารพต่อหลักการออกแบบที่มั่นคง.
ไปให้ไกลขึ้น
ในบทความถัดไป เราจะสำรวจ :
-
การบูรณาการต่อเนื่องกับการบันทึกล็อกที่มีโครงสร้าง
-
รายงานทดสอบ HTML พร้อมบันทึกที่ฝังอยู่
-
การดีบั๊กขั้นสูงโดยการเชื่อมต่อ JVM
-
ประสิทธิภาพและการเพิ่มประสิทธิภาพของการบันทึก
บทความนี้เป็นส่วนหนึ่งของชุด "การพัฒนาปลั๊กอิน Gradle" บนบล็อกเทคนิคของฉัน。