Debugging Mockito: Den Fehler "Wanted but not invoked" in Gradle-Plugintests beheben
Publié le 17 November 2024
- Einführung
- Der Kontext: Gradle Bakery Plugin
- Problem #1: Überprüfe das falsche Mock
- Problem #2: kaskadierendes UnfinishedStubbingException
- Problem #3: Die Konfigurationsdatei existiert nicht
- Problem #4 : afterEvaluate und NullPointerException
- Die vollständige Lösung
- Gelernte Lektionen
- Finale Testarchitektur
- Conclusion
- Ressourcen
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
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 !
}
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 !
}
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 ! ✅
}
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)
}
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
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
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 |
Ressourcen
Haben Sie ähnliche Probleme in Ihren Tests festgestellt? Teilen Sie Ihre Erfahrung in den Kommentaren mit!