Debugging Mockito: Résoudre l'erreur "Wanted but not invoked" dans les tests de plugins Gradle
Δημοσιεύτηκε 17 November 2024
- Εισαγωγή
- Το πλαίσιο : Plugin Gradle Bakery
- Πρόβλημα #1 : Ελέγξτε το λάθος mock
- Πρόβλημα #2: UnfinishedStubbingException σε καскаδο
- Πρόβλημα #3 : Το αρχείο ρυθμίσεων δεν υπάρχει
- Πρόβλημα #4 : afterEvaluate και NullPointerException
- Η πλήρης λύση
- Μαθήματα που έχουμε μάθει
- Τελική δοκιμαστική αρχιτεκτονική
- Συμπέρασμα
- Πόροι
Εισαγωγή
Κατά την ανάπτυξη του plugin GradleΑρτοποιείοΓια το blog μου JBake, συνάντησα μια φαινομενικά απλή πρόβλημα: ένας έλεγχος μονάδας που απέτυχε με το λάθος.Wanted but not invoked. Το φαινόταν ένα απλό bug και έδειξε ότι είναι ένα παράδειγμα διδασκαλίας για να καταλάβουμε τις λεπτομέρειες του mocking με το Mockito και το Kotlin.
Στο άρθρο αυτό, σας οδηγώ σε ένα μεθοδικό ταξίδι debugging, όπου κάθε λύση αποκαλύπτει ένα νέο πρόβλημα, μέχρι την τελική λύση.
Το πλαίσιο : 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 "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 ❌ ÉCHEC ! Mockito vérifie "project.plugins" mais l'interaction a eu lieu sur "mockPluginContainer" end note @enduml
Το πρόβλημα: Mockito δεν μπορεί να παρακολουθήσει τις αλληλεπιδράσεις σε`project.plugins`επειδή είναι μόνο un 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 "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
❌ 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
Η λύση
Δημιουργώόλοιτα mocks εκτός από κάθε μπλοκ stubbing, στη συνέχεια τα ρυθμίζουμε με`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. Πάντα δημιουργείτε τα mocks πρώτα, στη συνέχεια τα ρυθμίζετε. |
Πρόβλημα #3 : Το αρχείο ρυθμίσεων δεν υπάρχει
Η διάγνωση
Ακόμη και με τους σωστούς mocks, ο έλεγχος απέτυχε συνέχεια επειδή το plugin δεν ήταν σε θέση να βρει το αρχείο ρυθμίσεων:
// 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
Le plugin ne trouve pas
le fichier et sort
avant d'appliquer JBake
end note
@enduml
Η λύση
�Ρυθμίστε τα 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 ! ✅
}
@startuml
!define SUCCESS #90EE90
!define PROBLEM #FFB6C1
rectangle "Μοκ διαμόρφωση" {
card "projectDirectory.asFile" SUCCESS
card "returns: /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 "import sys\nsys.stdout.write(sys.stdin.read())" 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
}
}
Το mock του`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 "Test" 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 configuré pour
exécuter immédiatement
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: ✅ 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. Μόκαρε τα 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 "Mock Objects" {
[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 στα σωστά αντικείμενα
-
Η διαχείριση των ασυγχρονών callbacks στα tests
-
Η επίλυση διαδρομών στα πρόσθετα Gradle
Η μεθοδική αποσφαλμάτωση, κατανόοντας κάθε επίπεδο του προβλήματος, οδήγησε σε μια ανθεκτική και διατηρήσιμη λύση.
|
Συμβουλή για τα τεστ σαςΕάν συναντήσετε το "Wanted but not invoked" με το Mockito, πάντα ρωτήστε τον εαυτό σας: 1. Έλεγχω το σωστό mock ? 2. Είναι όλα τα mocks μου δημιουργημένα εκτός των μπλοκ ρυθμίσεων; 3. Τρέχουν πραγματικά οι callbacks μου; 4. Οι διαδρομές μου λύνονται σωστά; |
Πόροι
Έχετε συναντήσει προβλήματα παρόμοια στα δοκιμαστικά σας; Μοιραστείτε την εμπειρία σας στα σχόλια!