Inleiding

Tijdens de ontwikkeling van de Gradle-pluginbakkerijvoor mijn blog JBake, ben ik een probleem tegengekomen dat ogenschijnlijk eenvoudig leek: een eenheidstest die faalde met de fout`Wanted but not invoked`. Wat leek op een triviaal bug bleek een perfecte casestudy te zijn om de nuances van mocking met Mockito en Kotlin te begrijpen.

In dit artikel neem ik je mee op een methodische debugtocht, waarbij elke oplossing een nieuw probleem onthult, tot de uiteindelijke oplossing.

De context: Plugin Gradle Bakery

De Bakery-plugin is een wrapper rond JBake die het publiceren van statische sites vergemakkelijkt. Hieronder ziet u de vereenvoudigde structuur:

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

De test die faalde was eenvoudig:

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

    plugin.apply(project)

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

De fout :

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

Probleem #1 : Controleer de slechte mock

De diagnose

@startuml
actor Test
participant "project.plugins" as PP
participant "mockPluginContainer" as MPC
participant "BakeryPlugin" as BP

Test -> Test: createMockProject()
activate Test
Test -> MPC: maak een mock
Test -> PP: doReturn mockPluginContainer
Test --> Test: return project
deactivate Test

Test -> BP: apply(project)
activate BP
BP -> PP: project.plugins
PP --> BP: returns mockPluginContainer
BP -> MPC: apply(JBakePlugin::class.java)
deactivate BP

Test -> PP: verify(project.plugins).apply(...)
note right
❌ ÉCHEC !
Mockito controleert "project.plugins"
maar de interactie vond plaats op "mockPluginContainer
end note
@enduml

Het probleem: Mockito kan de interacties niet traceren op`project.plugins`want het is slechts een getter die de echte mock retourneert`mockPluginContainer`. De verificatie moet direct op de mock-instantie worden uitgevoerd.

De oplossing

Wijzigen`createMockProject()`om de twee objecten terug te geven:

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

En pas de test aan:

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

Probleem #2: UnfinishedStubbingException cascaderend

De diagnose

Nadat de eerste correctie is toegepast, is er een nieuwe fout opgetreden :

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

De problematische code gebruikte de DSL-syntaxis van Mockito-Kotlin :

val mockProject = mock<Project> {
    on { extensions } doReturn mockExtensionContainer
    on { plugins } doReturn mockPluginContainer
    on { logger } doReturn mock()  // ❌ PROBLÈME ICI !
}
@startuml
participant "Mockito" as M
participant "Mock Builder" as MB
participant "Inline Mock" as IM

M -> MB: mock<Project> { ... }
activate MB
MB -> MB: on { logger } doReturn
MB -> IM: mock()
activate IM
note right
❌ Aanmaken van een nieuwe mock
PENDANT Het stubbing is bezig!
Mockito verliest de context
van het parent stubbing
end note
IM --> MB: nieuw mock
deactivate IM
MB -x M: UnfinishedStubbingException
deactivate MB
@enduml

De oplossing

Makenallede mocks buiten elk stub-blok, en configureer ze met`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)
}

Gouden regel: Nooit bellen`mock()`Binnen een mock configuratieblok. Maak altijd eerst de mocks aan, daarna configureren.

Probleem #3 : Het configuratiebestand bestaat niet

De diagnose

Zelfs met de juiste mocks, slaagde de test nooit, omdat het plugin het configuratiebestand niet kon vinden:

// 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 !
}
@startuml
participant "BakeryPlugin" as BP
participant "Project Layout" as PL
participant "Extension" as EXT
participant "FileSystem" as FS

BP -> PL: layout.projectDirectory.asFile
PL --> BP: /some/mock/path
BP -> EXT: extension.configPath.get()
EXT --> BP: "../../site.yml"
BP -> BP: resolve("../../site.yml")
BP -> FS: exists() ?
FS --> BP: false ❌
BP -> BP: return (skip JBake)
note right
De plug-in vindt het bestand niet en stopt vóór het toepassen van JBake
end note
@enduml

De oplossing

Configureer de mocks zodat de padresolutie werkt :

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 ! ✅
}
@startuml
!define SUCCESS #90EE90
!define PROBLEM #FFB6C1

rectangle "nagebootste configuratie" {
    card "projectDirectory.asFile" SUCCESS
    card "returns: /real/path/to/project" SUCCESS

    card "configPath.get()" SUCCESS
    card "retouren: site.yml" SUCCESS

    card "oplossen()" SUCCESS
    card "/real/path/to/project/site.yml" SUCCESS

    card "bestaat()" SUCCESS
    card "waar ✅" SUCCESS
}
@enduml

Probleem #4: afterEvaluate en NullPointerException

De diagnose

De plugin past JBake toe in een blok`afterEvaluate`, en krijgt toegang tot`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
        }
}

De mock van`buildDirectory.dir()keerde terug`null.

De oplossing

Spotten`afterEvaluate`zodat het onmiddellijk wordt uitgevoerd, en volledig configureren`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)
}
@startuml
participant "Test" as T
participant "BakeryPlugin" as BP
participant "mockProject" as MP
participant "afterEvaluate Actie" as AE

T -> BP: apply(project)
activate BP
BP -> MP: afterEvaluate { ... }
activate MP
note right
Mock geconfigureerd om direct uit te voeren
end note
MP -> AE: execute(project)
activate AE
AE -> MP: layout.buildDirectory.dir("bakken")
MP --> AE: Provider<Directory> ✅
AE -> MP: plugins.apply(JBakePlugin)
deactivate AE
MP --> BP:
deactivate MP
BP --> T:
deactivate BP

T -> T: verify(mockPluginContainer).apply(...)
note right: ✅ SUCCÈS !
@enduml

De volledige oplossing

Hier is de functie`createMockProject()`eindelijk, dat alle problemen oplost :

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

En het eindtest dat slaagt:

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

Geleerde lessen

@startmindmap
* Mocken met Mockito
** ✅ Goede praktijken
*** Maak eerst alle mocks aan
*** Configureer met whenever() apart
*** Controleer het exacte mock, geen proxy
*** Testen van de padresolutie
** Valkuilen om te vermijden
*** mock()` in een configuratieblok
*** Controleren project.x in plaats van mockX
*** Negeer de blokken afterEvaluate
*** Retourneren null voor de Aanbieders
@endmindmap

Controleer de juiste mock

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

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

2. Vermijd geneste mocks

// ❌ 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)

3. Mock de evaluatie‑callbacks

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

4. Test van padresolutie

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

Finale testarchitectuur

@startuml
package "testlaag" {
    [Test Class] --> [createMockProject()]
}

package "Namaakcreatie" {
    [createMockProject()] --> [Create All Mocks]
    [Create All Mocks] --> [Configure Paths]
    [Configure Paths] --> [Configure Callbacks]
    [Configure Callbacks] --> [Assemble Project]
}

package "Mock Objects" {
    [Assemble Project] --> [Project Mock]
    [Assemble Project] --> [PluginContainer Mock]
    [Assemble Project] --> [Extension Mocks]
    [Assemble Project] --> [Layout Mocks]
}

package "Plugin onder test" {
    [Test Class] --> [BakeryPlugin]
    [BakeryPlugin] --> [Project Mock]
    [BakeryPlugin] --> [PluginContainer Mock]
}

package "Verificatie" {
    [Test Class] --> [verify(mockPluginContainer)]
}
@enduml

Conclusie

Wat leek een eenvoudig testprobleem te zijn, bleek een uitstekende casestudie te zijn over :

  • De subtiliteiten van Mockito met Kotlin

  • Het belang van het mocken van de juiste objecten

  • Het beheren van asynchrone callbacks in de tests

  • Padresolutie in Gradle-plugins

Methodisch debuggen, door elke laag van het probleem te begrijpen, heeft geleid tot een robuuste en onderhoudbare oplossing.

Advies voor uw tests: Als je "Wanted but not invoked" tegenkomt met Mockito, vraag je je altijd af : 1. Controleer ik de juiste mock? 2. Zijn al mijn mocks buiten de configuratieblokken gemaakt? 3. Zijn mijn callbacks echt aan het uitvoeren? 4. Lossen mijn paden correct op?

Bronnen


Heb je vergelijkbare problemen ondervonden in je tests? Deel je ervaring in de reacties!

Gerelateerde artikelen