문제 : Gradle 플러그인의 단위 테스트 격리

Gradle 플러그인에 대한 단위 테스트를 작성할 때 일반적인 도전 과제는 프로젝트 구성에 대한 의존성을 관리하는 것, 즉 파일에 정의된 속성과 같은 것`gradle.properties`. 우리의 경우, 플러그인`jbake.ghpages`속성을 읽어야 했다`site_config_path`올바르게 작동하기 위해. 작업을 위한 단위 테스트`initialize`이 속성이 존재할 때 플러그인의 동작을 확인해야 했다.

근본적인 문제는 단위 테스트가 설계상으로는 격리된 상태여야 한다는 것입니다. 사용하는 것은`ProjectBuilder`Gradle이 인스턴스를 생성합니다.Project`메모리에서, 파일 시스템에 있는 실제 프로젝트와 완전히 연결되지 않은 상태입니다. 따라서 이 테스트 인스턴스는 파일을 자동으로 읽지 않습니다.`gradle.properties`그리고 따라서 그 속성에 대해 알지 못합니다.`site_config_path.

문제 아키텍처

다음 다이어그램은 테스트 환경과 파일 시스템 사이의 분리 관계를 보여줍니다:

@startuml
!define RECTANGLE class

package "프로덕션 환경" {
  RECTANGLE ProjectReal {
    + build.gradle.kts
    + gradle.properties
    + src/
  }

  RECTANGLE GradleProperties {
    site_config_path=src/jbake/settings/site.yml
  }

  RECTANGLE JbakePlugin {
    - readProperty(name: String)
    + initialize()
  }

  ProjectReal --> GradleProperties : lit au démarrage
  JbakePlugin --> GradleProperties : accède via project.properties
}

package "테스트 환경" {
  RECTANGLE ProjectTest {
    + Créé par ProjectBuilder
    + En mémoire uniquement
    + Pas de gradle.properties
  }

  RECTANGLE JbakePluginTest {
    + testInitialize()
  }

  ProjectTest -[#red]x GradleProperties : ❌ Pas d'accès
  JbakePluginTest --> ProjectTest : utilise
}

note right of ProjectTest : Isolation = Pas d'accès\nau système de fichiers
@enduml

거짓 좋은 아이디어의 유혹

첫 번째 접근 방식은 파일을 생성하는 것일 수 있다.`gradle.properties`테스트 자원에서 가짜인

// src/test/resources/gradle.properties
site_config_path=src/jbake/settings/site.yml

그러나, 이 방법은 실패를 피할 수 없는 운명이기 때문에`ProjectBuilder`설계된 것이 파일 시스템을 스캔하여 구성 파일을 찾는 것이 아니므로, 테스트는 격리된 상태로 남아 이 파일을 무시합니다.

해결책 : `ExtraPropertiesExtension`를 사용해 속성을 시뮬레이션하기

이 문제의 우아한 솔루션은 파일을 읽는 것이 아니라, 객체에 속성의 존재를 직접 시뮬레이션*하는 것이다.`Project`de 테스트. Gradle은 이를 위한 강력한 메커니즘을 제공합니다: *추가 속성(Extra Properties).

1단계: ExtraPropertiesExtension 이해하기

`ExtraPropertiesExtension`이는 Gradle 모델의 각 객체에 연결된 키-값 컨테이너입니다. 이를 통해 프로젝트, 태스크 또는 기타 Gradle 확장에 동적으로 속성을 추가할 수 있습니다.

@startuml
!define RECTANGLE class

RECTANGLE Project {
  + name: String
  + version: String
  + extensions: ExtensionContainer
  + properties: Map<String, ?>
}

RECTANGLE ExtensionContainer {
  + getByType(Class<T>): T
  + add(String, Object): void
}

RECTANGLE ExtraPropertiesExtension {
  + set(String, Object): void
  + get(String): Object
  + has(String): boolean
  + getProperties(): Map<String, Object>
}

Project --> ExtensionContainer : contient
ExtensionContainer --> ExtraPropertiesExtension : gère
ExtraPropertiesExtension --> Project : synchronise avec\nproject.properties

note right of ExtraPropertiesExtension : Point d'entrée pour\ninjecter des propriétés
@enduml

2단계: 테스트 설정 - 준비

먼저, 우리 단위 테스트의 기본 구조를 만들어봅시다 :

JbakeGhPagesPluginTest.kt - Structure de base
class JbakeGhPagesPluginTest {

    @TempDir
    lateinit var testProjectDir: File

    @Test
    fun `check initialize and config yaml file if not existing`() {
        // Étape 1 : Créer un projet de test isolé
        val project = ProjectBuilder.builder()
            .withProjectDir(testProjectDir)
            .build()

        // Suite des étapes...
    }
}

단계 3: 속성 주입

다음은 테스트 프로젝트에 속성을 삽입하는 상세한 과정입니다:

JbakeGhPagesPluginTest.kt - Injection complète
@Test
fun `check initialize and config yaml file if not existing`() {
    // Étape 1 : Créer un projet de test isolé en mémoire
    val project = ProjectBuilder.builder()
        .withProjectDir(testProjectDir)
        .build()

    // Étape 2 : Récupérer le gestionnaire de propriétés supplémentaires
    val extra = project.extensions.getByType(ExtraPropertiesExtension::class.java)

    // Étape 3 : Définir la propriété requise pour ce test
    extra.set("site_config_path", "src/jbake/settings/site.yml")

    // Étape 4 : Vérifier que la propriété est bien injectée
    assertTrue(project.hasProperty("site_config_path"))

    // Étape 5 : Appliquer le plugin qui pourra maintenant accéder à la propriété
    project.plugins.apply("jbake.ghpages")

    // Étape 6 : Exécuter la tâche et vérifier son comportement
    val task: Task = project.tasks.findByName("initialize")
        .apply(::assertNotNull)!!

    // Étape 7 : Exécuter les actions de la tâche
    task.actions.forEach { it.execute(task) }

    // Étape 8 : Assertions finales
    assertEquals(
        "src/jbake/settings/site.yml",
        project.properties["site_config_path"],
        "La propriété devrait être accessible dans le projet"
    )
}

단계 4: 솔루션 흐름도

@startuml
start

:Créer ProjectBuilder;
:Obtenir ExtraPropertiesExtension;
:Injecter la propriété\nvia extra.set();

partition "검사" {
  :Vérifier project.hasProperty();
  :Appliquer le plugin;
  :Récupérer la tâche;
}

partition "실행" {
  :Exécuter les actions\nde la tâche;
  :Le plugin lit\nproject.properties;
}

partition "주장" {
  :Vérifier la propriété\nest accessible;
  :Valider le comportement\nattendues;
}

stop
@enduml

단계 5: 비교 전/후

@startuml
!define RECTANGLE class

package "이전 - 문제" {
  RECTANGLE TestProject1 {
    + ProjectBuilder.build()
    + Pas de propriétés
  }

  RECTANGLE Plugin1 {
    + initialize()
    + ❌ project.properties["사이트_설정_경로"] = null
  }

  TestProject1 --> Plugin1 : échec

  note right of Plugin1 : Test échoue car\nla propriété n'existe pas
}

package "후 - 해결책" {
  RECTANGLE TestProject2 {
    + ProjectBuilder.build()
    + ExtraPropertiesExtension
  }

  RECTANGLE ExtraProps {
    + set("사이트_설정_경로", "...")
  }

  RECTANGLE Plugin2 {
    + initialize()
    + ✅ project.properties["사이트_설정_경로"] = "src/jbake/settings/site.yml"
  }

  TestProject2 --> ExtraProps : configure
  ExtraProps --> Plugin2 : propriété disponible

  note right of Plugin2 : Test réussit car\nla propriété est simulée
}
@enduml

단계 6 : 다중 테스트 사례 관리

다양한 시나리오를 테스트하기 위해 다양한 구성의 여러 테스트를 만들어 봅시다 :

Tests multiples avec configurations différentes
class JbakeGhPagesPluginTest {

    @TempDir
    lateinit var testProjectDir: File

    private fun createProjectWithProperty(propertyValue: String?): Project {
        val project = ProjectBuilder.builder()
            .withProjectDir(testProjectDir)
            .build()

        // Injection conditionnelle de la propriété
        propertyValue?.let { value ->
            val extra = project.extensions.getByType(ExtraPropertiesExtension::class.java)
            extra.set("site_config_path", value)
        }

        return project
    }

    @Test
    fun `should work with valid property`() {
        val project = createProjectWithProperty("src/jbake/settings/site.yml")
        project.plugins.apply("jbake.ghpages")

        val task = project.tasks.findByName("initialize")!!
        task.actions.forEach { it.execute(task) }

        // Assertions pour le cas normal
        assertEquals("src/jbake/settings/site.yml", project.properties["site_config_path"])
    }

    @Test
    fun `should handle missing property gracefully`() {
        val project = createProjectWithProperty(null) // Pas de propriété
        project.plugins.apply("jbake.ghpages")

        val task = project.tasks.findByName("initialize")!!

        // Le plugin devrait gérer l'absence de propriété
        assertDoesNotThrow {
            task.actions.forEach { it.execute(task) }
        }
    }

    @Test
    fun `should handle invalid property path`() {
        val project = createProjectWithProperty("invalid/path/to/config.yml")
        project.plugins.apply("jbake.ghpages")

        val task = project.tasks.findByName("initialize")!!

        // Test du comportement avec un chemin invalide
        assertThrows<FileNotFoundException> {
            task.actions.forEach { it.execute(task) }
        }
    }
}

솔루션의 최종 아키텍처

@startuml
!define RECTANGLE class

package "테스트 환경" {
  RECTANGLE TestClass {
    + createProjectWithProperty()
    + testValidProperty()
    + testMissingProperty()
    + testInvalidProperty()
  }

  RECTANGLE ProjectBuilder {
    + builder()
    + withProjectDir()
    + build()
  }

  RECTANGLE TestProject {
    + extensions
    + properties
    + plugins
    + tasks
  }

  RECTANGLE ExtraPropertiesExtension {
    + set(key, value)
    + get(key)
    + has(key)
  }

  RECTANGLE JbakePlugin {
    + apply(project)
    + createInitializeTask()
    + readSiteConfigPath()
  }
}

TestClass --> ProjectBuilder : utilise
ProjectBuilder --> TestProject : crée
TestProject --> ExtraPropertiesExtension : contient
TestClass --> ExtraPropertiesExtension : configure
ExtraPropertiesExtension --> TestProject : synchronise properties
TestProject --> JbakePlugin : applique
JbakePlugin --> TestProject : lit properties

note right of ExtraPropertiesExtension : Point de contrôle\npour l'injection
note bottom of TestProject : Environnement\ncontrôlé et isolé
@enduml

이 접근 방식의 장점

  • 완전 절연테스트는 외부 파일에 의존하지 않습니다. 독립적이며, 로컬, CI/CD 등을 포함한 어떤 환경에서도 신뢰성 있게 실행할 수 있습니다.

  • 명확함과 의도테스트는 실행에 필요한 선행 조건을 명시적으로 선언합니다. 테스트를 읽는 사람은 누구든 플러그인이 해당 속성을 필요로 한다는 것을 즉시 알 수 있다.`site_config_path`작동하기 위해.

  • 유지보수성속성 이름이 변경되면 테스트에서 한 곳만 업데이트하면 되고, 테스트 구성 파일을 수정할 필요가 없습니다.

  • 유연성: 각 테스트에서 주입된 속성 값을 단순히 바꾸는 것만으로도 다양한 시나리오를 테스트하는 것이 간단해집니다. (유효한 값, 무효한 값, 누락된 값 등)

  • 퍼포먼스파일을 읽지 않으며, 모든 것이 메모리에서 이루어지므로 테스트가 더 빨라집니다.

  • 재현성테스트는 파일 시스템의 상태에 의존하지 않기 때문에 결정적입니다.

모범 사례와 조언

유틸리티 메서드 생성

Méthode utilitaire pour la réutilisation
class GradleTestUtils {
    companion object {
        fun createProjectWithProperties(
            projectDir: File,
            properties: Map<String, String>
        ): Project {
            val project = ProjectBuilder.builder()
                .withProjectDir(projectDir)
                .build()

            val extra = project.extensions.getByType(ExtraPropertiesExtension::class.java)
            properties.forEach { (key, value) ->
                extra.set(key, value)
            }

            return project
        }
    }
}

속성 검증

Validation robuste
@Test
fun `should validate property injection`() {
    val project = createProjectWithProperty("test-value")

    // Vérifications multiples
    assertTrue(project.hasProperty("site_config_path"))
    assertEquals("test-value", project.property("site_config_path"))
    assertNotNull(project.properties["site_config_path"])

    // Vérification que la propriété est accessible par le plugin
    project.plugins.apply("jbake.ghpages")
    // ... reste du test
}

결론

단위 테스트 환경이 구성 파일을 읽도록 애쓰는 대신, 필요한 상태를 시뮬레이션하는 것이 모범 사례입니다. 사용`ExtraPropertiesExtension`프로그래밍 방식으로 Gradle 속성을 정의하는 것이 효율적이고 신뢰할 수 있는 플러그인 단위 테스트를 수행하는 가장 깔끔하고 견고한 방법입니다.

이 기술은 외부 의존성 문제를 간단한 의존성 주입으로 변환하여 테스트 주도 개발(TDD)의 철학 그 자체에 심어줍니다. 이 기술은 테스트 환경에 대한 완전한 제어를 제공하면서도 고품질 단위 테스트에 필요한 격리를 유지합니다.

이 기사에 제시된 다이어그램과 예시는 이 접근 방식을 점진적이고 체계적으로 구현하는 방법을 보여주며, 이를 통해 Gradle 플러그인을 위한 견고하고 유지 관리 가능한 테스트 제품군을 만들 수 있습니다.

관련 기사