Giới thiệu

Khi phát triển một plugin Gradle, các bài kiểm tra chức năng là rất thiết yếu để xác nhận hành vi của plugin trong một môi trường Gradle thực tế. Tuy nhiên, một câu hỏi quan trọng nhanh chóng đặt ra là:làm thế nào để thiết lập một hệ thống logging hiệu quả mà không tạo ra các phụ thuộc gây vấn đề với GradleRunner ?

Trong bài viết này, chúng ta sẽ cùng khám phá:

  • Vấn đề logging trong các bài kiểm tra chức năng Gradle

  • Kiến trúc của một giải pháp mạnh mẽ với SLF4J và Logback

  • Việc triển khai đầy đủ với các ví dụ cụ thể

  • Các thực hành tốt nhất và những sai lầm cần tránh

Vấn đề: Phụ thuộc vào `GradleRunner

Bối cảnh Phát triển Plugin

Khi phát triển một plugin Gradle, người ta thường sử dụng`GradleRunner`Gradle TestKit để thực hiện các bản build kiểm thử:

@Test
fun `test my plugin`() {
    val result = GradleRunner.create()
        .withProjectDir(projectDir)
        .withArguments("myTask")
        .build()

    // Comment logger efficacement ici ?
}

Tiến thoái lưỡng nan trong Logging

Vấn đề chính là như sau:việc logging không được phụ thuộc vào một đối tượng mà ta không chắc chắn về việc khởi tạo của nóVui lòng cung cấp nội dung cần dịch.

@startuml
!define RECTANGLE class

skinparam backgroundColor #FEFEFE
skinparam componentStyle rectangle

package "Kiến trúc Vấn đề" {
    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
Vấn đề: Logger phụ thuộc vào `GradleRunner` có thể chưa được khởi tạo
end note

@enduml

Những Ý Tưởng Tưởng Chừng Là Hay Nhưng Thực Ra Là Sai

ĐỪNG làm điều này:

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

Giải pháp: Logger Độc lập

Kiến trúc Khuyến nghị

Giải pháp là sử dụng một loggerhoàn toàn độc lập du GradleRunner, được khởi tạo ở cấp độ lớp kiểm thử.

@startuml
!define RECTANGLE class

skinparam backgroundColor #FEFEFE
skinparam componentStyle rectangle

package "Kiến trúc Đúng" {
    RECTANGLE TestClass {
        + {static} logger: Logger ✓
        + runTest()
    }

    RECTANGLE SLF4J {
        + LoggerFactory
    }

    RECTANGLE GradleRunner {
        + build()
        + forwardOutput()
    }

    RECTANGLE "Kết quả Console" as Output {
        + captured text
    }

    TestClass --> SLF4J : utilise ✓
    TestClass --> GradleRunner : utilise
    GradleRunner --> Output : génère
    TestClass --> Output : analyse
}

note top of TestClass
Logger được khởi tạo
độc lập với `GradleRunner
end note

note bottom of GradleRunner
.forwardOutput()` cho phép
ghi lại đầu ra
để phân tích
end note

@enduml

Luồng Thực thi

@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("Thiết lập kiểm thử...")

junit -> test : @Test
test -> logger : info("TEST: Đang bắt đầu...")

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("Đang phân tích đầu ra...")
test -> test : analyze output
test -> logger : info("✓ Kiểm thử thành công")

test --> junit : success
deactivate test
deactivate junit

@enduml

Triển khai Hoàn tất

Dependencies Gradle

Thêm các phụ thuộc cần thiết vào`build.gradle.kts`Vui lòng cung cấp văn bản cần dịch.

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()
}

Cấu hình Logback

Tạo tệp`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>

Lớp Kiểm Thử Đầy Đủ

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
        }
    }
}

Sơ đồ Logging Đa cấp

@startuml
!theme plain
skinparam backgroundColor #FEFEFE

package "Các mức độ Logging" {

    rectangle "TRACE" #E8E8E8 {
        note
Chi tiết thực thi rất chi tiết
• Giá trị của biến
• Các lời gọi phương thức nội bộ
        end note
    }

    rectangle "DEBUG" #D0E8FF {
        note
Thông tin gỡ lỗi
• Các bước trung gian
• Nội dung của các tệp được tạo
• Phân tích đầu ra Gradle
        end note
    }

    rectangle "INFO" #C8FFC8 {
        note
Thông tin chính
• Bắt đầu/kết thúc kiểm tra
• Kết quả xác thực
• Thời gian thực thi
        end note
    }

    rectangle "WARN" #FFFFC8 {
        note
Cảnh báo
• Hành vi không mong muốn
• Kết quả bất thường
        end note
    }

    rectangle "ERROR" #FFC8C8 {
        note
Lỗi
• Lỗi kiểm thử
• Ngoại lệ
• Stack traces
        end note
    }
}

@enduml

Thực hành Tốt nhất

Cấu trúc Logging Khuyến nghị

@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")
}

Biểu tượng Trực quan để Tăng khả năng Đọc hiểu

Sử dụng các ký hiệu Unicode để cải thiện khả năng đọc của log:

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

Trích xuất Thông tin từ Đầu ra

// 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") }

So sánh Trước/Sau

Trước đây: Tiếp cận Vấn đề

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
    }
}

Sau: Tiếp cận Mạnh mẽ

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
    }
}

Sơ đồ Tổng kết

@startuml
!theme plain
skinparam backgroundColor #FEFEFE

title Luồng Logging đầy đủ trong Kiểm thử chức năng
actor "Nhà phát triển" 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 : 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("Khởi tạo")
logger -> output : [INFO] Initialisation

junit -> test : @BeforeEach
test -> logger : debug("Thiết lập kiểm thử")
logger -> output : [DEBUG] Setup test

== Phase de Test ==

junit -> test : @Test
test -> logger : info("TEST: Đang bắt đầu")
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("Đang phân tích đầu ra")
logger -> output : [DEBUG] Analyzing output

test -> test : assertions
test -> logger : info("✓ Kiểm thử thành công")
logger -> output : [INFO] ✓ Test passed

== Phase de Cleanup ==

junit -> test : @AfterEach
test -> logger : debug("Dọn dẹp")
logger -> output : [DEBUG] Cleanup

junit -> test : @AfterAll
test -> logger : info("Kết thúc kiểm thử")
logger -> output : [INFO] Fin des tests

test --> junit : ✓ all tests passed
deactivate test
deactivate junit

dev <-- junit : rapport de tests

@enduml

Những cạm bẫy cần tránh

❌ Bẫy số 1: Quên `.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()

❌ Bẫy số 2: Logger chưa được khởi tạo

// MAUVAIS
class Test {
    lateinit var logger: Logger // Peut ne pas être initialisé
}
// BON
class Test {
    companion object {
        private val logger = LoggerFactory.getLogger(...) // Toujours initialisé
    }
}

❌ Bẫy số 3: Logging Quá Mức

// 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

Kết quả và Chỉ số

Với cách tiếp cận này, bạn sẽ nhận được:

Số đo Trước Sau

Các dòng mã

~50 dòng

~80 dòng (+60%)

Thời gian debug

~30 phút

~5 phút (-83%)

Phát hiện lỗi

Cơ bản

Chi tiết

Bảo trì

Khó

Đơn giản

Độc lập

❌ Được kết hợp

✓ Độc lập

Kết luận

Việc triển khai một hệ thống logging mạnh mẽ trong các bài kiểm tra chức năng của plugin Gradle dựa trên một nguyên tắc đơn giản:sự độc lậpVui lòng cung cấp nội dung cần dịch.

Các điểm chính cần lưu ý:

  1. Trình ghi nhật ký độc lập: Được khởi tạo trong một`companion object`

  2. SLF4J + Logback: Stack đã được kiểm chứng và có thể cấu hình

  3. .forwardOutput(): Chụp đầu ra Gradle

  4. Logging có cấu trúc: INFO cho các bước, DEBUG cho chi tiết

  5. Phân tích đầu ra: Trích xuất thông tin từ`result.output`

Cách tiếp cận này đảm bảo các bài kiểm tracó thể bảo trì, các đối tượng có thể gỡ lỗi et mạnh mẽ, đồng thời tuân thủ các nguyên tắc thiết kế bền vững.

Tìm hiểu thêm

Trong một bài viết sắp tới, chúng tôi sẽ khám phá:

  • Tích hợp liên tục với logging có cấu trúc

  • Các báo cáo kiểm thử HTML với logs tích hợp

  • Gỡ lỗi nâng cao với JVM attach

  • Hiệu suất và tối ưu hóa logging

Bài viết này là một phần của loạt bài "Développement de Plugins Gradle" trên blog kỹ thuật của tôi.

Bài viết liên quan