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 mock không? 2. Có phải tất cả các mock của tôi đều được tạo bên ngoài các khối cấu hình không? 3. Các callback của tôi có thực sự chạy không? 4. Các đường dẫn của tôi có được phân giải chính xác khô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é!

Bài viết liên quan