مشکل: عزل تست‌های واحد از یک پلاگین 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`برای اسکن سیستم فایل‌ها به دنبال فایل‌های پیکربندی طراحی نشده است. تست در isolation باقی می‌ماند و این فایل را نادیده می‌گیرد.

حل : شبیه‌سازی ویژگی با `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 : 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

مرحله ۲ : تنظیم تست - آماده‌سازی

ابتدا ساختار اصلی تست واحد خود را ایجاد کنیم:

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

مرحله ۴: نمودار جریان حل

@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

مرحله ۵ : مقایسه قبل/بعد

@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

مرحله ۶ : مدیریت موارد آزمایش متعدد

برای آزمایش سناریوهای مختلف، چندین تست با تنظیمات مختلف ایجاد کنیم :

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، روش تمیزترین و قوی‌ترین برای انجام تست‌های واحد مؤثر و قابل اعتماد برای افزونه‌ها است.

این تکنیک یک مشکل وابستگی خارجی را به یک تزریق وابستگی ساده تبدیل می‌کند که در قلب همان فلسفه Test-Driven Development (TDD) است. این تکنیک کنترل کامل بر محیط تست را فراهم می‌کند، در حالی که جدایی لازم برای تست‌های واحد با کیفیت را نیز حفظ می‌کند.

دیاگرام‌ها و نمونه‌های ارائه‌شده در این مقاله نشان می‌دهد که این رویکرد چگونه می‌تواند به‌صورت تدریجی و منهجی پیاده‌سازی شود؛ این امکان را می‌دهد یک بسته تست قوی و پایدار برای پلاگین‌های Gradle خود ایجاد کنید.

مقالات مرتبط