บทนำ

ในการพัฒนาปลั๊กอิน 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

ความคิดดี ๆ ที่ผิด

อย่าทำอย่างนี้ :

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

แนวทางแก้ไข : ล็อกเกอร์อิสระ

สถาปัตยกรรมที่แนะนำ

วิธีแก้ปัญหาคือการใช้ 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 ขึ้นอยู่กับหลักการง่ายๆ :อิสรภาพ.

ประเด็นสำคัญที่ต้องจำ :

  1. Logger อิสระ: เริ่มต้นใน`companion object`

  2. SLF4J + Logbackสแต็คที่ผ่านการทดสอบแล้วและสามารถกำหนดค่าได้

  3. .forwardOutput()การบันทึกผลลัพธ์ของ Gradle

  4. บันทึกแบบมีโครงสร้าง: ข้อมูล สำหรับ ขั้นตอน , แก้ไข สำหรับ รายละเอียด

  5. การวิเคราะห์เอาต์พุตการดึงข้อมูลตั้งแต่`result.output`

วิธีการนี้รับประกันการทดสอบสามารถบำรุงได้, ที่สามารถดีบั๊กได้ et แข็งแรง, โดยเคารพต่อหลักการออกแบบที่มั่นคง.

ไปให้ไกลขึ้น

ในบทความถัดไป เราจะสำรวจ :

  • การบูรณาการต่อเนื่องกับการบันทึกล็อกที่มีโครงสร้าง

  • รายงานทดสอบ HTML พร้อมบันทึกที่ฝังอยู่

  • การดีบั๊กขั้นสูงโดยการเชื่อมต่อ JVM

  • ประสิทธิภาพและการเพิ่มประสิทธิภาพของการบันทึก

บทความนี้เป็นส่วนหนึ่งของชุด "การพัฒนาปลั๊กอิน Gradle" บนบล็อกเทคนิคของฉัน。

บทความที่เกี่ยวข้อง