Debugging Mockito: حل الخطأ "Wanted but not invoked" في اختبارات إضافات Gradle
Publié le 17 November 2024
مقدمة
أثناء تطوير المكون الإضافي Gradleمخبزلمدونتي JBake، واجهت مشكلة تبدو بسيطة : اختبار وحدة كان يفشل مع خطأ`Wanted but not invoked`. ما بدا أنه عطل بسيط تبين أنه مثال نموذجي لفهم دقاق الـ mocking مع Mockito و Kotlin.
في هذا المقال، آخذكم في رحلة تصحيح أخطاء منهجية، حيث يكشف كل حل عن مشكلة جديدة، حتى الوصول إلى الحل النهائي.
السياق : إضافة Gradle Bakery
ملحق Bakery هو غلاف حول 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 : تحقق من المزيف السيء
التشخيص
المشكلة : لا يمكن لـ Mockito تتبع التفاعلات على`project.plugins`لأنه مجرد getter الذي يعيد المॉक الحقيقي`mockPluginContainer`. يجب إجراء التحقق مباشرة على نسخة الموك
الحل
عدل`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 !
}
الحل
إنشاءجميعالموكات خارج أي كتلة استدعاء، ثم تكوينها مع`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()`داخل تكوين محاكاة. دائمًا قم بإنشاء المحاكيات أولًا ثم قم بتكوينها. |
المشكلة #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 !
}
الحل
قم بتكوين الـ mocks بحيث يعمل حل المسار :
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 ! ✅
}
المشكلة #4: afterEvaluate و NullPointerException
التشخيص
يقوم البرنامج المساعد بتطبيق JBake في جزء`afterEvaluate`, ويصل إلى`buildDirectory.dir()`(No output, as the French text to translate is empty)
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)
}
الحل الكامل
هذه هي الدالة`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 !
}
الدروس المستفادة
تحقق من المحاكاة الصحيحة
// ❌ FAUX
verify(project.plugins).apply(JBakePlugin::class.java)
// ✅ CORRECT
val (project, mockPluginContainer) = createMockProject()
verify(mockPluginContainer).apply(JBakePlugin::class.java)
2. تجنب الموكات المتداخلة
// ❌ 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. محاكاة استدعاءات التقييم
// ✅ 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()}")
الهندسة المعمارية للاختبار النهائي
خاتمة
ما بدا أنه مجرد مشكلة اختبار تبيّن أنه حالة دراسة ممتازة على :
-
التفاصيل الدقيقة لـ Mockito مع Kotlin
-
أهمية محاكاة الكائنات الصحيحة
-
إدارة الاستدعاءات غير المتزامنة في الاختبارات
-
حل المسارات في إضافات Gradle
التصحيح المنهجي، من خلال فهم كل طبقة من المشكلة، أدى إلى حل قوي وقابل للصيانة.
|
نصيحة لاختباراتك: إذا واجهت "Wanted but not invoked" مع Mockito، اسأل نفسك دائمًا : 1. هل أتحقق من الـ mock الصحيح؟ 2. هل جميع mocks الخاصة بي تم إنشاؤها خارج كتل التكوين؟ 3. هل يتم تنفيذ ردود الاتصال الخاصة بي حقًا؟ 4. هل تحلّ مساراتي بشكل صحيح؟ |
الموارد
هل واجهت مشكلات مماثلة في اختباراتك؟ شارك تجربتك في التعليقات !