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…
Ce risque existe uniquement lorsque la vérification d’état n’est pas déjà prévue par les services métier.

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.
Le test de tous les comportements de chaque collaborateur est à réserver aux points vraiment sensibles et critiques des applications. Il faut choisir le bon curseur coût/sécurité

Risque « plausible »

Faire ponctuellement des tests sur le comportement avec un stub est très difficile et demande une maintenance supplémentaire.
Ex : Il est parfois nécessaire de développer un stub spécifique à un test et ayant un comportement à définir et développer. Premier appel différent du second, etc…
Il est important pour le développeur de savoir détecter le moment où passer à une autre approche … S’il la connaît …

Visibilité sur les tests effectués et leur localisation
Risque « faible »

L’a
pproche par Mock incite à développer une classe de test pour une classe à tester. Si le nombre des classes de test diffère du nombre de classes à tester, on sait vite ou sont les blancs à combler…

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.
Ainsi, les taux de couverture en lignes et branches de code donnent une indication qu’il devient fastidieux d’améliorer car on ne sait pas dans quelles classes de test sont situées la combinatoire des tests unitaires. Les tests s’éparpillent alors …
Il faut noter ici que les tests d’intégration (au sens SUT+ objets réels de l’application) peuvent être redondants et donc inutiles. Hors, ils amènent un coût d’exécution et de maintenance non négligeables…

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…
En fait, je développe le SUT et le test en simultané car il me faut avoir déjà conçu quelques interfaces et interactions pour pouvoir écrire mes « attentes de scénario comportemental »

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 :

  1. Le fait de pouvoir faire des test d’intégration plus facilement implique parfois des tests se recouvrant et des erreurs venant d’un collaborateur non concerné au premier titre par le test ou d’un collaborateur non remis à zéro entre deux tests… Même s’il est souvent possible de trouver le coupable rapidement, une tâche imprévue viens de vous être assignée ;-). Ainsi, un de vos tests a peut-être mis en évidence un problème sur un composant qui n’est pas à votre charge ou éloigné en terme de couche applicative. Mais comme c’est vous qui avez besoin que votre test soit passant avant de livrer (pas de -Dmaven.test.skip=true, avant une livraison ! Sinon Grrr … je mords) c’est probablement vous qui allez corriger, avec parfois un temps important consommé à autre chose que votre tâche principale.
    Avez-vous remarqué aussi que c’était souvent le vendredi soir à 18h30 et avant une livraison importante ?
  2. Le fait de développer des bouchons étant un peu rébarbatif, on est vite tenté de les mutualiser … Mais vous est-il déjà arrivé de modifier un bouchon partagé et de vous rendre compte qu’il va falloir modifier 20 tests et créer trois versions du bouchon ou pire y créer des branches d’exécution ?
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é.
Grande parallelisation des développements hors interfaces à définir en amont.

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é.
Parallelisation un peu plus délicate tout en restant acceptable dans la plupart des cas.

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.
Si l’on est très strict sur le détail des vérifications, la complexité du code de test peut croître de manière presque exponentielle.
Pour des scenarii plus simples, la complexité du code du test est équivalente à celle du SUT.
D’où l’importance de ne pas faire de redondance de tests à l’aide de matchers trop stricts et utilisés pour toutes les combinatoires.

Risque « plausible »

Implique forcément l’écriture de plusieurs classes. Leur code peut être simple ou peut être compliqué.
La volonté de mutualiser conduit néanmoins souvent le développeur fainéant (comme moi par exemple) à vouloir mutualiser les stubs … Sauf qu’à l’arrivée, c’est souvent plus coûteux qu’on ne l’a d’abord pensé !
Pensez-y !

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 …
Dépend de la rigueur des développeurs et de la cohérence de leur processus de travail en équipe

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 …
Mais est-ce réellement un risque me direz-vous ?
Oui, sur de l’existant, l’approche par mock est parfois difficile à mettre en place sans effectuer le refactoring adéquat …

Risque « faible »

Les interfaces sont optionnelles et les méthodes ou classes statiques sont autorisées…
Elles empêchent néanmoins tout passage d’une méthode de test à l’autre …

*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.

Selon moi, un « Stub » permet le test d’un contrat de service, un « Mock » permet le test de chaque implémentation de service, contrat inclus.

%d blogueurs aiment cette page :