Quand devrais-je me moquer?

138

J'ai une compréhension de base des objets simulés et faux, mais je ne suis pas sûr d'avoir une idée du moment et de l'endroit où utiliser la moquerie - d'autant plus que cela s'appliquerait à ce scénario ici .

Esteban Araya
la source
Je recommande de se moquer uniquement des dépendances hors processus et uniquement de celles d'entre elles, avec lesquelles les interactions sont observables en externe (serveur SMTP, bus de messages, etc.). Ne vous moquez pas de la base de données, c'est un détail d'implémentation. Plus à ce sujet ici: enterprisecraftsmanship.com/posts/when-to-mock
Vladimir

Réponses:

122

Un test unitaire doit tester un seul codepath via une seule méthode. Lorsque l'exécution d'une méthode passe en dehors de cette méthode, dans un autre objet et inversement, vous avez une dépendance.

Lorsque vous testez ce chemin de code avec la dépendance réelle, vous n'êtes pas un test unitaire; vous testez l'intégration. Bien que ce soit bon et nécessaire, ce ne sont pas des tests unitaires.

Si votre dépendance est boguée, votre test peut être affecté de manière à renvoyer un faux positif. Par exemple, vous pouvez transmettre à la dépendance une valeur null inattendue, et la dépendance peut ne pas lancer sur null comme il est documenté pour le faire. Votre test ne rencontre pas d'exception d'argument nul comme il se doit, et le test réussit.

En outre, vous pouvez trouver difficile, voire impossible, d'obtenir de manière fiable l'objet dépendant pour qu'il renvoie exactement ce que vous voulez pendant un test. Cela inclut également le lancement d'exceptions attendues dans les tests.

Un simulacre remplace cette dépendance. Vous définissez les attentes sur les appels à l'objet dépendant, définissez les valeurs de retour exactes qu'il doit vous donner pour effectuer le test souhaité et / ou les exceptions à lever afin de pouvoir tester votre code de gestion des exceptions. De cette manière, vous pouvez tester facilement l'unité en question.

TL; DR: simulez chaque dépendance que votre test unitaire touche.

Drew Stephens
la source
164
Cette réponse est trop radicale. Les tests unitaires peuvent et doivent exercer plus d'une méthode unique, tant qu'elle appartient à la même unité cohésive. Agir autrement exigerait beaucoup trop de moquerie / truquage, conduisant à des tests compliqués et fragiles. Seules les dépendances qui n'appartiennent pas vraiment à l'unité testée doivent être remplacées par moquage.
Rogério
10
Cette réponse est également trop optimiste. Ce serait mieux s'il incorporait les lacunes de @ Jan en matière d'objets simulés.
Jeff Axelrod
1
N'est-ce pas plus un argument pour injecter des dépendances pour les tests plutôt que des simulations spécifiques? Vous pourriez à peu près remplacer «mock» par «stub» dans votre réponse. Je conviens que vous devriez soit vous moquer, soit supprimer les dépendances importantes. J'ai vu beaucoup de code factice qui finit par réimplémenter des parties des objets simulés; les moqueries ne sont certainement pas une solution miracle.
Draemon
2
Simulez toutes les dépendances que votre test unitaire touche. Cela explique tout.
Teoman shipahi
2
TL; DR: simulez chaque dépendance que votre test unitaire touche. - ce n'est pas vraiment une bonne approche, dit mockito lui-même - ne vous moquez pas de tout. (voté contre)
p_champ
167

Les objets simulés sont utiles lorsque vous souhaitez tester les interactions entre une classe testée et une interface particulière.

Par exemple, nous voulons tester ces sendInvitations(MailServer mailServer)appels de méthode MailServer.createMessage()exactement une fois, et également les appels MailServer.sendMessage(m)exactement une fois, et aucune autre méthode n'est appelée sur l' MailServerinterface. C'est à ce moment que nous pouvons utiliser des objets simulés.

Avec des objets fictifs, au lieu de passer un vrai MailServerImpl, ou un test TestMailServer, nous pouvons passer une implémentation fictive de l' MailServerinterface. Avant de passer une simulation MailServer, nous la «formons», de sorte qu'elle sache quelle méthode appelle à attendre et quelles valeurs de retour renvoyer. À la fin, l'objet fictif affirme que toutes les méthodes attendues ont été appelées comme prévu.

Cela semble bon en théorie, mais il y a aussi quelques inconvénients.

Faux défauts

Si vous avez un framework fictif en place, vous êtes tenté d'utiliser un objet fictif à chaque fois que vous devez passer une interface à la classe sous le test. De cette façon, vous finissez par tester les interactions même lorsque cela n'est pas nécessaire . Malheureusement, le test indésirable (accidentel) des interactions est mauvais, car vous testez alors qu'une exigence particulière est implémentée d'une manière particulière, au lieu de cela, l'implémentation a produit le résultat requis.

Voici un exemple en pseudocode. Supposons que nous ayons créé une MySorterclasse et que nous voulions la tester:

// the correct way of testing
testSort() {
    testList = [1, 7, 3, 8, 2] 
    MySorter.sort(testList)

    assert testList equals [1, 2, 3, 7, 8]
}


// incorrect, testing implementation
testSort() {
    testList = [1, 7, 3, 8, 2] 
    MySorter.sort(testList)

    assert that compare(1, 2) was called once 
    assert that compare(1, 3) was not called 
    assert that compare(2, 3) was called once 
    ....
}

(Dans cet exemple, nous supposons que ce n'est pas un algorithme de tri particulier, tel que le tri rapide, que nous voulons tester; dans ce cas, ce dernier test serait en fait valide.)

Dans un exemple aussi extrême, il est évident pourquoi ce dernier exemple est faux. Lorsque nous modifions l'implémentation de MySorter, le premier test fait un excellent travail pour s'assurer que nous trions toujours correctement, ce qui est le but des tests - ils nous permettent de modifier le code en toute sécurité. D'un autre côté, ce dernier test se rompt toujours et il est activement nuisible; cela empêche la refactorisation.

Se moque comme des talons

Les frameworks fictifs permettent souvent également une utilisation moins stricte, où nous n'avons pas à spécifier exactement combien de fois les méthodes doivent être appelées et quels paramètres sont attendus; ils permettent de créer des objets fictifs qui sont utilisés comme stubs .

Supposons que nous ayons une méthode sendInvitations(PdfFormatter pdfFormatter, MailServer mailServer)que nous voulons tester. L' PdfFormatterobjet peut être utilisé pour créer l'invitation. Voici le test:

testInvitations() {
   // train as stub
   pdfFormatter = create mock of PdfFormatter
   let pdfFormatter.getCanvasWidth() returns 100
   let pdfFormatter.getCanvasHeight() returns 300
   let pdfFormatter.addText(x, y, text) returns true 
   let pdfFormatter.drawLine(line) does nothing

   // train as mock
   mailServer = create mock of MailServer
   expect mailServer.sendMail() called exactly once

   // do the test
   sendInvitations(pdfFormatter, mailServer)

   assert that all pdfFormatter expectations are met
   assert that all mailServer expectations are met
}

Dans cet exemple, nous ne nous soucions pas vraiment de l' PdfFormatterobjet, donc nous l'entraînons simplement à accepter discrètement tout appel et à renvoyer des valeurs de retour prédéfinies raisonnables pour toutes les méthodes sendInvitation()appelées à ce stade. Comment avons-nous élaboré exactement cette liste de méthodes de formation? Nous avons simplement exécuté le test et avons continué à ajouter les méthodes jusqu'à ce que le test réussisse. Remarquez que nous avons entraîné le stub à répondre à une méthode sans savoir pourquoi il doit l'appeler, nous avons simplement ajouté tout ce dont le test se plaignait. Nous sommes heureux, le test passe.

Mais que se passe-t-il plus tard, lorsque nous changeons sendInvitations(), ou une autre classe qui sendInvitations()utilise, pour créer des fichiers PDF plus sophistiqués? Notre test échoue soudainement car maintenant plus de méthodes de PdfFormattersont appelées et nous n'avons pas formé notre stub à les attendre. Et généralement, ce n'est pas seulement un test qui échoue dans des situations comme celle-ci, c'est tout test qui utilise, directement ou indirectement, la sendInvitations()méthode. Nous devons corriger tous ces tests en ajoutant plus de formations. Notez également que nous ne pouvons pas supprimer les méthodes dont nous n'avons plus besoin, car nous ne savons pas lesquelles d'entre elles ne sont pas nécessaires. Encore une fois, cela empêche la refactorisation.

De plus, la lisibilité du test a terriblement souffert, il y a beaucoup de code là-bas que nous n'avons pas écrit parce que nous le voulions, mais parce que nous devions le faire; ce n'est pas nous qui voulons ce code là-bas. Les tests qui utilisent des objets factices semblent très complexes et sont souvent difficiles à lire. Les tests doivent aider le lecteur à comprendre comment la classe soumise au test doit être utilisée, ils doivent donc être simples et directs. S'ils ne sont pas lisibles, personne ne les maintiendra; en fait, il est plus facile de les supprimer que de les maintenir.

Comment y remédier? Facilement:

  • Essayez d'utiliser de vraies classes au lieu de simuler autant que possible. Utilisez le réel PdfFormatterImpl. Si ce n'est pas possible, modifiez les classes réelles pour que cela soit possible. Ne pas pouvoir utiliser une classe dans les tests indique généralement des problèmes avec la classe. La résolution des problèmes est une situation gagnant-gagnant - vous avez corrigé la classe et vous avez un test plus simple. D'un autre côté, ne pas le réparer et utiliser des simulations est une situation sans issue - vous n'avez pas corrigé la classe réelle et vous avez des tests plus complexes et moins lisibles qui empêchent les refactorisations ultérieures.
  • Essayez de créer une implémentation de test simple de l'interface au lieu de vous en moquer dans chaque test, et utilisez cette classe de test dans tous vos tests. Créer TestPdfFormatterqui ne fait rien. De cette façon, vous pouvez le changer une fois pour tous les tests et vos tests ne sont pas encombrés de longues configurations où vous formez vos stubs.

Dans l'ensemble, les objets simulés ont leur utilité, mais lorsqu'ils ne sont pas utilisés avec précaution, ils encouragent souvent les mauvaises pratiques, testent les détails de mise en œuvre, entravent la refactorisation et produisent des tests difficiles à lire et à maintenir .

Pour plus de détails sur les lacunes des simulacres, consultez également Objets simulés: lacunes et cas d'utilisation .

Jan Soltis
la source
1
Une réponse bien pensée, et je suis généralement d'accord. Je dirais que puisque les tests unitaires sont des tests en boîte blanche, devoir changer les tests lorsque vous modifiez l'implémentation pour envoyer des PDF plus sophistiqués peut ne pas être un fardeau déraisonnable. Parfois, les simulacres peuvent être un moyen utile de mettre en œuvre rapidement des talons au lieu d'avoir beaucoup de plaque chauffante. Dans la pratique, il semble cependant que leur utilisation ne se limite pas à ces cas simples.
Draemon
1
Le but d'une simulation n'est-il pas que vos tests sont cohérents, que vous n'avez pas à vous soucier de vous moquer d'objets dont les implémentations changent continuellement par d'autres programmeurs à chaque fois que vous exécutez votre test et que vous obtenez des résultats de test cohérents?
PositiveGuy
1
Points très bons et pertinents (en particulier sur les tests de fragilité). J'utilisais beaucoup de simulacres quand j'étais plus jeune, mais maintenant je considère que les tests unitaires qui dépendent énormément des simulacres sont potentiellement jetables et je me concentre davantage sur les tests d'intégration (avec des composants réels)
Kemoda
6
"Ne pas pouvoir utiliser une classe dans les tests indique généralement des problèmes avec la classe." Si la classe est un service (par exemple, accès à la base de données ou proxy au service Web), elle doit être considérée comme une dépendance externe et
simulée
1
Mais que se passe-t-il plus tard, lorsque nous modifions sendInvitations ()? Si le code sous test est modifié, il ne garantit plus le contrat précédent, il doit donc échouer. Et généralement, ce n'est pas seulement un test qui échoue dans des situations comme celle-ci . Si tel est le cas, le code n'est pas implémenté de manière propre. La vérification des appels de méthode de la dépendance ne doit être testée qu'une seule fois (dans le test unitaire approprié). Toutes les autres classes utiliseront uniquement l'instance simulée. Je ne vois donc aucun avantage à mélanger l'intégration et les tests unitaires.
Christopher Will
55

Règle de base:

Si la fonction que vous testez a besoin d'un objet compliqué en tant que paramètre et qu'il serait pénible de simplement instancier cet objet (si, par exemple, elle essaie d'établir une connexion TCP), utilisez une simulation.

Orion Edwards
la source
4

Vous devez vous moquer d'un objet lorsque vous avez une dépendance dans une unité de code que vous essayez de tester et qui doit être "juste ainsi".

Par exemple, lorsque vous essayez de tester une logique dans votre unité de code mais que vous devez obtenir quelque chose d'un autre objet et que ce qui est renvoyé par cette dépendance peut affecter ce que vous essayez de tester - simulez cet objet.

Un excellent podcast sur le sujet peut être trouvé ici

Toran Billups
la source
Le lien dirige désormais vers l'épisode actuel, et non vers l'épisode prévu. Le podcast prévu est-il ce hanselminutes.com/32/mock-objects ?
C Perkins