บทนำ

ในขณะที่พัฒนาปลั๊กอิน Gradleร้านเบเกอรีสำหรับบล็อก JBake ของฉัน ฉันได้พบปัญหาที่ดูเหมือนจะง่าย : การทดสอบหน่วยล้มเหลวเนื่องจากข้อผิดพลาด`Wanted but not invoked`. สิ่งที่ดูเหมือนจะเป็นบั๊กเล็กน้อยกลับกลายเป็นกรณีศึกษาที่สมบูรณ์แบบในการทำความเข้าใจรายละเอียดยิบย่อยของการใช้ 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 : ตรวจสอบม็อกที่ไม่ถูกต้อง

การวินิจฉัย

@startuml
actor Test
participant "project.plugins" as PP
participant "mockPluginContainer" as MPC
participant "BakeryPlugin" as BP

Test -> Test: createMockProject()
activate Test
Test -> MPC: สร้างม็อก
Test -> PP: doReturn mockPluginContainer
Test --> Test: ส่งคืนโครงการ
deactivate Test

Test -> BP: apply(project)
activate BP
BP -> PP: project.plugins
PP --> BP: กลับมา mockPluginContainer
BP -> MPC: apply(JBakePlugin::class.java)
deactivate BP

Test -> PP: verify(project.plugins).apply(...)
note right
❌ ÉCHEC !
Mockito ตรวจสอบ "project.plugins"
แต่การโต้ตอบเกิดขึ้นบน
"mockPluginContainer
end note
@enduml

ปัญหา : ไม่สามารถตรวจสอบการโต้ตอบของ Mockito บนได้`project.plugins`เพราะว่าเป็นเพียงแค่ getter ที่คืนค่า mock ที่แท้จริง`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 !
}
@startuml
participant "Mockito" as M
participant "ผู้สร้างม็อก" as MB
participant "ม็อกอินไลน์" as IM

M -> MB: mock<Project> { ... }
activate MB
MB -> MB: บน { logger } doReturn
MB -> IM: mock()
activate IM
note right
❌ การสร้างม็อกใหม่
PENDANT กำลังทำ stubbing อยู่ !
Mockito สูญเสียบริบท
ของ stubbing ผู้ปกครอง
end note
IM --> MB: ม็อกใหม่
deactivate IM
MB -x M: UnfinishedStubbingException
deactivate MB
@enduml

วิธีแก้ไข

สร้างทุกคนม็อกที่อยู่นอกบล็อก stub ทั้งหมด, แล้วจึงกำหนดค่าให้กับมันด้วย`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 : ไม่พบไฟล์การกำหนดค่า

การวินิจฉัย

แม้ว่าจะใช้ Mock ที่ถูกต้องแล้ว แต่การทดสอบก็ยังล้มเหลวอยู่ เนื่องจากปลั๊กอินไม่สามารถค้นหาไฟล์กำหนดค่าได้:

// 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 "Project Layout" as PL
participant "Extension" as EXT
participant "FileSystem" 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
ปลั๊กอินไม่พบไฟล์และออกจากระบบก่อนที่จะนำ JBake มาใช้
end note
@enduml

วิธีแก้ไข。

ตั้งค่า mock ให้การกำหนดเส้นทางทำงาน :

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 "resolve()" SUCCESS
    card "/real/path/to/project/site.yml" SUCCESS

    card "exists()" SUCCESS
    card "true ✅" 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 "BakeryPlugin" as BP
participant "mockProject" as MP
participant "afterEvaluate Action" as AE

T -> BP: apply(project)
activate BP
BP -> MP: afterEvaluate { ... }
activate MP
note right
Mock ที่ถูกกำหนดค่าเพื่อ
ทำงานทันที
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
* การม็อกด้วย Mockito
** ✅ แนวปฏิบัติที่ดี
*** สร้างม็อกทั้งหมดก่อน
*** กำหนดค่าโดยใช้ whenever() แยกต่างหาก
*** ตรวจสอบม็อคที่แม่นยำ ไม่ใช่พร็อกซี่
*** ทดสอบการแก้ไขเส้นทาง
** ❌ สิ่งที่ต้องหลีกเลี่ยง
*** mock() ในบล็อกการกำหนดค่า
*** ตรวจสอบ project.x แทน mockX
*** ข้ามบล็อก afterEvaluate
*** ส่งคืน null สำหรับผู้ให้บริการ
@endmindmap

ตรวจสอบว่าม็อกนี้ถูกต้อง

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

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

2. หลีกเลี่ยงการใช้ Mock แบบซ้อนกัน

// ❌ 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. จำลอง Callback การประเมิน

// ✅ 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

  • ความสำคัญของการทำ Mock วัตถุที่เหมาะสม

  • การจัดการกับ callback แบบอะซิงโครนัสในการทดสอบ

  • การแก้ไขเส้นทางในปลั๊กอิน Gradle

การดีบักอย่างเป็นระบบ โดยการทำความเข้าใจแต่ละชั้นของปัญหา นำไปสู่ผลลัพธ์ที่แข็งแรงและบำรุงรักษาได้

คำแนะนำสำหรับการทดสอบของคุณหากคุณพบ "Wanted but not invoked" ขณะใช้ Mockito ให้ถามตัวเองเสมอ: 1. ฉันกำลังตรวจสอบ mock ที่ถูกต้องอยู่หรือเปล่า? 2. ทุก mock ของฉันถูกสร้างขึ้นนอกบล็อกการตั้งค่าใช่ไหม? 3. Callback ของฉันทำงานจริงหรือไม่ ? 4. ทางของฉันทำงานได้ถูกต้องไหม?

ทรัพยากร


คุณพบปัญหาที่คล้ายกันในการทดสอบของคุณหรือไม่? แบ่งปันประสบการณ์ของคุณในความคิดเห็น!

บทความที่เกี่ยวข้อง