The Problem: Isolating Unit Tests for a Gradle Plugin

When writing unit tests for a Gradle plugin, a common challenge is managing dependencies on project configuration, such as properties defined in the gradle.properties file.`gradle.properties`In our case, the plugin`jbake.ghpages`had to read a property`site_config_path`to function correctly. The unit test for the task`initialize`needed to verify the plugin’s behavior in the presence of this property.

The fundamental problem is that unit tests, by design, must be isolated. Using`ProjectBuilder`from Gradle creates an instance of`Project`in memory, completely disconnected from a real project on the file system. Consequently, this test instance does not automatically read the gradle.properties file`gradle.properties`and therefore has no knowledge of the property`site_config_path`.

Problem Architecture

The following diagram illustrates the disconnection between the test environment and the file system:

test isolation problem

The Temptation of a False Good Idea

An initial approach might be to create a dummy gradle.properties file`gradle.properties`in the test resources.

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

However, this method is doomed to fail because`ProjectBuilder`is not designed to scan the file system for configuration files. The test would remain isolated and ignore this file.

The Solution: Simulating the Property with ExtraPropertiesExtension

The elegant solution to this problem is not to read the file, but to simulate the presence of the property directly within the`Project`test object. Gradle provides a powerful mechanism for this: extra properties.

Step 1: Understanding ExtraPropertiesExtension

`ExtraPropertiesExtension`is a key-value container attached to every Gradle model object. It allows adding properties dynamically to a project, a task, or any other Gradle extension.

extra properties architecture

Step 2: Test Setup - Preparation

Let’s start by creating the basic structure of our unit test:

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

Step 3: Property Injection

Here is the detailed process for injecting the property into the test project:

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

Step 4: Solution Flow Diagram

solution flow

Step 5: Before/After Comparison

before after comparison

Step 6: Managing Multiple Test Cases

To test different scenarios, let’s create several tests with varied configurations:

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

Final Solution Architecture

final architecture

Advantages of this Approach

  • Complete Isolation: The test does not depend on any external file. It is self-contained and can run reliably in any environment (local, CI/CD, etc.).

  • Clarity and Intent: The test explicitly declares the prerequisites for its execution. Anyone reading the test immediately sees that the plugin requires the property`site_config_path`to function.

  • Maintainability: If the property name changes, it only needs to be updated in one place in the test, without having to manipulate test configuration files.

  • Flexibility: It becomes trivial to test different scenarios by simply changing the value of the property injected into each test (valid value, invalid, absent, etc.).

  • Performance: No file reading; everything happens in memory, making the tests faster.

  • Reproducibility: Tests are deterministic because they do not depend on the state of the file system.

Best Practices and Tips

Creating a Utility Method

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

Property Validation

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
}

Conclusion

Rather than struggling to make a unit test environment read configuration files, the best practice is to simulate the required state. Using`ExtraPropertiesExtension`to programmatically define Gradle properties is the cleanest and most robust method for performing effective and reliable plugin unit tests.

This technique transforms an external dependency problem into a simple dependency injection, at the very heart of the Test-Driven Development (TDD) philosophy. It offers total control over the test environment while maintaining the isolation necessary for high-quality unit tests.

The diagrams and examples presented in this article show how this approach can be implemented progressively and methodically, allowing for the creation of a robust and maintainable test suite for your Gradle plugins.

Related articles