معرفی

در حین توسعه پلاگین Gradleنانوايبرای وبلاگ JBake من، یک مشکل به ظاهر ساده روبرو شدم: یک تست واحد که با خطا شکست می‌خورد`Wanted but not invoked`. چه چیزی که به نظر می‌رسید یک باگ عادی بود، به یک مثال عالی برای درک جزئیات موکینگ با Mockito و Kotlin تبدیل شد.

در این مقاله، شما را در یک سفر دیباگینگ منظم می‌برم، جایی که هر راه‌حل یک مسئله جدید را فاش می‌سازد، تا به حل نهایی برسیم.

بخش زمینه : Plugin Gradle Bakery

افزونه Bakery یک لبه‌پوش (wrapper) است که به JBake بسته می‌شود و انتشار سایت‌های استاتیک را آسان می‌سازد. ساختار ساده‌شده آن به شرح زیر است :

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
fun `plugin applies jbake gradle plugin`() {
    val project = createMockProject()
    val plugin = BakeryPlugin()

    plugin.apply(project)

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

خطا :

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

مشکل #1: بررسی mock غلط

تشخیص

@startuml
actor Test
participant "project.plugins" as PP
participant "شاسي افزونه شبیه‌سازی‌شده" as MPC
participant "پلاگین نانوایی" 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
  "شاسي افزونه شبیه‌سازی‌شده"
end note
@enduml

مشکل: Mockito نمی‌تواند تعاملات را روی ردیابی کند`project.plugins`چون تنها یک getter است که mock واقعی را برمی‌گرداند`mockPluginContainer`بررسی باید مستقیماً روی نمونهٔ mock انجام شود.

حل

ویرایش`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 به صورت زنجیره‌ای

تشخیص

پس از اعمال اولین اصلاح، یک خطا جدید ظاهر شد:

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

کد مشکل‌دار از سینتکس DSL 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 "موک در خط" 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

راه حل

ایجادهمهمک‌ها را خارج از هر بلوک استابینگ بگذارید، سپس آن‌ها را با پیکربندی کنید`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()`در داخل یک بلوک پیکربندی mock. همیشه ابتدا mock‌ها را ایجاد کنید، سپس آنها را پیکربندی کنید.

مشکل #3 : فایل تنظیمات وجود ندارد

تشخیص

حتی با مک‌های صحیح، تست همیشه به این دلیل که پلاگین نتوانست فایل تنظیمات را پیدا کند، شکست می‌خورد :

// 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 "پلاگین نانواژی" as BP
participant "چیدمان پروژه" as PL
participant "تمدید" as EXT
participant "سیستم فایل" 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

راه‌حل

تنظیم موک‌ها برای اینکه حل مسیر کار کند :

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 "پیکربندی موک" {
    card "projectDirectory.asFile" SUCCESS
    card "بازگشت: /real/path/to/project" SUCCESS

    card "configPath.get()" SUCCESS
    card "بازگشت: site.yml" SUCCESS

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

    card "exists()" SUCCESS
    card "درست ✅" SUCCESS
}
@enduml

مشکل #4 : afterEvaluate و NullPointerException

تشخیص

پلاگین JBake را در یک بلوک اعمال می‌کند`afterEvaluate`, و دسترسی به`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()باز می‌گشت`null.

حل

طنزگو`afterEvaluate`برای اینکه بلافاصله اجرا شود و به طور کامل پیکربندی شود`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 "افزونه نانوایی" as BP
participant "پروژه موک" as MP
participant "afterEvaluate Action" 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

حل کامل

این تابع است`createMockProject()`پایان، که تمام مشکلات را حل می‌کند:

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

و تست نهایی که می‌گذرد :

@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

1. بررسی mock خوب

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

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

2. اجتناب از mocks تو در تو

// ❌ 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. مocke کردن callbacks ارزیابی

// ✅ 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. تست حل مسیرها

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

معماری تست نهایی

@startuml
package "لایهٔ تست" {
    [Test Class] --> [createMockProject()]
}

package "ایجاد ساختگی" {
    [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 "پلاگین تحت تست" {
    [Test Class] --> [BakeryPlugin]
    [BakeryPlugin] --> [Project Mock]
    [BakeryPlugin] --> [PluginContainer Mock]
}

package "تایید" {
    [Test Class] --> [verify(mockPluginContainer)]
}
@enduml

نتیجه

چه چیزی که به نظر می‌رسید یک مشکل آزمایشی ساده بود، نشان داد که یک مطالعه موردی عالی است :

  • نکات دقیق Mockito با Kotlin

  • اهمیت مOCKING اشیاء مناسب

  • مدیریت کال‌بک‌های ناهمگام در تست‌ها

  • حل مسیرها در پلاگین‌های Gradle

رفع اشکال منظم، با درک هر لایه از مشکل، به رسیدن به یک راه‌حل قوی و قابل نگهداری منجر شد.

نکته برای تست‌های شمااگر با Mockito 'Wanted but not invoked' را مواجه شوید، همیشه از خودتان بپرسید: 1. آیا من الماک صحیح را بررسی می‌کنم؟ 2. آیا تمام موک‌های من خارج از بلوک‌های تنظیمات ایجاد شده‌اند؟ 3. آیا callback‌های من در واقع اجرا می‌شوند؟ 4. آیا مسیرهای من به‌درستی حل می‌شوند؟

منابع


آیا شما در تست‌های خود مشکلات مشابهی را تجربه کرده‌اید؟ تجربه خود را در کامنت‌ها به اشتراک بگذارید!

مقالات مرتبط