Gỡ lỗi Mockito: Giải quyết lỗi \"Wanted but not invoked\" trong các test của plugin Gradle
Đăng ngày 17 November 2024
Giới thiệu
Trong quá trình phát triển plugin GradleTiệm bánhcho blog JBake của mình, tôi đã gặp một vấn đề có vẻ đơn giản: một bài kiểm tra đơn vị (unit test) bị lỗi với thông báo`Wanted but not invoked`Điều tưởng chừng như là một lỗitrivial đã hóa ra là một trường hợp điển hình hoàn hảo để hiểu rõ những điểm tinh tế của mocking với Mockito và Kotlin.
Trong bài viết này, tôi sẽ đưa bạn vào một hành trình debugging có phương pháp, nơi mỗi giải pháp lại hé lộ một vấn đề mới, cho đến khi tìm ra lời giải cuối cùng.
Ngữ cảnh: Plugin Gradle Bakery
Plugin Bakery là một wrapper bao quanh JBake giúp đơn giản hóa việc xuất bản các trang web tĩnh. Dưới đây là cấu trúc rút gọn của nó:
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...
}
}
}
}
Bài kiểm tra bị lỗi rất đơn giản:
@Test
fun `plugin applies jbake gradle plugin`() {
val project = createMockProject()
val plugin = BakeryPlugin()
plugin.apply(project)
verify(project.plugins).apply(JBakePlugin::class.java)
}
Lỗi:
Wanted but not invoked:
pluginContainer.apply(class org.jbake.gradle.JBakePlugin);
Actually, there were zero interactions with this mock.
Vấn đề #1: Kiểm tra mock sai
Chẩn đoán
@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 ❌ THẤT BẠI ! Mockito kiểm tra "project.plugins" nhưng tương tác đã xảy ra trên "mockPluginContainer" end note @enduml
Vấn đề là: Mockito không thể theo dõi các tương tác trên`project.plugins`vì đây chỉ là một getter trả về mock thực sự`mockPluginContainer`Việc kiểm tra phải được thực hiện trực tiếp trên instance của mock.
Giải pháp
Chỉnh sửa`createMockProject()`để trả về hai đối tượng:
private fun createMockProject(): Pair<Project, PluginContainer> {
val mockPluginContainer = mock<PluginContainer>()
val mockProject = mock<Project> {
on { plugins } doReturn mockPluginContainer
}
return Pair(mockProject, mockPluginContainer)
}
Và điều chỉnh bài kiểm tra:
@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)
}
Vấn đề #2: UnfinishedStubbingException theo chuỗi
Chẩn đoán
Sau khi áp dụng bản sửa lỗi đầu tiên, một lỗi mới đã xuất hiện:
UnfinishedStubbingException:
Unfinished stubbing detected here
Hints:
3. you are stubbing the behaviour of another mock inside
before 'thenReturn' instruction is completed
Đoạn mã gây lỗi đã sử dụng cú pháp DSL của 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 "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
❌ Tạo mock mới
TRONG KHI đang stubbing!
Mockito mất ngữ cảnh
của stubbing cha
end note
IM --> MB: nouveau mock
deactivate IM
MB -x M: UnfinishedStubbingException
deactivate MB
@enduml
Giải pháp
Tạotất cảcác mock bên ngoài mọi khối stubbing, sau đó cấu hình chúng với`whenever()`Vui lòng cung cấp văn bản cần dịch.
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)
}
|
Quy tắc vàng: Không bao giờ gọi`mock()`bên trong một khối cấu hình mock. Luôn tạo các mock trước, sau đó mới cấu hình chúng. |
Vấn đề #3: Tệp cấu hình không tồn tại
Chẩn đoán
Ngay cả khi đã có các bản mock chính xác, bài kiểm tra vẫn thất bại vì plugin không tìm thấy tệp cấu hình:
// 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 "Bố cục Dự án" as PL
participant "Tiện ích mở rộng" as EXT
participant "Hệ thống Tập tin" 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
Plugin không tìm thấy
tệp và thoát
trước khi áp dụng JBake
end note
@enduml
Giải pháp
Cấu hình các mock để việc phân giải đường dẫn hoạt động:
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 "Cấu hình Giả lập" {
card "projectDirectory.asFile" SUCCESS
card "trả về: /real/path/to/project" SUCCESS
card "configPath.get()" SUCCESS
card "trả về: site.yml" SUCCESS
card "resolve()" SUCCESS
card "/real/path/to/project/site.yml" SUCCESS
card "exists()" SUCCESS
card "true ✅" SUCCESS
}
@enduml
Vấn đề #4: afterEvaluate và NullPointerException
Chẩn đoán
Plugin áp dụng JBake trong một khối`afterEvaluate`, và truy cập vào`buildDirectory.dir()`Vui lòng cung cấp văn bản cần dịch.
project.afterEvaluate {
// ...
project.tasks.withType(JBakeTask::class.java)
.getByName("bake").apply {
output = project.layout.buildDirectory
.dir(site.bake.destDirPath) // ❌ NPE ici !
.get()
.asFile
}
}
Bản mock của`buildDirectory.dir()`trả về`null`Vui lòng cung cấp văn bản cần dịch.
Giải pháp
Giả lập`afterEvaluate`để nó thực thi ngay lập tức, và cấu hình đầy đủ`buildDirectory`Vui lòng cung cấp văn bản cần dịch.
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 "Kiểm tra" as T
participant "BakeryPlugin" as BP
participant "mockProject" as MP
participant "Action afterEvaluate" as AE
T -> BP: apply(project)
activate BP
BP -> MP: afterEvaluate { ... }
activate MP
note right
Mock được cấu hình để
thực thi ngay lập tức
end note
MP -> AE: execute(project)
activate AE
AE -> MP: layout.buildDirectory.dir("bake")
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: ✅ THÀNH CÔNG !
@enduml
Giải pháp toàn diện
Đây là hàm`createMockProject()`cuối cùng, giải quyết mọi vấn đề:
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)
}
Và bài kiểm tra cuối cùng đã vượt qua:
@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 !
}
Bài học kinh nghiệm
@startmindmap * Giả lập với Mockito ** Các thực hành tốt *** Tạo tất cả các mock trước *** Cấu hình riêng với whenever() *** Kiểm tra mock chính xác, không phải proxy *** Kiểm tra giải quyết đường dẫn ** Những bẫy cần tránh *** mock() trong một khối cấu hình *** Kiểm tra project.x thay vì mockX *** Bỏ qua các khối afterEvaluate *** Trả về null cho các Nhà cung cấp @endmindmap
1. Kiểm tra mock chính xác
// ❌ FAUX
verify(project.plugins).apply(JBakePlugin::class.java)
// ✅ CORRECT
val (project, mockPluginContainer) = createMockProject()
verify(mockPluginContainer).apply(JBakePlugin::class.java)
2. Tránh các mock lồng nhau
// ❌ 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. Mock các callback đánh giá
// ✅ 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. Kiểm tra việc phân giải đường dẫn
// 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()}")
Kiến trúc kiểm thử cuối cùng
@startuml
package "Lớp Kiểm thử" {
[Test Class] --> [createMockProject()]
}
package "Khởi tạo Mock" {
[createMockProject()] --> [Create All Mocks]
[Create All Mocks] --> [Configure Paths]
[Configure Paths] --> [Configure Callbacks]
[Configure Callbacks] --> [Assemble Project]
}
package "Đối tượng Mock" {
[Assemble Project] --> [Project Mock]
[Assemble Project] --> [PluginContainer Mock]
[Assemble Project] --> [Extension Mocks]
[Assemble Project] --> [Layout Mocks]
}
package "Plugin đang được Kiểm thử" {
[Test Class] --> [BakeryPlugin]
[BakeryPlugin] --> [Project Mock]
[BakeryPlugin] --> [PluginContainer Mock]
}
package "Xác minh" {
[Test Class] --> [verify(mockPluginContainer)]
}
@enduml
Kết luận
Điều tưởng chừng như chỉ là một vấn đề kiểm thử đơn giản lại hóa ra là một trường hợp nghiên cứu điển hình tuyệt vời về:
-
Những điểm tinh tế của Mockito với Kotlin
-
Tầm quan trọng của việc mock đúng đối tượng
-
Quản lý các callback bất đồng bộ trong các bài kiểm tra
-
Việc phân giải đường dẫn trong các plugin Gradle
Việc gỡ lỗi có phương pháp, bằng cách hiểu rõ từng lớp của vấn đề, đã cho phép đạt được một giải pháp mạnh mẽ và dễ bảo trì.
|
Lời khuyên cho các bài kiểm tra của bạn: Nếu bạn gặp lỗi "Wanted but not invoked" với Mockito, hãy luôn tự hỏi:
1. Tôi có đang kiểm tra đúng |
Tài nguyên
Bạn có gặp phải các vấn đề tương tự trong các bài kiểm tra của mình không? Hãy chia sẻ trải nghiệm của bạn trong phần bình luận nhé!