In this article, we will explore how to set up a solid development foundation for a Gradle plugin using a Test-Driven Development (TDD) approach. This method ensures that our code is robust, maintainable, and precisely meets the requirements from the start.

Project Initialization

Gradle greatly facilitates creating a new plugin thanks to the command`gradle init`. By choosing to create a "Gradle Plugin" with Kotlin, we obtain a ready-to-use project structure, including two crucial types of tests:

  • Unit Tests :Located in`src/test`, they allow validating isolated components of our plugin, such as the internal logic of a task or the configuration of an extension. They are fast and do not require a full execution of Gradle.

  • Functional Tests :Located in`src/functionalTest`, they use`GradleRunner`to run a full Gradle build in a temporary test project. This allows us to verify the actual behavior of the plugin in a controlled environment.

2. Our First TDD Cycle: Record a Task

Our first requirement is simple: the plugin must register a task named`printSiteConfig`.

2.1. The Test First (The Test that Fails)

According to TDD, we first write a test that verifies the existence of this task. In`SiteBakerPluginTest.kt`(our unit test file), we add:

@Test
fun `plugin registers task`() {
    // Créer un projet de test en mémoire
    val project = ProjectBuilder.builder().build()
    project.plugins.apply("com.cheroliv.site-baker")

    // Vérifier que la tâche a bien été enregistrée
    assertNotNull(project.tasks.findByName("printSiteConfig"))
}

This test fails because we haven’t yet written any code in our plugin.

2.2. Then the Code (Make the Test Pass)

Now we write the minimum necessary code in`SiteBakerPlugin.kt`so that the test passes:

class SiteBakerPlugin: Plugin<Project> {
    override fun apply(project: Project) {
        // Enregistrer une tâche simple
        project.tasks.register("printSiteConfig") { task ->
            // ... la logique de la tâche viendra plus tard
        }
    }
}

We are rerunning the tests, and they pass. Our first feature is validated.

3. Second TDD Cycle: Add a DSL Extension

The following requirement is to allow users to configure our plugin via a DSL block in their`build.gradle.kts`. We want a block`site { …​ }`where one can specify a configuration path.

3.1. Test First

We are adding a test to verify that the extension`site`is well recorded and one can assign a value to it.

@Test
fun `plugin registers extension`() {
    val project = ProjectBuilder.builder().build()
    project.plugins.apply("com.cheroliv.site-baker")

    // Récupérer l'extension et lui affecter une valeur
    project.extensions
        .findByType(SiteExtension::class.java)!!
        .configPath
        .set("config.yml")

    // Vérifier que la valeur a bien été prise en compte
    assertEquals(
        "config.yml",
        project.extensions.findByType(SiteExtension::class.java)?.configPath?.get()
    )
}

This test fails because neither the class`SiteExtension`the recording of the extension exists

3.2. The code then

We are creating the class`SiteBakerExtension.kt`and we update`SiteBakerPlugin.kt`:

// SiteBakerExtension.kt
open class SiteExtension @Inject constructor(objects: ObjectFactory) {
    val configPath: Property<String> = objects.property(String::class.java)
}

// SiteBakerPlugin.kt
class SiteBakerPlugin: Plugin<Project> {
    override fun apply(project: Project) {
        // Enregistrer l'extension
        val extension = project.extensions.create("site", SiteExtension::class.java)

        project.tasks.register("printSiteConfig") { task ->
            task.doLast {
                // On utilisera l'extension plus tard
            }
        }
    }
}

The tests pass again.

4. Functional Test: Validate Integration

Now that the units are tested, we need to make sure everything works together in a real build. That’s the role of functional testing in`SiteBakerPluginFunctionalTest.kt`.

4.1. The Integration Test: Create a Controlled Environment

This test will simulate a real project using our plugin. For it to be reliable, it must behermetic, that is, it must not depend on existing files on the system. It must itself create all necessary conditions for its execution.

The test will therefore:

  1. Create a`build.gradle.kts`of test that uses our plugin and its DSL.

  2. Create the configuration file(managed-jbake-context.yml) that the plugin is supposed to read. This is a crucial step for the robustness of the test.

  3. Execute the task`printSiteConfig`via`GradleRunner`.

  4. Check that the build output is correct.

By copying or creating this configuration file each time it runs, we ensure that the test isreproducibleand does not depend on an external state. This secures our tests against regressions that could be related to reading the file.

@Test fun `can run task with DSL`() {
    // 1. Créer un build.gradle.kts de test
    buildFile.writeText("""
        plugins { id("com.cheroliv.site-baker") }
        site { configPath = "managed-jbake-context.yml" }
    """.trimIndent())

    // 2. Créer le fichier de configuration pour un test contrôlé
    val configFile = File(projectDir, "managed-jbake-context.yml")
    configFile.writeText("site: { title: 'Mon Site de Test' }") // Contenu YAML simple

    // 3. Exécuter la build
    val runner = GradleRunner.create()
    runner.withPluginClasspath()
    runner.withArguments("printSiteConfig")
    runner.withProjectDir(projectDir) // Spécifier le répertoire du projet de test
    val result = runner.build()

    // 4. Vérifier la sortie
    assertTrue(result.output.contains("Site config path: managed-jbake-context.yml"))
}

This test will fail as long as the task`printSiteConfig`will not actually use the value of the extension.

4.2. Finalize the Logic

We are updating the task in`SiteBakerPlugin.kt`so that it displays the configured value:

project.tasks.register("printSiteConfig") { task ->
    task.doLast {
        println("Site config path: ${extension.configPath.get()}")
    }
}

All tests, unit and functional, now pass.

Conclusion

By following a TDD approach, we built a plugin incrementally and safely. Each small feature is immediately validated by a test, from quick unit tests to functional tests that validate full integration.

By taking care to make our tests functionalhermetic— specifically by programmatically creating the necessary configuration files — we build an extremely robust safety net. This rigor effectively protects us against regressions and gives us great confidence to add more complex features later, such as parsing the YAML configuration file.

This solid test base is the most valuable asset for the maintenance and future evolution of the plugin.

Related articles