دیباگ Mockito: حل خطای "Wanted but not invoked" در تستهای پلاگین Gradle
منتشر شده در 17 November 2024
معرفی
در حین توسعه پلاگین 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. آیا مسیرهای من بهدرستی حل میشوند؟ |
منابع
آیا شما در تستهای خود مشکلات مشابهی را تجربه کردهاید؟ تجربه خود را در کامنتها به اشتراک بگذارید!