Einführung

Bei der Entwicklung des Gradle-PluginsBäckereifür meinen JBake-Blog bin ich auf ein scheinbar einfaches Problem gestoßen: ein Unit-Test, der mit einem Fehler fehlschlug`Wanted but not invoked`. Was wie ein triviales Bug aussah, erwies sich als ein perfektes Lehrbeispiel, um die Feinheiten des Mockens mit Mockito und Kotlin zu verstehen.

In diesem Artikel führe ich dich auf eine methodische Debugging-Reise, bei der jede Lösung ein neues Problem aufzeigt, bis zur endgültigen Lösung.

Der Kontext: Gradle Bakery Plugin

Das Bakery-Plugin ist ein Wrapper um JBake, der die Veröffentlichung von statischen Websites vereinfacht. Hier ist seine vereinfachte Struktur:

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

Der Test, der fehlschlug, war einfach:

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

    plugin.apply(project)

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

Der Fehler:

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

Problem #1: Überprüfe das falsche Mock

Die Diagnose

mockito problem 1

Das Problem: Mockito kann die Interaktionen nicht auf`project.plugins`weil es nur ein Getter ist, der das echte Mock zurückgibt`mockPluginContainer`. Die Überprüfung muss direkt auf der Instanz des Mock erfolgen.

Die Lösung

Ändern`createMockProject()`um die beiden Objekte zurückgeben :

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

Und den Test anpassen:

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

Problem #2: kaskadierendes UnfinishedStubbingException

Die Diagnose

Sobald die erste Korrektur angewendet wurde, ist ein neuer Fehler aufgetreten:

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

Der problematische Code verwendete die DSL-Syntax von Mockito-Kotlin

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

Die Lösung

Erstellenalledie Mocks außerhalb eines Stubbing-Blocks und sie dann mit zu konfigurieren`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)
}

Goldene Regel: Nie anrufen`mock()`innerhalb eines Mock-Konfigurationsblocks. Erstelle immer zuerst die Mocks, dann konfiguriere sie.

Problem #3: Die Konfigurationsdatei existiert nicht

Die Diagnose

Selbst mit den richtigen Mocks schlug der Test immer fehl, weil das Plugin die Konfigurationsdatei nicht finden konnte :

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

Die Lösung

Mocks konfigurieren, damit die Pfadauflösung funktioniert:

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

Problem #4 : afterEvaluate und NullPointerException

Die Diagnose

Der Plugin wendet JBake in einem Block an.afterEvaluate, und zugreift auf`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
        }
}

Der Mock von`buildDirectory.dir()kehrte zurück`null.

Die Lösung

Spötter`afterEvaluate`damit es sofort ausgeführt wird, und es vollständig konfigurieren`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

Die vollständige Lösung

Hier ist die Funktion`createMockProject()`Finale, die alle Probleme löst:

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

Und der abschließende Test, der besteht:

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

Gelernte Lektionen

lessons learned

1. Überprüfe das richtige Mock

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

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

2. Verschachtelte Mocks vermeiden

// ❌ 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. Die Evaluierungs-Callbacks mocken

// ✅ 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. Pfadauflösung testen

// 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 Testarchitektur

test architecture

Conclusion

Was zunächst wie ein einfaches Testproblem erschien, stellte sich als ein hervorragendes Fallbeispiel für : heraus

  • Die Feinheiten von Mockito mit Kotlin

  • Die Bedeutung des Mockens der richtigen Objekte

  • Die Verwaltung asynchroner Callbacks in Tests

  • Die Auflösung von Pfaden in Gradle-Plugins

Durch methodisches Debuggen und das Verständnis jeder Schicht des Problems gelang es, eine robuste und wartbare Lösung zu finden.

Tipp für Ihre TestsWenn Sie mit Mockito auf Wanted but not invoked stoßen, fragen Sie sich immer : 1. Überprüfe ich das richtige Mock? 2. Werden alle meine Mocks außerhalb der Konfigurationsblöcke erstellt? 3. Werden meine Callbacks wirklich ausgeführt? 4. Lösen sich meine Pfade korrekt auf?

Ressourcen


Haben Sie ähnliche Probleme in Ihren Tests festgestellt? Teilen Sie Ihre Erfahrung in den Kommentaren mit!

Verwandte Artikel