ปัญหา: แยกการทดสอบหน่วยของปลั๊กอิน 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 : อ่านเมื่อเริ่มต้น
  JbakePlugin --> GradleProperties : เข้าถึงผ่าน 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 : ใช้
}

note right of ProjectTest : การแยก = \nไม่สามารถเข้าถึงระบบไฟล์
@enduml

ความอยากที่จะเชื่อว่าอะไรเป็นความคิดที่ดี แม้มันเป็นความคิดที่ผิด

วิธีการเริ่มต้นอาจเป็นการสร้างไฟล์`gradle.properties`เทียมในทรัพยากรของการทดสอบ.

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

ยางไรก็ตาม, วิธีนี้ถูกกำหนดให้ล้มเหลว เพราะว่า`ProjectBuilder`ไม่ได้ถูกออกแบบมาเพื่อสแกนระบบไฟล์เพื่อค้นหาไฟล์การกำหนดค่า การทดสอบจะยังคงแยกตัวอยู่และจะเพิกเฉยต่อไฟล์นี้

ทางแก้ปัญหา : จำลองคุณสมบัติกับ `ExtraPropertiesExtension

วิธีแก้ปัญหาที่สง่างามไม่ใช่การ อ่าน ไฟล์ แต่เป็นการ จำลอง การมีอยู่ของคุณสมบัติโดยตรงในวัตถุ`Project`การทดสอบ. 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 : ประกอบด้วย
ExtensionContainer --> ExtraPropertiesExtension : จัดการ
ExtraPropertiesExtension --> Project : ซิงโครไนซ์กับ\nproject.properties

note right of ExtraPropertiesExtension : จุดเริ่มต้นสำหรับ\nฉีดคุณสมบัติ
@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

:สร้าง ProjectBuilder;
:ได้รับ ExtraPropertiesExtension;
:ฉีดคุณสมบัติ\nผ่าน extra.set();

partition "การตรวจสอบ" {
  :ตรวจสอบ project.hasProperty();
  :ใช้ปลั๊กอิน;
  :ดึงงาน;
}

partition "การทำงาน" {
  :เรียกใช้การกระทำ\nของงาน;
  :ปลั๊กอิน lit\nproject.properties;
}

partition "การยืนยัน" {
  :ตรวจสอบคุณสมบัติ\nเข้าถึงได้;
  :ตรวจสอบพฤติกรรม\nที่คาดหวัง;
}

stop
@enduml

ขั้นตอนที่ 5 : การเปรียบเทียบก่อน/หลัง

@startuml
!define RECTANGLE class

package "AVANT - ปัญหา" {
  RECTANGLE TestProject1 {
    + ProjectBuilder.build()
    + Pas de propriétés
  }

  RECTANGLE Plugin1 {
    + initialize()
    + ❌ project.properties["site_config_path"] = null
  }

  TestProject1 --> Plugin1 : ความล้มเหลว

  note right of Plugin1 : การทดสอบล้มเหลวเนื่องจาก\nคุณสมบัติไม่พบ
}

package "APRÈS - วิธีแก้ปัญหา" {
  RECTANGLE TestProject2 {
    + ProjectBuilder.build()
    + ExtraPropertiesExtension
  }

  RECTANGLE ExtraProps {
    + set("site_config_path", "...")
  }

  RECTANGLE Plugin2 {
    + initialize()
    + ✅ project.properties["site_config_path"] = "src/jbake/settings/site.yml"
  }

  TestProject2 --> ExtraProps : กำหนดค่า
  ExtraProps --> Plugin2 : อสังหาริมทรัพย์ที่ว่าง

  note right of Plugin2 : ทดสอบสำเร็จ เนื่องจาก\nคุณสมบัติถูกจำลอง
}
@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 : ใช้
ProjectBuilder --> TestProject : สร้าง
TestProject --> ExtraPropertiesExtension : มี
TestClass --> ExtraPropertiesExtension : ตั้งค่า
ExtraPropertiesExtension --> TestProject : ซิงค์คุณสมบัติ
TestProject --> JbakePlugin : นำไปใช้
JbakePlugin --> TestProject : lit properties

note right of ExtraPropertiesExtension : จุดตรวจสอบ\nเพื่อการฉีด
note bottom of TestProject : สภาพแวดล้อม\nที่ควบคุมและแยกออก
@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 ผ่านโปรแกรมเป็นวิธีที่สะอาดและแข็งแรงที่สุดสำหรับการทำการทดสอบยูนิตปลั๊กอินอย่างมีประสิทธิภาพและเชื่อถือได้

เทคนิคนี้เปลี่ยนปัญหาการพึ่งพิงภายนอกให้กลายเป็นการฉีดพึ่งพิงง่าย ๆ ที่ใจกลางของปรัชญา Test‑Driven Development (TDD) โดยให้ควบคุมสภาพแวดล้อมทดสอบได้อย่างสมบูรณ์ พร้อมทั้งรักษาการแยกส่วนที่จำเป็นสำหรับการทดสอบหน่วยคุณภาพ

แผนภาพและตัวอย่างที่แสดงในบทความนี้แสดงให้เห็นว่าวิธีการนี้สามารถดำเนินการได้อย่างค่อยเป็นค่อยไปและเป็นระบบ ทำให้สามารถสร้างชุดทดสอบที่แข็งแกร่งและบำรุงรักษาได้สำหรับปลั๊กอิน Gradle ของคุณ

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