Mocks versus Stubs
Je vais m’essayer à un exercice très difficile qui consiste à faire un rappel sur un grand article de Martin Fowler et à le compléter de ma propre expérience.
Oui, je vois ce sourire naître au coin de vos lèvres et ce regard rempli d’espièglerie à l’idée de ne pas savoir si vous êtes tombé sur un très bon article ou bien sur une piètre et banale paraphrase …
Je relève donc le défi et voici mon propos, cher lecteur dubitatif et astucieux !
Tout d’abord, j’ai pu constater à quel point, malgré la montée en puissance des approches TDD et agiles, les développeurs restent globalement réfractaires aux tests qui leur semblent fastidieux et improductifs. Le thème de ce billet n’étant pas de résoudre cet état de fait ou de convaincre sur la nécessité de tester, ceci a tout de même quelques conséquences :
- Les développeurs connaissent peu et capitalisent peu sur les tests
- Leur manque de pratique et de prise de recul leur fait écrire de mauvais tests, ou, en tous cas, des tests qui les confortent dans leur appréhension globale des « tests unitaires »
- Ce cercle vicieux étant établi, nous comprendrons que selon les développeurs, certains ne se seront jamais posé la question de la différence entre « mocks » (imitateurs) et « stubs » (bouchons) et des impacts que cela peut engendrer sur leur conception, leur implémentation, leur résistance au refactoring de code, la sécurité réellement apportée sur la non-régression etc …
Pour avoir une première approche du sujet et avant de continuer, je vous suggère donc de vous plonger ou replonger dans l’excellent article de Martin Fowler à l’adresse suivante : http://martinfowler.com/articles/mo… (janvier 2007)
Ensuite, pour introduire le problème, voici la définition d’un « mock » comparé à un « stub » selon le site http://www.easymock.org:
A Mock Object is a test-oriented replacement for a collaborator. It is configured to simulate the object that it replaces in a simple way. In contrast to a stub, a Mock Object also verifies whether it is used as expected. Tim Mackinnon, Steve Freeman and Philip Craig pioneered the concept of Mock Objects, and coined the term. They presented it at the XP2000 conference in their paper Endo Testing: Unit Testing with Mock Objects. A reworked version of the paper is published in the book Extreme Programming Examined (Addison-Wesley).
Dictionnaire
Eléments de langage pour la suite de cet article:
System under test ou SUT | L’objet ou l’ensemble d’objets que l’on souhaite tester. |
Collaborator | Un objet qui collabore avec le SUT pour réaliser le traitement testé. Le SUT utilise des collaborators pour exécuter son traitement. Il s’agit d’une doublure au sens large du terme. Voir définition des doublures de test (test doubles) ici : http://xunitpatterns.com/Test Double.html |
Stub | Un « stub » ou « bouchon » est un objet utilisé par le SUT et se comportant d’une manière prédéfinie par le testeur. Il peut dans certains cas s’agir d’un objet réel du métier, souvent configuré par le setup du test, ou bien d’un objet de remplacement et dont l’implémentation de test est à écrire pour le test. Une forme spécifique de doublure. |
Mock | Un « mock » ou « imitateur » est un objet utilisé par le SUT et se comportant d’une manière prédéfinie de manière déclarative par le testeur. Il ne peut jamais s’agir d’un objet réel du métier. Le scénario de son imitation est configuré, réalisé, vérifié et remis à zéro pour chaque méthode de test. Toute discordance entre le scénario prévu et l’imitation exécutée donne lieu à une erreur. Une forme spécifique de doublure. |
Matcher | Un élément du framework de mocking qui vérifie en plus du bon appel d’une méthode d’un collaborateur, que ses paramètres sont en phase avec une attente particulière. Il peut vérifier le type du paramètre et des conditions supplémentaires sur ce paramètre attendu comme son égalité à une valeur, sa supériorité à une valeur … Il existe des matchers flexibles et d’autres moins : EasyMock.anyObject() et EasyMock.eq(« StringValue ») par exemple. |
Machine d’état | Représente l’ensemble des états possibles d’un objet ou groupe d’objets(système) en définissant tous les chemins possibles pour arriver d’un état initial à un état final. Cette machine d’état définit les transitions possibles depuis et vers chaque état en indiquant leur mode de déclenchement (auto, manuel …) ou leur condition de déclenchement et de bonne exécution (valeur limite, erreur détectée…). |
Refactoring | Terme délicieusement anglais, il reste difficilement traduisible en français. Je dirai que l’existence de ce mot est la preuve que l’on peut toujours et encore s’améliorer 😉 |
Test d’intégration | Ce terme peut recouvrir plusieurs réalités. Certains parlent de tests d’intégration lorsque l’application est testée depuis l’extérieur par des outils tels que Jmeter, Selenium, etc … D’autres parlent également de tests d’intégration lorsque le SUT est testé en relation avec d’autres objets réels de l’application ou même parfois de simples stubs. En effet, dans ces cas là, le test n’est plus unitaire car il porte sur un SUT complexe (Objets à tester + autres objets réels ou bien Objet à tester + stubs) |
Test de comportement ou test d’état ?
D’après M Fowler, choisir entre l’écriture d’un mock ou d’un stub consiste donc à tester le comportement d’un système donné(SUT) durant une activité donnée, ou bien, tester son état résultant suite à une activité donnée.
Système à tester :
public class CalendarServiceImpl implements CalendarService { ... private void addEvent(CalendarEvent event, User user) { eventsDao.save(event, user); } public CalendarEvent createEvent(User user, String description, Date date, Time time, Location location) { CalendarEvent event = calendarEventFactory.createEvent(description, date, time, location); this.addEvent(event, user); return event; } ... }
Test de comportement (behavioral test):
@Test public void shouldCreateEventCorrectly() { //Given CalendarEventFactory eventFactoryMock = EasyMock.createMock(CalendarEventFactory.class); CalendarEventDao eventDaoMock = EasyMock.createMock(CalendarEventDao.class); User userMock = EasyMock.createMock(User.class); CalendarEvent eventMock = EasyMock.createMock(CalendarEvent.class); Time timeMock = EasyMock.createMock(Time.class); Location locationMock = EasyMock.createMock(Location.class); String description = "description"; Date date = new Date(); //Ici, description des comportements attendus EasyMock.expect(eventFactoryMock.createEvent(description, date, timeMock, locationMock)).andReturn(eventMock); eventDaoMock.save(EasyMock.eq(eventMock), EasyMock.eq(userMock)); //Passage d'EasyMock en mode replay. EasyMock.replay(eventFactoryMock, eventDaoMock, userMock, eventMock, timeMock, locationMock); CalendarService sut = new CalendarServiceImpl(eventDaoMock, eventFactoryMock); //When CalendarEvent result = sut.createEvent(userMock, description, date,timeMock, locationMock); //Then //Vérification du résultat. Assert.assertEquals("L'événement renvoyé correspond à celui de la factory utilisée",result,eventMock); //Vérification des comportements EasyMock.verify(eventFactoryMock, eventDaoMock, eventMock, timeMock, locationMock); }
Test de l’état résultant (state test):
@Test public void shouldCreateEventCorrectly() { //Given CalendarEventDao eventDao = new CalendarEventDaoStub(); CalendarEventFactory eventFactory = new CalendarEventFactoryImpl(); CalendarService sut = new CalendarServiceImpl(eventDao, eventFactory); Date givenDate = new Date(); Time timeStub = new TimeStub() ; User userStub = new UserStub(); Location locationStub = new LocationStub(); //When CalendarEvent result = sut.createEvent(userStub, "description", givenDate ,timeStub, locationStub); //Then //Vérification de l'état résultant. Assert.assertNotNull("L'événement est correctement créé",result); Assert.assertEquals("Le User correspond à celui fourni.", userStub, result.getUser()); Assert.assertEquals("La date correspond à celle fournie.", givenDate, result.getDate()); Assert.assertEquals("L'emplacement correspond à celui fourni.", locationStub, result.getLocation()); Assert.assertEquals("L'heure correspond à celle fournie.", timeStub, result.getTime()); }
NB : On notera que ce type de test conduit inévitablement à tester les stubs ou à tester des implémentations réelles (ici “CalendarEventFactoryImpl”). Ceci conduit donc souvent à faire des tests d’intégration, terme expliqué à la section “Dictionnaire” de cet article.
Martin Fowler décrit très judicieusement les différents avantages et inconvénients de chaque méthode que je vais résumer, reformuler et compléter ici*:
Thèmes |
Approche par « Mocks »Test de comportement |
Approche par « Stubs »Test d’état |
Régressions invisibles |
Risque « faible »Peut exister si aucun matcher strict n’est jamais utilisé. |
Risque « plausible »Les stubs étant moins précis dans les tests d’implémentation, des régressions invisibles sont potentiellement plus nombreuses sans une écriture de nombreux stubs parfois dédiés à un seul test. Ex: Un log de sécurité en base de données n’est plus fait… L’état le dit-il ? Est-il facile d’écrire tous les stubs concernés ? Surtout si l’activité testée appelle plusieurs fois le même service avec des inputs différents ? Quid si l’état du collaborateur est modifié de manière complexe lui aussi ? |
Tests faussement passants |
Risque « inexistant »Dans le cas de l’existence de plusieurs chemins dans la machine d’état pour arriver à l’état final, toute transition inattendue sera détectée. |
Risque « plausible »Peut mener à des tests faussement passants si le(s) collaborateur(s) et/ou le SUT ont au moins un état de leur machine d’état possédant deux transitions entrantes. Ex : Un état pouvant résulter de plusieurs chemins où un chemin est attendu et l’autre … pas forcément. |
Ingérence dans le code |
Risque « inexistant »Les mocks étant basés sur des implémentations d’interfaces embarquant leur propre implémentation de vérification du comportement, aucun impact n’est nécessaire dans l’interface de départ. |
Risque «faible»Implémentation parfois modifiée pour permettre l’interrogation de l’état depuis les tests. => code supplémentaire inutile et qui peut être détourné de son utilisation si visibilité laxiste… |
Mauvais choix d’approche(complexité des tests trop grande, redondance des setups, code de test volumineux à maintenir, …) |
Risque « plausible»L’approche comportementale des tests peut inciter à tester absolument tous les comportements possibles depuis un SUT mais la complexité des « attentes de chaque scénario » peut alors croître exponentiellement et le refactoring de chaque implémentation du SUT devenir fastidieux. |
Risque « plausible »Faire ponctuellement des tests sur le comportement avec un stub est très difficile et demande une maintenance supplémentaire. |
Visibilité sur les tests effectués et leur localisation |
Risque « faible »L’a |
Risque « plausible »Risque constant de faire des tests d’intégration avec la problématique de ne pas voir d’un coup d’oeil ce qui est testé ou non. |
Dans une approche TDD, difficulté d’écriture du test avant conception/réalisation du code à tester |
Risque « fort »Comme le dit M Fowler, l’approche même de conception/réalisation de code est impactée par l’approche mock vs stubs. Ainsi, étant plutôt un mockist, je m’étais déjà interrogé sur les raisons pour lesquelles contrairement à d’autres développeurs TDD, je n’arrivait pas à écrire l’ensemble de mes tests avant de concevoir/coder… |
Risque « faible »Il peut arriver d’avoir à retoucher un test à base de stubs après implémentation mais ces cas restent factuels et peuvent être évités avec un peu de pratique… |
Problème d’isolation des tests(code de test partagé, remise à zéro des contextes de test entre appels de tests unitaires …) |
Risque «faible»Sauf erreur de code dans les tests et d’utilisation des frameworks de mocking, les mocks permettent un setup, une déclaration des attentes du scénario comportemental, une exécution, une vérification et un reset de tout ceci de manière aisée entre chaque test. |
Risque « plausible »Deux phénomènes en jeu :
|
Problèmes d’isolation des développements lorsque les couches basses ou utilisées ne sont pas encore implémentées. |
Risque « faible »Nécessite uniquement de décrire les interfaces des collaborateurs mais aucun code d’implémentation hors du composant testé. |
Risque « plausible »Même en bouchonnant les collaborateurs, il faut écrire du code pour pouvoir développer son propre composant. Ainsi, au mieux, le stub sera utilisé pour les tests, au pire, il sera jeté ou modifé. |
Problème de complexité de code du test |
Risque « à croissance linéaire à exponentielle »Selon la criticité et le niveau de détail des comportements testés, il peut devenir très compliqué d’écrire les scénarii d’une approche par mock et leur combinatoire. |
Risque « plausible »Implique forcément l’écriture de plusieurs classes. Leur code peut être simple ou peut être compliqué. |
Confusion entre setup et test |
Risque « faible »Les frameworks comme EasyMock empêchent d’exécuter de nouveaux expects sur le scenario comportemental en phase d’exécution ou après vérification. Dépends tout de même du framework de mocking utilisé, présent ou à venir. |
Risque « plausible »Rien ne présage des méthodes de chacun pour faire le setup des stubs utilisés et de leurs dépendances, rien non plus n’interdit d’appeler du code d’initialisation d’un bouchon juste après exécution du code testé et avant vérification … |
Restrictions sur la conception |
Risque « plausible »Le mocking nécessite des interfaces et n’aime pas les classes statiques « fourre-tout ». Exit les DateUtils et autres UIUtils. On préferrera des objets avec un rôle bien défini : TimestampingService ou ComparatorFactory, RendererFactory … |
Risque « faible »Les interfaces sont optionnelles et les méthodes ou classes statiques sont autorisées… |
*Le lecteur peut voir que c’est ici qu’il va falloir que je mouille ma chemise pour éviter la paraphrase;-)
NB : Ajouter des colonnes de pondération sur ce tableau peut-être une méthode facilitant votre décision lors de la définition de votre stratégie de tests concernant l’approche comportementale ou l’approche par état résultant. D’autres sujets sur les tests unitaires ne sont pas
traités ici mais il s’agit déjà d’une partie du travail que vous devrez accomplir pour ne pas vous tromper.
Conclusion
En conclusion, je pense qu’aucune des différentes approches citées ici n’est meilleure qu’une autre et je dirai que la méthodologie technique du test n’a que peu d’importance en fait parce que le test naît d’un besoin métier pour un logiciel fiable et un service fiable, d’utilisateurs avec leurs exigences et d’un contexte projet. Cette méthodologie de test est définie à partir de la stratégie du projet en création, en refonte complète ou en maintenance/évolution, du budget, de la criticité d’une application, du rythme d’évolution des versions des livrables, du nombre et du type des utilisateurs à qui il faut offrir le service et donc du risque d’interruption de service ou de régression … Elle peut aussi être choisie par l’équipe de réalisation à la condition que tous soient d’accord sur un même processus de travail et une même approche globale des tests unitaires. Le plus important est qu’elle soit choisie en connaissance de cause et avant le commencement du projet car, comme nous l’avons vu, il y a impact sur la manière de concevoir le logiciel lui-même.
L’approche par mock, pour moi, reste tout de même la plus complète car elle englobe toutes les possibilités de l’approche par stub même si cela implique des vérifications plus strictes de l’implémentation réelle et donc une plus grande rigueur et un plus grand coût de conception/refactoring. Ainsi lorsque l’utilisation des matchers stricts, dans une approche par Mock, est mal dosée, une plus grande attention est nécessaire au niveau des développeurs lors des modifications ou refactorings. Selon moi, dans la plupart des cas, le mode de tests à retenir peut être un mixte de stubs ou de mocks au choix des équipes mais il doit fortement tendre vers le mocking dès que la fiabilité et la criticité d’une implémentation et de sa non régression entrent en jeu.