Uvod

Pri razvoju plugina GradlepekaraZa moj blog JBake, susreo sam se s jednostavnim problemom: jedini test koji je pao uz grešku`Wanted but not invoked`. Što se čini da je bio trivijalni bug, otkriveno je kao savršeni primer za razumevanje subtiliteta mockinga uz Mockito i Kotlin.

U ovom članku vas vodim kroz metodički put ispravljanja grešaka, gde svako rešenje otkriva novi problem, do finalnog rešenja.

Kontekst: Plugin Gradle Bakery

Plug-in Bakery je omotac oko JBake-a koji olakšava objavljivanje statičkih sajtova. Evo njegove pojednostavljene strukture:

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

Test koji je pao bio je jednostavan :

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

    plugin.apply(project)

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

Greška:

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

Problem #1 : Proveri loš mock

Дијагноза

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

Test -> Test: createMockProject()
activate Test
Test -> MPC: create 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 vérifie "project.plugins"
  mais l'interaction a eu lieu sur
  "mockPluginContainer"
end note
@enduml

Проблем: Mockito не може да траце взаимодействије на`project.plugins`je samo getter koji vraća pravi mock`mockPluginContainer`. Provera mora da se izvede direktno na instanci mock objekta.

Решење

Измени`createMockProject()`за враћање два объекта :

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

И прилагодити тест:

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

Проблем #2 : UnfinishedStubbingException у каскади

Дијагностика

Posle primene prve ispravke, pojavila se nova greška :

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

Problematni kod je koristio DSL sintaksu 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 "макет градитель" 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
  ❌ Création d'un nouveau mock
  PENDANT le stubbing en cours !

  Mockito perd le contexte
  du stubbing parent
end note
IM --> MB: nouveau mock
deactivate IM
MB -x M: UnfinishedStubbingException
deactivate MB
@enduml

Решење

Kreiratisvimocks izvan svakog stubbing bloka, pa ih konfiguriši sa`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)
}

Злато правило: Никогда не звати`mock()`unutar bloka konfiguracije mocka. Uvek najprije kreirati mockove, zatim ih konfigurirati.

Проблем #3: Файл за конфигурацију не постоји.

Дијагноза

Čak i sa ispravnim mock-ovima, test je nastavio da ne uspe jer plugin nije pronašao fajl konfiguracije :

// 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 "Raspored projekta" as PL
participant "Proširenje" as EXT
participant "Sistem datoteke" 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
  Le plugin ne trouve pas
  le fichier et sort
  avant d'appliquer JBake
end note
@enduml

Решение

Podesite mockove da resavanje puta funkcionise :

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 "Mock konfiguracija" {
    card "projectDirectory.asFile" SUCCESS
    card "vraća: /real/path/to/project" SUCCESS

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

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

    card "postoji()" SUCCESS
    card "тачно ✅" SUCCESS
}
@enduml

Problem #4 : afterEvaluate i NullPointerException

Дијагноза

Плагин примењује JBake у блоку`afterEvaluate`, i pristupa`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
        }
}

Мок од`buildDirectory.dir()vraćao se`null.

Решение

prigovarač`afterEvaluate`da bi se izvršilo odmah, i potpuno konfigurisati`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 "Тест" as T
participant "BakeryPlugin" as BP
participant "mockProject" as MP
participant "posleEvaluacije Akcija" as AE

T -> BP: apply(project)
activate BP
BP -> MP: afterEvaluate { ... }
activate MP
note right
  Mock configuré pour
  exécuter immédiatement
end note
MP -> AE: execute(project)
activate AE
AE -> MP: layout.buildDirectory.dir("пеци")
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

Kompletno resenje

Ово је функција`createMockProject()`finale, koji rešava sve probleme :

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

I finalni test koji prolazi :

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

Научене лекције

@startmindmap
* Mocking avec Mockito
** ✅ Bonnes pratiques
*** Créer tous les mocks d'abord
*** Configurer avec whenever() séparément
*** Vérifier le mock exact, pas un proxy
*** Tester la résolution des chemins
** ❌ Pièges à éviter
*** mock() dans un bloc de configuration
*** Vérifier project.x au lieu de mockX
*** Ignorer les blocs afterEvaluate
*** Retourner null pour les Providers
@endmindmap

Proveriti ispravni mok

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

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

2. Izbeći ugnježene mokove

// ❌ 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. Mockovati callbackove evaluacije

// ✅ 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. Testirati rešavanje putanja

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

Završna arhitektura testiranja

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

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

package "Мок објекти" {
    [Assemble Project] --> [Project Mock]
    [Assemble Project] --> [PluginContainer Mock]
    [Assemble Project] --> [Extension Mocks]
    [Assemble Project] --> [Layout Mocks]
}

package "Dodatak u testu" {
    [Test Class] --> [BakeryPlugin]
    [BakeryPlugin] --> [Project Mock]
    [BakeryPlugin] --> [PluginContainer Mock]
}

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

Zaključak

Što se činomalo kao jednostavni problem testiranja, pokazalo se kao odličan slučaj za studiju:

  • Subtilnosti Mockita sa Kotlinom

  • Važnost mockovanja dobrih objekata

  • Управљање асинхронним callbacks у тестовима

  • Решавање путања у Gradle додацима

Metodičko debugovanje, razumevši svaki sloj problema, omogućilo je da se dođe do jakog i održivog rešenja.

Savet za vaše testove: Ako naiđete na "Wanted but not invoked" sa Mockito, uvek se pitajte : 1. Da li proveravam pravilan mock? 2. Да ли су сви моји мокови направљени изван блокова за конфигурацију? 3. Da li moji callbacks stvarno se izvršavaju? 4. Да ли мои путају решевају исправно?

ресурси


Da li ste naišli na slične probleme u vašim testovima? Podijelite svoje iskustvo u komentarima!

Повезани чланци