Solve the Gradle Unit Test Challenge with `gradle.properties
Published on 13 July 2025
The Problem: Isolating Unit Tests of a Gradle Plugin
When writing unit tests for a Gradle plugin, a common challenge is managing dependencies on the project configuration, such as properties defined in the file`gradle.properties`. In our case, the plugin`jbake.ghpages`had to read a property`site_config_path`for it to work correctly. The unit test for the task`initialize`had to check the plugin’s behavior in the presence of this property.
The fundamental problem is that unit tests, by design, must be isolated. The use of`ProjectBuilder`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 file`gradle.properties`and therefore does not know the property`site_config_path`.
Problem Architecture
The following diagram illustrates the disconnection between the test environment and the file system:
@startuml
!define RECTANGLE class
package "Production Environment" {
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 "Test environment" {
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
The Temptation of a False Good Idea
A first approach could be to create a file`gradle.properties`artificial in the test resources.
// src/test/resources/gradle.properties
site_config_path=src/jbake/settings/site.yml
However, this method is doomed to failure because`ProjectBuilder`is not designed to scan the file system for configuration files. The test would remain isolated and would ignore this file.
The Solution: Simulate Property with `ExtraPropertiesExtension
The elegant solution to this problem is not to read the file, but to simulate the presence of the property directly in the object`Project`for test. Gradle provides a powerful mechanism for this: the extra properties (Extra Properties).
Step 1: Understand ExtraPropertiesExtension
`ExtraPropertiesExtension`is a key-value container attached to each object in the Gradle model. It allows adding properties dynamically to a project, a task, or any other Gradle extension.
@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
Step 2: Test Setup - Preparation
Let’s start by creating the basic structure of our unit test:
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: Injection of the Property
Here is the detailed process for injecting the property into the test project:
@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: Flowchart of the Solution
@startuml
start
:Créer ProjectBuilder;
:Obtenir ExtraPropertiesExtension;
:Injecter la propriété\nvia extra.set();
partition "Checks" {
:Vérifier project.hasProperty();
:Appliquer le plugin;
:Récupérer la tâche;
}
partition "Execution" {
:Exécuter les actions\nde la tâche;
:Le plugin lit\nproject.properties;
}
partition "Assertions" {
:Vérifier la propriété\nest accessible;
:Valider le comportement\nattendues;
}
stop
@enduml
Step 5: Before/After Comparison
@startuml
!define RECTANGLE class
package "BEFORE - Problem" {
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 : Test échoue car\nla propriété n'existe pas
}
package "AFTER - Solution" {
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 : Test réussit car\nla propriété est simulée
}
@enduml
Step 6: Handling Multiple Test Cases
To test different scenarios, let’s create several tests with varied configurations:
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 Architecture of the Solution
@startuml
!define RECTANGLE class
package "Test Environment" {
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
The Benefits of this Approach
-
Complete IsolationThe test does not depend on any external file. It is self-contained and can be executed reliably in any environment (local, CI/CD, etc.).
-
Clarity and IntentThe test explicitly declares the prerequisites for its execution. Anyone who reads the test immediately sees that the plugin requires the property`site_config_path`to function.
-
Maintainability: If the name of the property changes, you only need to update it in one place in the test, without having to manipulate test configuration files.
-
FlexibilityIt becomes trivial to test different scenarios by simply changing the value of the injected property in each test (valid value, invalid, absent, etc.).
-
PerformanceNo file reading; everything happens in memory, making the tests faster.
-
ReproducibilityTests are deterministic because they do not depend on the state of the file system.
Best Practices and Tips
Create a Utility Method
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 of Properties
@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
Instead of struggling to make a unit test environment read configuration files, the best practice is to simulate the required state. The use of`ExtraPropertiesExtension`To programmatically set the Gradle properties is the cleanest and most robust method to perform effective and reliable unit tests of plugins.
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 complete control over the test environment while maintaining the isolation needed for quality unit tests.
The diagrams and examples presented in this article show how this approach can be implemented gradually and methodically, enabling the creation of a robust and maintainable test suite for your Gradle plugins.