Mock ou pas mock ?

C’est quoi un mock ?

Avant de rentrer dans le vif du sujet, il me semble important de faire une mise au point sur les concepts dont je vais parler dans cet article.

Dans le jargon du développeur, le mot mock a tendance à être utilisé à toutes les sauces. Martin Fowler, dans l’article Mocks Aren’t Stubs, parle de différents concepts :

  • Test double : objet factice qui prétend être l’objet attendu mais dont le comportement est adapté spécifiquement aux besoins du test. Le mot double fait allusion à la doublure au cinéma. Le mot mock est souvent utilisé dans ce sens-là.
  • Dummy : objet demandé par l’API qu’on teste mais qui n’est pas utilisé lors de l’exécution du code.
  • Fake : objet qui remplit le contrat (interface) mais dont l’implémentation peut faire abstraction de certaines contraintes, ce qui fait qu’il n’est pas adapté à la production (les bases de données en mémoire en sont un bon exemple).
  • Stub : objet au comportement éventuellement configurable qui peut ne répondre que très partiellement au contrat qu’il est censé remplir. Souvent écrit spécifiquement pour un ou ensemble de tests, il peut parfois enregistrer les interactions qu’on a avec lui dans l’objectif de les vérifier par la suite.
  • Mock : objet magique au comportement pré-câblé très spécifique. Dans la plupart des langages il est nécessaire d’utiliser un outil pour générer ce genre d’objets, cet outil permettant souvent de configurer au moment de l’exécution le comportement de l’objet sous la forme : «quand on te demande ça tu fais ça».

Le mock, un outil très puissant

Je me souviens encore du jour où j’ai découvert Mockito, j’avais l’impression d’avoir trouvé un trésor. Mockito est un outil qui permet de créer des mocks en Java. Les mocks sont des objets dont le comportement est décrit lors de l’exécution. Même si ce concept est assez naturel dans les langages dynamiques et interprétés, ce n’est pas du tout le cas dans des langages statiques et compilés comme Java.

Cet aspect magique a une énorme valeur ajoutée. Il devient quasiment trivial de construire un faux objet et de lui décrire comment il doit se comporter quand on ne dispose pas d’une implémentation de l’interface dont nous avons besoin adaptée au contexte du test.

Je me suis alors mis à mocker tout, y-compris les éléments de code qui étaient pourtant complètement adaptés au contexte du test. J’utilisais Mockito partout, je demandais même à JUnit de créer automatiquement les mocks.

Quelques temps après, je me suis rendu compte que j’avais tendance à créer régulièrement des mocks d’un même objet au comportement identique dans différents tests. J’avais aussi des difficultés à faire en sorte que les tests suivent correctement les refactorings successifs que j’appliquais à mon code.

Faut-il tout mocker ?

Petit à petit j’ai pris du recul par rapport à ce que je faisais et je me suis rendu compte qu’il fallait utiliser les mocks seulement quand c’est nécessaire. Après tout, pourquoi ne pas utiliser du vrai code de production quand son comportement est tout à fait adapté dans le cadre du test ? Surtout d’ailleurs si ce vrai code est testé unitairement !

En procédant ainsi on réduit la verbosité et donc la lisibilité des tests puisqu’on n’a plus à créer et configurer le comportement des mocks. Et puis pour moi qui suis un développeur fainéant, c’est très bien puisque je n’ai pas besoin d’exécuter l’algorithme dans ma tête lorsque je dis au mock ce qu’il doit renvoyer dans telle ou telle situation, la machine s’en chargera toute seule lors de l’exécution des tests, et elle elle ne se trompera pas !

Si on ne mocke pas le code dont on dispose des implémentations tout à fait adaptées au cadre des tests, on diminue drastiquement le nombre de mocks utilisés. On utilise alors les mocks seulement dans le cas où nous ne disposons pas d’objet adapté au cadre du test.

Les dangers des mocks

Plus un test utilise des mocks, plus il est couplé à l’implémentation du code qu’il teste. En effet, lorsqu’on configure le comportement du mock, il est nécessaire de savoir exactement comment le code va l’utiliser. Ce couplage fait qu’il est fréquent de tomber dans le cas où un changement anodin dans le code va changer la façon dont l’objet factice est utilisé. Comme en général les mocks ne sont configurés pour répondre qu’à des cas très spécifiques, on tombe très vite dans un cas où le mock ne sait pas quoi faire et le test échoue. Il faut alors redéfinir le comportement du mock alors qu’avec un objet fake aucun changement n’aurait été nécessaire puisqu’il fonctionne dans un cas général et pas dans le seul cas correspondant au test.

L’utilisation intensive de mocks pose un deuxième problème. Supposons qu’un composant est mocké dans un test. Suite à un refactoring ou un changement des règles métier, ce composant doit changer de comportement, tout en gardant un prototype et un type de retour identiques. Si ce composant est testé, on adapte les tests pour qu’ils vérifient le nouveau comportement attendu et il est très probable qu’on va s’arrêter là. Or, le test dont il était question au début continue à supposer que le composant a l’ancien comportement, donc il continue de passer. Ce changement de comportement pourrait pourtant avoir des répercussions sur ce code. Malheureusement, dans de telles situations, c’est au moment de l’exécution de l’application qu’on va se rendre compte du problème. Et poutant, tout était testé !

Privilégier les objets Fake

J’utilise de plus en plus ce que Martin Fowler appelle des objets fake. Ce sont des objets qui ont une implémentation plutôt complète du contrat qu’ils représentent mais auxquels il manque certaines caractéristiques qui font qu’ils ne sont pas utilisables en production.

Typiquement, il est normalement assez facile d’implémenter un objet responsable de la persistence des données (Data Access Object ou Repository) en stockant tout en mémoire à l’aide d’une Map par exemple. Une telle implémentation peut remplir 100 % du contrat fonctionnel mais elle n’est pour autant pas utilisable en production car elle ne persiste pas les données en cas de redémarrage du serveur. Pour autant, elle peut rendre de grands services dans un test parce qu’on peut l’utiliser directement sans avoir à configurer quoi que ce soit et en plus elle devrait être très rapide en terme de temps d’exécution.

Pour s’assurer que cet objet factice remplit correctement son contrat, on peut le tester avec le même code de test que l’objet qu’il remplace. Ils sont censés tous les deux avoir un comportement identique. De plus, une fois que cet objet existe, on peut l’utiliser dans tous les tests de code qui ont besoin de l’objet qu’il remplace, et ce sans rien avoir d’autre à faire que de l’instancier.

De plus en plus d’outils proposent une implémentation alternative qui fonctionne sans aucune dépendance avec un composant tiers. C’est par exemple le cas avec des bases de données, je pense par exemple à fongo qui est une implémentation en mémoire de MongoDB. C’est très appréciable et je dirais même que pour moi c’est devenu un argument de choix pour sélectionner des technologies.

Tester sans mocker

Depuis un certain temps, mon point de vue a encore évolué et je fais désormais en sorte de ne plus avoir à utiliser de mock. Je me suis en effet rendu compte que le besoin d’utiliser un mock est la plupart du temps révélateur d’un problème de conception. En effet, bien souvent, lorsqu’on a envie d’utiliser un mock ce n’est pas parce qu’on souhaite pouvoir définir exactement comment va se comporter l’objet, c’est plutôt parce qu’on ne peut pas instancier l’objet facilement. Cela peut venir de plusieurs raisons. Parfois, l’objet en question a besoin d’énormément d’autres objets pour fonctionner, peut-être même de composants externes (bases de données, web services), et à ce moment-là j’écris généralement un fake. J’ai aussi constaté que parfois on a des difficultés à construire un objet parce que c’est un wrapper qui contient un autre objet, c’est surtout le cas pour des structures de données. Dans ce cas, je remplace le wrapper par un objet autonome (copie des champs plutôt que délégation), et son instanciation ne pose plus de problème. Dans la plupart des cas il est possible de revoir la conception à moindres frais pour faire en sorte de ne pas avoir besoin de mock.

Le seul aspect des mocks que je continue à utiliser et dont je n’ai pas encore parlé est l’enregistrement des interactions. Les mocks restent un outil très adapté lorsqu’on souhaite s’assurer qu’une méthode a bien été appelée sur un objet donnée.

Je me souviens avoir lu un article d’Antonio Goncalves qui proposait de lancer le NoMock Movement il y a quelques années et à ce moment-là je n’étais pas convaincu. Mais depuis, j’ai pris du recul, mon point de vue a évolué et je suis désormais un adepte du NoMock. On appelle ça l’expérience !

Le joker du développeur

Pour conclure cet article, je dirais que les mocks sont ni plus ni moins qu’un joker pour le développeur. Il doit les utiliser avec beaucoup de parcimonie, un peu comme si c’était la solution de dernier recours. Ils sont très utilises lorsqu’on travaille avec du code legacy mais une bonne conception devrait permettre de s’en passer totalement dans du code nouveau.

L’image d’en-tête provient de Flickr.

3 réflexions au sujet de « Mock ou pas mock ? »

  1. Cet article sur les mocks soulève des questions philosophiques sur les méthodologies de test. Je dirais que la meilleure stratégie c’est la mixité. Le tout ou rien ne peut convenir à toutes les situations.

    Faut-il fuire les mocks comme la peste ?

    Lorsque j’écris un test unitaire portant sur une classe, j’attend de ce test qu’il échoue seulement si la classe que je teste contient un bug. C’est un point vital du test unitaire: l’isolation. Avec les mocks, il est facile de « mimer » le comportement des services dont la classe a besoin.
    Tu me diras « Mais mes mocks sont dupliqués à travers mes tests ». Je dirais qu’il y a là un problème sous jacent qui semble remonter dans tes tests. Il est tout à fait possible d’écrire une librairie de tests fournissant un mock pré-configuré qui peut être réutilisé dans ces tests. Tout comme il est possible d’écrire une implémentation spécifique aux tests de ce service. Le choix entre les deux dépend du contexte, aucun n’est vraiment meilleur que l’autre.
    Pour mimer une base de données, une implémentation mémoire peut grandement aider. Le mock n’est pas adapté car la miriade de comportements à mocker rend le mock aussi verbeux qu’une implémentation fake.
    Pour des services plus simples, les mocks sont adaptés. Celà amène à s’interroger sur la complexité du code que l’on écrit. Si le service est compliqué à mocker, c’est qu’il y a vraisemblablement un problème. Il viole peut être le principe n°1 du SOLID : Single Responsibility. Ou bien, il devrait être splitté en sous-services plus simples (Interface Segregation).

    Mocker ou ne pas mocker, c’est une confrontation entre « tests unitaires » et « tests d’intégration ». A mon précédent job, les tests d’intégration étaient répandus. Le système était quasi impossible à mocker (Code Legacy). Ces tests d’intégrations sont devenus le messie. Quelques mois plus, le retour de bâton ne s’est pas fait attendre. Ces tests échouaient massivement suite à des refactors ou changements de comportements de services. Pourquoi ? Parce qu’ils dépendaient de trop d’implémentations à la fois. Il n’y avait aucune isolation entre le service testé et ses dépendances. La source des échecs de tests étaient difficile à trouver car de nombreuses classes dépendantes étaient en présence. Si bien, qu’on a fini par désactiver ou supprimer (sic!) ces tests tant ils faisaient plus de mal que de bien.

    Ces échecs à chaque battement d’aile de papillon n’étaient pas le fruit du hasard. Ils étaient la résultante d’un système incapable de fonctionner sans lancer tout un arsenal de services. Il ne faut pas en retenir que les tests d’intégration sont mauvais. Au contraire, ils ont des bénéfices qui complètent parfaitement les tests unitaires : ils testent la coopération entre les services.

    Les tests unitaires massivement mockés ont l’immense avantage de garantir l’isolation de la classe testée. Si le test échoue, c’est la classe testée qui est incriminée. Le temps de recherche du bug est largement réduit par rapport à du test d’intégration. Le temps d’exécution est aussi largement plus réduit car le contexte est entièrement mocké. Mais les mocks sont souvent inefficaces lorsqu’on teste un service qui fait appel à une librarie tierce.

    Les mocks sont-ils dangereux ?

    Les mocks ne sont pas couplés à l’implémentation du service qu’ils miment mais à son contrat (qu’on raccourcit généralement à une interface en Java). Le contrat d’un service définit les préconditions, invariants et post-conditions de celui-ci.

    Un changement qui modifie ce contrat n’est pas anodin. Et que ce changement entraine un échec de tests unitaires qui reposaient sur l’ancien contrat ne me choque pas, bien au contraire. C’est déjà le signe que les tests sont suffisamment robustes pour échouer dès qu’une modification de contrat a lieu. Ensuite, celà oblige à passer en revue les services dépendants pour adapter leurs internes à ce nouveau contrat.

    Le fait d’utiliser un objet fake ne résout pas plus ce problème. En supposant un service de base de données qui renvoie une liste NULL au lieu d’une liste vide dans le nouveau contrat, le service qui utilise le fake échouera en nullpointer car il n’avait pas prévu ce cas.

    Le vrai danger de l’objet fake c’est qu’on ne prend plus le temps de bien avoir compris le contrat du service qu’on utilise. On se retrouve vite à se dire « que celà va marcher », alors que le mock oblige à réfléchir à comment le service est censé réagir. Pour aller plus loin, tout contrat d’un service (ou interface) devrait être fourni avec un ensemble de tests et un objet factice (ou mock, selon le degré de complexité du service). Celui qui implémente le contrat peut vérifier s’il ne respecte bien. Celui qui dépend du contrat peut vérifier que sa classe respecte l’utilisation du contrat.

    Le cas des objets fake

    La présente nécessite de ces objets traduit deux possibilités. Soit le service que l’objet fake implémente est trop complexe, soit il est tout à fait inadapté de fournir un mock. Par exemple, un service qui effectue des requêtes sur Elasticsearch est un bon candidat pour utiliser du fake, et donc du test d’intégration. Mocker le contexte pour vérifier que le service crée la bonne « query » n’a aucune valeur ajoutée. Par contre, vérifier qu’avec un jeu de données de tests, le service obtient le résultat attendu avec une base « mémoire » en a un.

    L’autre inconvénient des fake est qu’ils nécessitent eux-même d’être testés et celà ne peut se passer autrement que par des mocks, sous peine d’être dans une spirale sans fin.

    Je crois que la meilleure des stratégie c’est la diversification. Ni le tout mock, ni le tout fake. Il n’y a pas de miracle, mock ou fake, il faut les écrire et les maintenir. Chaque type de test a ses avantages et ses défauts, aucun ne peut prétendre à être la Silver Bullet. Ce qui compte avant tout, c’est l’humain. Certains seront à l’aise avec des tests isolés, d’autres avec des tests d’intégration. Le plus important à mon sens c’est d’être libre de ses choix pour être pleinement efficace.

    1. Salut Jérôme et merci pour cette réponse qui est quasiment aussi longue que l’article.

      Je vois que nous avons une vision un peu divergente sur ce sujet. Je suis d’accord avec toi qu’il est important d’utiliser l’outil ou la technique adapté au problème auquel on doit répondre.
      Je vois cependant qu’on est bien d’accord sur le fait que le code doit être bien désigné. Un code qui respecte SOLID est un code assez facilement testable qui ne devrait pas nécessiter en principe de se poser des questions compliquées quant à la façon dont nous allons le tester.

      Je suis peut-être un peu provocateur dans mes propos, mais j’ai appris au fur et à mesure à tester sans mock et j’ai l’impression d’écrire de meilleurs tests.
      J’ai à un moment tout mocké, même les structures de données. Au début je le faisais systématiquement puis je me suis dit que c’était stupide. Je ne le faisais plus que quand j’avais des structures de données difficiles à instancier parce que ce sont des wrappers d’autres objets par exemple. Depuis, je fais en sorte que les structures de données ne dépendent de rien d’autre que de types simples. Petit à petit j’ai fait le même travail sur les objets qui fournissent des services.

      Je suis en ce moment même en train du code en utilisant quelques mocks parce que ça serait vraiment compliqué de faire autrement. Mais dans ce cas précis, l’origine du problème c’est une violation de SOLID, en l’occurrence de Interface Segregation Principle.

      Pour ce qui est du débat entre les tests unitaires et les tests d’intégration, je parle là de tests unitaires. Je ne suis pas un gros adepte des tests d’intégration, je considère que si le code est bien testé l’assemblage à de grandes chances de fonctionner. Et quand je dis que j’utilise le code de prod quand c’est possible, il est bien évident que je le fais si je n’ai pas besoin d’instancier la moitié du code métier. Dans ces cas-là, j’essaie de mettre un fake et si c’est compliqué, un dummy ou un mock en dernier recours.

      Merci pour ta réponse, elle constitue un témoignage intéressant et c’est toujours bien d’avoir l’avis des autres !

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *