Depuración de Mockito: Resolver el error "Wanted but not invoked" en las pruebas de plugins Gradle
Publicado el 17 November 2024
- Introducción
- El contexto : Plugin Gradle Bakery
- Problema #1: Verificar el mock incorrecto
- Problema #2: UnfinishedStubbingException en cascada
- Problema #3 : El archivo de configuración no existe
- Problema #4 : afterEvaluate y NullPointerException
- La solución completa
- Lecciones aprendidas
- Arquitectura de prueba final
- Conclusión
- Recursos
Introducción
Durante el desarrollo del plugin Gradlepanaderíapara mi blog JBake, encontré un problema aparentemente simple: una prueba unitaria que fallaba con el error`Wanted but not invoked`. Lo que parecía ser un error trivial resultó ser un caso de estudio perfecto para comprender las sutilezas del mocking con Mockito y Kotlin.
En este artículo, te llevo en un viaje de depuración metódico, donde cada solución revela un nuevo problema, hasta la resolución final.
El contexto : Plugin Gradle Bakery
El plugin Bakery es un wrapper alrededor de JBake que facilita la publicación de sitios estáticos. Aquí está su estructura simplificada :
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...
}
}
}
}
La prueba que fallaba era simple :
@Test
fun `plugin applies jbake gradle plugin`() {
val project = createMockProject()
val plugin = BakeryPlugin()
plugin.apply(project)
verify(project.plugins).apply(JBakePlugin::class.java)
}
El error :
Wanted but not invoked:
pluginContainer.apply(class org.jbake.gradle.JBakePlugin);
Actually, there were zero interactions with this mock.
Problema #1: Verificar el mock incorrecto
El diagnóstico
@startuml actor Test participant "project.plugins" as PP participant "mockPluginContainer" as MPC participant "BakeryPlugin" as BP Test -> Test: createMockProject() activate Test Test -> MPC: crear simulacro Test -> PP: doReturn mockPluginContainer Test --> Test: devolver proyecto deactivate Test Test -> BP: apply(project) activate BP BP -> PP: project.plugins PP --> BP: devuelve mockPluginContainer BP -> MPC: apply(JBakePlugin::class.java) deactivate BP Test -> PP: verify(project.plugins).apply(...) note right ❌ ÉCHEC ! Mockito verifica "project.plugins" pero la interacción tuvo lugar en "mockPluginContainer end note @enduml
El problema: Mockito no puede rastrear las interacciones en`project.plugins`porque solo es un getter que devuelve el mock real`mockPluginContainer`. La verificación debe hacerse directamente en la instancia del mock.
La solución
Modificar`createMockProject()`para devolver los dos objetos :
private fun createMockProject(): Pair<Project, PluginContainer> {
val mockPluginContainer = mock<PluginContainer>()
val mockProject = mock<Project> {
on { plugins } doReturn mockPluginContainer
}
return Pair(mockProject, mockPluginContainer)
}
Y adaptar la prueba :
@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)
}
Problema #2: UnfinishedStubbingException en cascada
El diagnóstico
Una vez aplicada la primera corrección, apareció un nuevo error:
UnfinishedStubbingException:
Unfinished stubbing detected here
Hints:
3. you are stubbing the behaviour of another mock inside
before 'thenReturn' instruction is completed
El código problemático utilizaba la sintaxis DSL de 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 "Creador de simulacros" as MB
participant "Mock en línea" as IM
M -> MB: mock<Project> { ... }
activate MB
MB -> MB: se { logger } doReturn
MB -> IM: mock()
activate IM
note right
❌ Creación de un nuevo mock
PENDANT ¡el stubbing en curso!
Mockito pierde el contexto
del stubbing padre
end note
IM --> MB: nuevo mock
deactivate IM
MB -x M: UnfinishedStubbingException
deactivate MB
@enduml
La solución
Creartodoslos mocks fuera de cualquier bloque de stubbing, luego configurarlos con`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)
}
|
Regla de oro: Nunca llamar`mock()`dentro de un bloque de configuración de mock. Siempre cree los mocks primero, luego configúrelos. |
Problema #3 : El archivo de configuración no existe
El diagnóstico
Incluso con los mocks correctos, el test siempre fallaba porque el plugin no encontraba el archivo de configuración :
// 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 "Diseño del proyecto" as PL
participant "Extensión" 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
El plugin no encuentra
el archivo y sale
antes de aplicar JBake
end note
@enduml
La solución
Configurar los mocks para que la resolución del camino funcione :
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 "Configuración de prueba" {
card "projectDirectory.asFile" SUCCESS
card "devuelve: /real/path/to/project" SUCCESS
card "configPath.get()" SUCCESS
card "returns: site.yml" SUCCESS
card "resolver()" SUCCESS
card "/real/path/to/project/site.yml" SUCCESS
card "existe()" SUCCESS
card "verdadero ✅" SUCCESS
}
@enduml
Problema #4 : afterEvaluate y NullPointerException
El diagnóstico
El plugin aplica JBake en un bloque`afterEvaluate`, y accede a`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
}
}
El mock de`buildDirectory.dir()volvía`null.
La solución
burlar`afterEvaluate`para que se ejecute inmediatamente, y configurarlo completamente`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 "prueba" as T
participant "BakeryPlugin" as BP
participant "mockProject" as MP
participant "afterEvaluate Acción" as AE
T -> BP: apply(project)
activate BP
BP -> MP: afterEvaluate { ... }
activate MP
note right
Mock configurado para
ejecuta inmediatamente
end note
MP -> AE: execute(project)
activate AE
AE -> MP: layout.buildDirectory.dir("hornear")
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
La solución completa
Aquí está la función`createMockProject()`Finalmente, que resuelve todos los problemas :
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)
}
Y la prueba final que pasa:
@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 !
}
Lecciones aprendidas
@startmindmap * Mocking con Mockito ** ✅ Buenas prácticas *** Crear todos los mocks primero *** Configurar con whenever() por separado *** Verificar el mock exacto, no un proxy *** Probar la resolución de rutas ** ❌ Trampas a evitar *** mock() en un bloque de configuración *** Verificar project.x en lugar de mockX *** Ignorar los bloques afterEvaluate *** Devolver null para los Proveedores @endmindmap
1. Verificar el mock correcto
// ❌ FAUX
verify(project.plugins).apply(JBakePlugin::class.java)
// ✅ CORRECT
val (project, mockPluginContainer) = createMockProject()
verify(mockPluginContainer).apply(JBakePlugin::class.java)
2. Evitar los mocks anidados
// ❌ 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. Simular los callbacks de evaluación
// ✅ 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. Probar la resolución de los caminos
// 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()}")
Arquitectura de prueba final
@startuml
package "Capa de prueba" {
[Test Class] --> [createMockProject()]
}
package "Creación simulada" {
[createMockProject()] --> [Create All Mocks]
[Create All Mocks] --> [Configure Paths]
[Configure Paths] --> [Configure Callbacks]
[Configure Callbacks] --> [Assemble Project]
}
package "Objetos simulados" {
[Assemble Project] --> [Project Mock]
[Assemble Project] --> [PluginContainer Mock]
[Assemble Project] --> [Extension Mocks]
[Assemble Project] --> [Layout Mocks]
}
package "Plugin en prueba" {
[Test Class] --> [BakeryPlugin]
[BakeryPlugin] --> [Project Mock]
[BakeryPlugin] --> [PluginContainer Mock]
}
package "Verificación" {
[Test Class] --> [verify(mockPluginContainer)]
}
@enduml
Conclusión
Lo que parecía ser un problema sencillo de prueba resultó ser un excelente caso de estudio sobre:
-
Las sutilezas de Mockito con Kotlin
-
La importancia de mockear los objetos correctos
-
La gestión de los callbacks asíncronos en las pruebas
-
La resolución de rutas en los plugins de Gradle
La depuración metódica, al comprender cada capa del problema, ha permitido llegar a una solución robusta y mantenible.
|
Consejo para tus pruebas: Si encuentras "Wanted but not invoked" con Mockito, pregúntese siempre : 1. ¿Estoy verificando el mock correcto? 2. ¿Se crean todos mis mocks fuera de los bloques de configuración? 3. ¿Se ejecutan realmente mis callbacks? 4. ¿Se resuelven correctamente mis caminos? |
Recursos
¿Has encontrado problemas similares en tus pruebas? ¡Comparte tu experiencia en los comentarios!