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!

Artículos relacionados