Gradle 플러그인 기능 테스트에서의 효과적인 로깅
게시: 07 November 2025
소개
Gradle 플러그인을 개발하는 동안, 기능 테스트는 실제 Gradle 환경에서 플러그인의 동작을 검증하는 데 필수적입니다. 그러나 중요한 질문이 빠르게 제기됩니다:어떻게 `GradleRunner`와 문제의존성을 만들지 않고 효과적인 로깅 시스템을 설정할 수 있나요?
이 글에서 탐구할 것:
-
Gradle 기능 테스트에서의 로깅 문제
-
SLF4J과 Logback을 사용한 견고한 솔루션의 아키텍처
-
완전한 구현과 구체적인 예시
-
좋은 관행과 피해야 할 함정
문제: GradleRunner 의존성
플러그인 개발의 맥락
그레이들 플러그인을 개발할 때 일반적으로 를 사용합니다`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
완전 구현
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 "흔적" #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")
}
가독성을 위한 시각적 상징
유니코드 기호를 사용하여 로그의 가독성을 향상시킵니다 :
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 "테스트클래스" as test
participant "Logger\n(SLF4J)" as logger
participant "로그백" 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("시작")
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
피해야 할 함정
❌ 함정 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번 : 초기화되지 않은 로거
// 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 min (-83%) |
감지된 오류 |
기본 |
상세한 |
유지보수 |
어렵다 |
간단한 |
독립 |
결합된 |
✓ 독립적인 |
결론
Gradle 플러그인의 기능 테스트에서 안정적인 로깅 시스템을 구축하는 것은 간단한 원칙에 기반합니다 :독립.
주요 포인트:
-
독립형 로거: 에서 초기화된`companion object`
-
SLF4J와 Logback: 검증되고 구성 가능한 Stack
-
.forwardOutput(): Gradle 출력 캡처
-
구조화된 로깅: INFO 단계용, DEBUG 세부정보용
-
출력 분석:부터 정보 추출`result.output`
이 접근 방식은 테스트를 보장합니다.유지 가능한, 디버그 가능한 et 튼튼한, 견고한 설계 원칙을 준수하면서.
더 멀리 가다
곧 다음 기사에서 탐구할 내용:
-
구조화된 로깅을 사용한 지속적 통합
-
로그가 포함된 HTML 테스트 보고서
-
JVM 연결 고급 디버깅
-
로깅의 성능 및 최적화
이 글은 내 기술 블로그의 "Gradle 플러그인 개발" 시리즈의 일부입니다.
관련 기사
31 May 2026
14 May 2026