Vấn đề: Cô lập các Unit Test của một Gradle Plugin

Khi viết các bài kiểm tra đơn vị (unit tests) cho một plugin Gradle, một thách thức phổ biến là quản lý các phụ thuộc vào cấu hình dự án, chẳng hạn như các thuộc tính được định nghĩa trong tệp`gradle.properties`. Trong trường hợp của chúng tôi, plugin`jbake.ghpages`phải đọc một thuộc tính`site_config_path`để hoạt động chính xác. Kiểm thử đơn vị cho tác vụ`initialize`cần kiểm tra hành vi của plugin khi có thuộc tính này.

Vấn đề cơ bản là các bài kiểm thử đơn vị, theo thiết kế, phải được cô lập. Việc sử dụng`ProjectBuilder`của Gradle tạo một thể hiện của`Project`trong bộ nhớ, hoàn toàn tách rời khỏi một dự án thực sự trên hệ thống tệp. Do đó, phiên bản thử nghiệm này không tự động đọc tệp`gradle.properties`và do đó không biết về quyền sở hữu`site_config_path`.

Kiến trúc của Vấn đề

Sơ đồ sau đây minh họa sự ngắt kết nối giữa môi trường kiểm thử và hệ thống tệp tin:

@startuml
!define RECTANGLE class

package "Môi trường Production" {
  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 "Môi trường Test" {
  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 : Cách ly = Không có quyền truy cập\nvào hệ thống tệp
@enduml

Sự Cám Dỗ của một Ý Tưởng Tưởng Là Hay

Một cách tiếp cận đầu tiên có thể là tạo một tệp`gradle.properties`giả trong tài nguyên kiểm thử.

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

Tuy nhiên, phương pháp này chắc chắn sẽ thất bại vì`ProjectBuilder`không được thiết kế để quét hệ thống tệp nhằm tìm kiếm các tệp cấu hình. Bài kiểm tra sẽ vẫn bị cô lập và bỏ qua tệp này.

Giải pháp: Mô phỏng Thuộc tính với `ExtraPropertiesExtension

Giải pháp tinh tế cho vấn đề này không phải là đọc tệp, mà là mô phỏng sự hiện diện của thuộc tính trực tiếp trong đối tượng`Project`kiểm thử. Gradle cung cấp một cơ chế mạnh mẽ cho việc này: các thuộc tính bổ sung (Extra Properties).

Bước 1: Tìm hiểu ExtraPropertiesExtension

`ExtraPropertiesExtension`là một vùng chứa khóa-giá trị được gắn với mỗi đối tượng của mô hình Gradle. Nó cho phép thêm các thuộc tính một cách linh hoạt vào một dự án, một tác vụ, hoặc bất kỳ extension Gradle nào khác.

@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 : Điểm nhập để\ntiêm các thuộc tính
@enduml

Bước 2: Thiết lập Bài kiểm tra - Chuẩn bị

Hãy bắt đầu bằng cách tạo cấu trúc cơ bản cho bài kiểm tra đơn vị của chúng ta:

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

Bước 3: Tiêm Thuộc tính

Dưới đây là quy trình chi tiết để inject thuộc tính vào dự án kiểm thử:

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

Bước 4: Sơ đồ Luồng của Giải pháp

@startuml
start

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

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

partition "Thực thi" {
  :Exécuter les actions\nde la tâche;
  :Le plugin lit\nproject.properties;
}

partition "Khẳng định" {
  :Vérifier la propriété\nest accessible;
  :Valider le comportement\nattendues;
}

stop
@enduml

Bước 5: So sánh Trước/Sau

@startuml
!define RECTANGLE class

package "TRƯỚC - Vấn đề" {
  RECTANGLE TestProject1 {
    + ProjectBuilder.build()
    + Pas de propriétés
  }

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

  TestProject1 --> Plugin1 : échec

  note right of Plugin1 : Kiểm tra thất bại vì\nthuộc tính không tồn tại
}

package "SAU - Giải pháp" {
  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 : configure
  ExtraProps --> Plugin2 : propriété disponible

  note right of Plugin2 : Kiểm tra thành công vì\nthuộc tính được mô phỏng
}
@enduml

Bước 6: Quản lý nhiều Test Case

Để kiểm tra các kịch bản khác nhau, hãy tạo nhiều bài kiểm tra với các cấu hình đa dạng:

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

Kiến trúc Giải pháp Cuối cùng

@startuml
!define RECTANGLE class

package "Môi trường Kiểm thử" {
  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 : Điểm kiểm soát\ncho việc tiêm
note bottom of TestProject : Môi trường\nkiểm soát và cô lập
@enduml

Những Lợi ích của Cách tiếp cận này

  • Cách ly Hoàn toàn: Bài kiểm tra không phụ thuộc vào bất kỳ tệp bên ngoài nào. Nó độc lập và có thể chạy một cách đáng tin cậy trong bất kỳ môi trường nào (local, CI/CD, v.v.).

  • Sự Rõ ràng và Ý định: Bài kiểm tra tuyên bố rõ ràng các điều kiện tiên quyết để thực thi. Bất kỳ ai đọc bài kiểm tra đều thấy ngay rằng plugin yêu cầu quyền sở hữu`site_config_path`để hoạt động.

  • Khả năng bảo trì: Nếu tên thuộc tính thay đổi, bạn chỉ cần cập nhật nó tại một nơi duy nhất trong bài kiểm tra mà không cần phải thao tác với các tệp cấu hình kiểm tra.

  • Tính linh hoạtViệc kiểm thử các kịch bản khác nhau trở nên dễ dàng hơn bằng cách chỉ cần thay đổi giá trị của thuộc tính được tiêm vào trong mỗi bài kiểm thử (giá trị hợp lệ, không hợp lệ, bị thiếu, v.v.).

  • Hiệu năng: Không đọc tệp, mọi thứ đều diễn ra trong bộ nhớ, giúp cho các bài kiểm tra nhanh hơn.

  • Khả năng tái lập: Các bài kiểm tra có tính xác định vì chúng không phụ thuộc vào trạng thái của hệ thống tệp.

Hướng dẫn và Lời khuyên Tốt nhất

Tạo một Phương thức Tiện ích

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

Xác thực Thuộc tính

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
}

Kết luận

Thay vì cố gắng làm cho môi trường kiểm thử đơn vị đọc các tệp cấu hình, cách thực hành tốt nhất là mô phỏng trạng thái cần thiết. Việc sử dụng`ExtraPropertiesExtension`để định nghĩa các thuộc tính Gradle bằng lập trình là phương pháp sạch sẽ và mạnh mẽ nhất để thực hiện các bài kiểm tra đơn vị cho plugin một cách hiệu quả và đáng tin cậy.

Kỹ thuật này chuyển đổi một vấn đề phụ thuộc bên ngoài thành một thao tác tiêm phụ thuộc (dependency injection) đơn giản, vốn là cốt lõi trong triết lý của Phát triển hướng kiểm thử (TDD). Nó mang lại khả năng kiểm soát toàn diện đối với môi trường kiểm thử trong khi vẫn duy trì sự cô lập cần thiết cho các bài kiểm thử đơn vị chất lượng.

Các sơ đồ và ví dụ trình bày trong bài viết này cho thấy cách tiếp cận này có thể được triển khai một cách tiệm tiến và có phương pháp, cho phép tạo ra một bộ kiểm thử mạnh mẽ và dễ bảo trì cho các plugin Gradle của bạn.

Bài viết liên quan