Giriş

Gradle eklentisi geliştirilirkenFırınJBake blogum için, basit görünen bir sorunla karşılaştım: Bir birim testi hatayla başarısız`Wanted but not invoked`. Görünüşe göre küçük bir hata gibi görünen şey, aslında Mockito ve Kotlin ile mocklamanın ince ayrıntılarını anlamak için mükemmel bir örneğiydi.

Bu makalede, metodik bir hata ayıklama yolculuğuna sizi götürüyorum; her çözüm yeni bir sorun ortaya çıkarıyor ve nihai çözüme kadar devam ediyor.

Bağlam: Gradle Bakery eklentisi

Bakery eklentisi, statik sitelerin yayınlanmasını kolaylaştıran JBake’in bir sarmalayıcısıdır. Basitleştirilmiş yapısı şöyledir:

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

Başarısız olan test basitt :

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

    plugin.apply(project)

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

Hata:

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

Problem #1 : Yanlış mocku kontrol et

Tanı

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

Test -> Test: createMockProject()
activate Test
Test -> MPC: mock oluştur
Test -> PP: doReturn mockPluginContainer
Test --> Test: geri dönüş projesi
deactivate Test

Test -> BP: apply(project)
activate BP
BP -> PP: project.plugins
PP --> BP: döndürür mockPluginContainer
BP -> MPC: apply(JBakePlugin::class.java)
deactivate BP

Test -> PP: verify(project.plugins).apply(...)
note right
ÉCHEC !
Mockito "project.plugins" kontrol eder.
ama etkileşim "mockPluginContainer"'de gerçekleşti.
end note
@enduml

Sorun : Mockito etkileşimlerini izleyemez`project.plugins`çünkü sadece gerçek mock’u döndüren bir getterdir`mockPluginContainer`. Doğrulama doğrudan mock örneği üzerinde yapılmalı.

Çözüm

Değiştir`createMockProject()`iki nesneyi geri döndürmek için :

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

Ve testi uyarlayın:

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

Sorun #2 : Zincirli UnfinishedStubbingException

Tanı

Birinci düzeltme uygulandığında, yeni bir hata ortaya çıktı :

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

Sorunlu kod, Mockito-Kotlin’in DSL sözdizimini kullanıyordu :

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 "Mock Builder" 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

Çözüm

oluşturher şeymock’lar stubbing bloğu dışında, sonra bunları ile yapılandırma`whenever()`(empty)

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

Altın kural: Asla arama yapma`mock()`Bir mock yapılandırma bloğunun içinde. Mock’ları her zaman önce oluşturun, ardından yapılandırın.

Sorun #3 : Yapılandırma dosyası bulunamadı

Teşhis

Doğru mock’lar kullanılmış olsa bile, test daima başarısız oluyordu çünkü plugin yapılandırma dosyasını bulamıyordu:

// 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 "Proje Düzeni" as PL
participant "Uzantı" 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
Eklenti dosyayı bulamaz ve JBake uygulanmadan önce çıkar.
end note
@enduml

Çözüm

Yol çözünmesi çalışacak şekilde mockları yapılandırın :

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 Yapılandırması" {
    card "projectDirectory.asFile" SUCCESS
    card "döndürür: /real/path/to/project" SUCCESS

    card "configPath.get()" SUCCESS
    card "döndürür: site.yml" SUCCESS

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

    card "exists()" SUCCESS
    card "true ✅" SUCCESS
}
@enduml

Sorun #4 : afterEvaluate ve NullPointerException

Tanı

Eklenti JBake’i bir blokta uygular`afterEvaluate`, ve erişir`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
        }
}

Mock’un`buildDirectory.dir()dönüyordu`null.

Çözüm

alay etmek`afterEvaluate`hemen yürütülmesi için ve tamamen yapılandırılması`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 "Test" as T
participant "BakeryPlugin" as BP
participant "mockProject" as MP
participant "afterEvaluate Aksiyon" as AE

T -> BP: apply(project)
activate BP
BP -> MP: afterEvaluate { ... }
activate MP
note right
Anında çalışacak şekilde yapılandırılmış mock
end note
MP -> AE: execute(project)
activate AE
AE -> MP: layout.buildDirectory.dir("pişir")
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

Tam çözüm

İşte fonksiyon`createMockProject()`Son, tüm sorunları çözen:

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

Ve final test geçer:

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

Öğrenilen dersler

@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

Doğru mock’u kontrol edin

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

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

2. İç içe mock’ları kullanmaktan kaçının

// ❌ 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. Değerlendirme geri çağırmalarını taklit edin

// ✅ 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. Yol çözümlemesini test et

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

Final test mimarisi

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

package "Mock Oluşturma" {
    [createMockProject()] --> [Create All Mocks]
    [Create All Mocks] --> [Configure Paths]
    [Configure Paths] --> [Configure Callbacks]
    [Configure Callbacks] --> [Assemble Project]
}

package "Mock Nesneleri" {
    [Assemble Project] --> [Project Mock]
    [Assemble Project] --> [PluginContainer Mock]
    [Assemble Project] --> [Extension Mocks]
    [Assemble Project] --> [Layout Mocks]
}

package "Test Altındaki Eklenti" {
    [Test Class] --> [BakeryPlugin]
    [BakeryPlugin] --> [Project Mock]
    [BakeryPlugin] --> [PluginContainer Mock]
}

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

Sonuç

Basit bir test sorunu gibi görünen şey, bir konu üzerinde mükemmel bir vaka çalışması olarak ortaya çıktı :

  • Kotlin ile Mockito’nun incelikleri

  • Doğru nesneleri mock etmenin önemi

  • Testlerde asenkron callback yönetimi

  • Gradle ektentilerdeki yol çözümleme

Metodolojik hata ayıklama, sorunun her katmanını anlayarak güçlü ve sürdürülebilir bir çözüm elde edilmesine olanak tanımıştır.

Testleriniz için tavsiye: "Wanted but not invoked" Mockito ile, her zaman kendinize şunu sorun : 1. Doğru mock’u mu kontrol ediyorum? 2. Tüm mocklarım konfigürasyon blokları dışında mı oluşturuluyor? 3. Callback’lar gerçekten çalışıyor mu? 4. Yollarım doğru bir şekilde çözülüyor mu?

Kaynaklar


Testlerinizde benzer sorunlarla karşılaştınız mı? Deneyiminizi yorumlarda paylaşın!

İlgili Makaleler