1. Introduction

Lors du développement du plugin Gradle Bakery pour mon blog JBake, j’ai rencontré un problème apparemment simple : un test unitaire qui échouait avec l’erreur Wanted but not invoked. Ce qui semblait être un bug trivial s’est révélé être un cas d’école parfait pour comprendre les subtilités du mocking avec Mockito et Kotlin.

Dans cet article, je vous emmène dans un voyage de debugging méthodique, où chaque solution révèle un nouveau problème, jusqu’à la résolution finale.

2. Le contexte : Plugin Gradle Bakery

Le plugin Bakery est un wrapper autour de JBake qui facilite la publication de sites statiques. Voici sa structure simplifiée :

class BakeryPlugin : Plugin<Project> {
    override fun apply(project: Project) {
        val extension = project.extensions.create(
            "bakery",
            BakeryExtension::class.java
        )

        project.afterEvaluate {
            if (!project.layout.projectDirectory.asFile
                    .resolve(extension.configPath.get()).exists()) {
                println("config file does not exists")
            } else {
                // C'EST ICI QUE ÇA SE PASSE
                project.plugins.apply(JBakePlugin::class.java)

                val site = FileSystemManager.from(project, extension.configPath.get())
                // Configuration de JBake...
            }
        }
    }
}

Le test qui échouait était simple :

@Test
fun `plugin applies jbake gradle plugin`() {
    val project = createMockProject()
    val plugin = BakeryPlugin()

    plugin.apply(project)

    verify(project.plugins).apply(JBakePlugin::class.java)
}

L’erreur :

Wanted but not invoked:
pluginContainer.apply(class org.jbake.gradle.JBakePlugin);
Actually, there were zero interactions with this mock.

3. Problème #1 : Vérifier le mauvais mock

3.1. Le diagnostic

mockito problem 1

Le problème : Mockito ne peut pas tracer les interactions sur project.plugins car ce n’est qu’un getter qui retourne le vrai mock mockPluginContainer. La vérification doit se faire directement sur l’instance du mock.

3.2. La solution

Modifier createMockProject() pour retourner les deux objets :

private fun createMockProject(): Pair<Project, PluginContainer> {
    val mockPluginContainer = mock<PluginContainer>()
    val mockProject = mock<Project> {
        on { plugins } doReturn mockPluginContainer
    }
    return Pair(mockProject, mockPluginContainer)
}

Et adapter le test :

@Test
fun `plugin applies jbake gradle plugin`() {
    val (project, mockPluginContainer) = createMockProject()
    val plugin = BakeryPlugin()

    plugin.apply(project)

    // ✅ Vérification directe sur le bon mock
    verify(mockPluginContainer).apply(JBakePlugin::class.java)
}

4. Problème #2 : UnfinishedStubbingException en cascade

4.1. Le diagnostic

Une fois la première correction appliquée, une nouvelle erreur est apparue :

UnfinishedStubbingException:
Unfinished stubbing detected here
Hints:
 3. you are stubbing the behaviour of another mock inside
    before 'thenReturn' instruction is completed

Le code problématique utilisait la syntaxe DSL de Mockito-Kotlin :

val mockProject = mock<Project> {
    on { extensions } doReturn mockExtensionContainer
    on { plugins } doReturn mockPluginContainer
    on { logger } doReturn mock()  // ❌ PROBLÈME ICI !
}
mockito problem 2

4.2. La solution

Créer tous les mocks en dehors de tout bloc de stubbing, puis les configurer avec whenever() :

private fun createMockProject(): Pair<Project, PluginContainer> {
    // 1️⃣ Créer TOUS les mocks d'abord
    val mockPluginContainer = mock<PluginContainer>()
    val mockExtensionContainer = mock<ExtensionContainer>()
    val mockLogger = mock<org.gradle.api.logging.Logger>()
    val mockProject = mock<Project>()

    // 2️⃣ Configurer les mocks séparément avec whenever()
    whenever(mockProject.plugins).thenReturn(mockPluginContainer)
    whenever(mockProject.extensions).thenReturn(mockExtensionContainer)
    whenever(mockProject.logger).thenReturn(mockLogger)

    return Pair(mockProject, mockPluginContainer)
}

Règle d’or : Ne jamais appeler mock() à l’intérieur d’un bloc de configuration de mock. Toujours créer les mocks en premier, puis les configurer.

5. Problème #3 : Le fichier de configuration n’existe pas

5.1. Le diagnostic

Même avec les mocks corrects, le test échouait toujours car le plugin ne trouvait pas le fichier de configuration :

// Dans BakeryPlugin.kt
if (!project.layout.projectDirectory.asFile
        .resolve(extension.configPath.get()).exists()) {
    println("config file does not exists")
    return@afterEvaluate  // ❌ Sort avant d'appliquer JBake !
}
mockito problem 3

5.2. La solution

Configurer les mocks pour que la résolution du chemin fonctionne :

private fun createMockProject(): Pair<Project, PluginContainer> {
    // ... autres mocks ...

    val configFile = File("../../site.yml").canonicalFile
    val projectDir = configFile.parentFile

    // Configuration cohérente des chemins
    whenever(mockConfigPathProperty.get()).thenReturn("site.yml")
    whenever(mockProjectDirectory.asFile).thenReturn(projectDir)

    // Maintenant : projectDir.resolve("site.yml") existe ! ✅
}
path resolution

6. Problème #4 : afterEvaluate et NullPointerException

6.1. Le diagnostic

Le plugin applique JBake dans un bloc afterEvaluate, et accède à buildDirectory.dir() :

project.afterEvaluate {
    // ...
    project.tasks.withType(JBakeTask::class.java)
        .getByName("bake").apply {
            output = project.layout.buildDirectory
                .dir(site.bake.destDirPath)  // ❌ NPE ici !
                .get()
                .asFile
        }
}

Le mock de buildDirectory.dir() retournait null.

6.2. La solution

Mocker afterEvaluate pour qu’il s’exécute immédiatement, et configurer complètement buildDirectory :

private fun createMockProject(): Pair<Project, PluginContainer> {
    // ... autres mocks ...

    val mockBuildDirectory = mock<DirectoryProperty>()
    val buildDir = File(projectDir, "build")

    // Mocker dir() pour retourner un Provider valide
    whenever(mockBuildDirectory.dir(any<String>())).doAnswer { invocation ->
        val path = invocation.arguments[0] as String
        val mockDirProvider = mock<Provider<Directory>>()
        val mockDir = mock<Directory>()

        whenever(mockDir.asFile).thenReturn(File(buildDir, path))
        whenever(mockDirProvider.get()).thenReturn(mockDir)

        mockDirProvider
    }

    // Mocker afterEvaluate pour exécution immédiate
    whenever(mockProject.afterEvaluate(any<Action<Project>>())).doAnswer { invocation ->
        val action = invocation.arguments[0] as Action<Project>
        action.execute(mockProject)  // ✅ Exécution synchrone
        null
    }

    return Pair(mockProject, mockPluginContainer)
}
after evaluate flow

7. La solution complète

Voici la fonction createMockProject() finale, qui résout tous les problèmes :

private fun createMockProject(): Pair<Project, PluginContainer> {
    // 1️⃣ CRÉER tous les mocks (pas de nested mocks !)
    val mockPluginContainer = mock<PluginContainer>()
    val mockExtensionContainer = mock<ExtensionContainer>()
    val mockLogger = mock<org.gradle.api.logging.Logger>()
    val mockTaskContainer = mock<TaskContainer>()
    val mockConfigPathProperty = mock<Property<String>>()
    val mockBakeryExtension = mock<BakeryExtension>()
    val mockProjectDirectory = mock<Directory>()
    val mockBuildDirectory = mock<DirectoryProperty>()
    val mockProjectLayout = mock<ProjectLayout>()
    val mockProject = mock<Project>()

    // 2️⃣ CONFIGURER la résolution des chemins
    val configFile = File("../../site.yml").canonicalFile
    val projectDir = configFile.parentFile
    val buildDir = File(projectDir, "build")

    whenever(mockConfigPathProperty.get()).thenReturn("site.yml")
    whenever(mockConfigPathProperty.isPresent).thenReturn(true)
    whenever(mockBakeryExtension.configPath).thenReturn(mockConfigPathProperty)
    whenever(mockProjectDirectory.asFile).thenReturn(projectDir)

    // 3️⃣ CONFIGURER buildDirectory avec dir()
    whenever(mockBuildDirectory.dir(any<String>())).doAnswer { invocation ->
        val path = invocation.arguments[0] as String
        val mockDirProvider = mock<Provider<Directory>>()
        val mockDir = mock<Directory>()
        whenever(mockDir.asFile).thenReturn(File(buildDir, path))
        whenever(mockDirProvider.get()).thenReturn(mockDir)
        mockDirProvider
    }

    // 4️⃣ ASSEMBLER le projet
    whenever(mockProjectLayout.projectDirectory).thenReturn(mockProjectDirectory)
    whenever(mockProjectLayout.buildDirectory).thenReturn(mockBuildDirectory)

    whenever(mockExtensionContainer.create("bakery", BakeryExtension::class.java))
        .thenReturn(mockBakeryExtension)
    whenever(mockExtensionContainer.getByType(BakeryExtension::class.java))
        .thenReturn(mockBakeryExtension)

    whenever(mockProject.extensions).thenReturn(mockExtensionContainer)
    whenever(mockProject.plugins).thenReturn(mockPluginContainer)
    whenever(mockProject.tasks).thenReturn(mockTaskContainer)
    whenever(mockProject.layout).thenReturn(mockProjectLayout)
    whenever(mockProject.logger).thenReturn(mockLogger)
    whenever(mockProject.projectDir).thenReturn(projectDir)

    // 5️⃣ CONFIGURER afterEvaluate pour exécution immédiate
    whenever(mockProject.afterEvaluate(any<Action<Project>>())).doAnswer { invocation ->
        val action = invocation.arguments[0] as Action<Project>
        action.execute(mockProject)
        null
    }

    return Pair(mockProject, mockPluginContainer)
}

Et le test final qui passe :

@Test
fun `plugin applies jbake gradle plugin`() {
    val (project, mockPluginContainer) = createMockProject()
    val plugin = BakeryPlugin()

    plugin.apply(project)

    verify(mockPluginContainer).apply(JBakePlugin::class.java) // ✅ SUCCÈS !
}

8. Leçons apprises

lessons learned

8.1. 1. Vérifier le bon mock

// ❌ FAUX
verify(project.plugins).apply(JBakePlugin::class.java)

// ✅ CORRECT
val (project, mockPluginContainer) = createMockProject()
verify(mockPluginContainer).apply(JBakePlugin::class.java)

8.2. 2. Éviter les mocks imbriqués

// ❌ FAUX - UnfinishedStubbingException
val mockProject = mock<Project> {
    on { logger } doReturn mock()  // Nested mock creation !
}

// ✅ CORRECT - Créer séparément
val mockLogger = mock<org.gradle.api.logging.Logger>()
val mockProject = mock<Project>()
whenever(mockProject.logger).thenReturn(mockLogger)

8.3. 3. Mocker les callbacks d’évaluation

// ✅ afterEvaluate doit s'exécuter pour les tests
whenever(mockProject.afterEvaluate(any())).doAnswer { invocation ->
    val action = invocation.arguments[0] as Action<Project>
    action.execute(mockProject)
    null
}

8.4. 4. Tester la résolution des chemins

// Toujours vérifier que les chemins se résolvent correctement
val extension = project.extensions.getByType(BakeryExtension::class.java)
val configPath = extension.configPath.get()
val projectDir = project.layout.projectDirectory.asFile
val resolvedConfig = projectDir.resolve(configPath)

println("Resolved config: ${resolvedConfig.absolutePath}")
println("Exists: ${resolvedConfig.exists()}")

9. Architecture de test finale

test architecture

10. Conclusion

Ce qui semblait être un simple problème de test s’est révélé être un excellent cas d’étude sur :

  • Les subtilités de Mockito avec Kotlin

  • L’importance de mocker les bons objets

  • La gestion des callbacks asynchrones dans les tests

  • La résolution de chemins dans les plugins Gradle

Le debugging méthodique, en comprenant chaque couche du problème, a permis d’arriver à une solution robuste et maintenable.

Conseil pour vos tests : Si vous rencontrez "Wanted but not invoked" avec Mockito, demandez-vous toujours :

  1. Est-ce que je vérifie le bon mock ?

  2. Est-ce que tous mes mocks sont créés en dehors des blocs de configuration ?

  3. Est-ce que mes callbacks s’exécutent vraiment ?

  4. Est-ce que mes chemins se résolvent correctement ?

11. Ressources


Avez-vous rencontré des problèmes similaires dans vos tests ? Partagez votre expérience dans les commentaires !