Dernièrement j’ai voulu faire un peu de refactoring de code sur une simple classe Java. L’objectif était de remanier le code de la classe sans modifier son comportement du point de vue de l’extérieur. Bien mal m’en a pris puisque j’ai dû batailler ferme pour pouvoir faire peau neuve de mon code source (et en être fier !!). Et pourtant, armé d’une imposante batterie de tests unitaires, j’étais (relativement ?) confiant. Pourquoi fut-ce alors si difficile de faire ce refactoring ? Tellement difficile que j’en suis venu au point de tout abandonner (le refactoring bien évidemment) et de « maudire » les « gourous » qui prônent la mise en place des tests unitaires, et pour qui selon eux, cette pratique doit permettre un refactoring en douceur et sans risque (maudit sois tu Robert C. Martin-Uncle Bob et ta règle des boy-scouts !).
Après une analyse du pourquoi du comment, j’en suis venu à la conclusion que le cœur du problème était l’utilisation (abusive) des mocks dans mes tests unitaires.
En effet même si les mocks fonctionnent très bien et rendent de fiers services lors de l’écriture de test unitaire, ils deviennent véritablement dangereux et source de prises de tête lors d’un refactoring de code.
Avant de démontrer dans ce premier article pourquoi un mock est dangereux, revenons tout d’abord sur la définition d’un mock.
Définition d’un mock
Un mock est un objet principalement utilisé lors des tests unitaires. Un objet que l’on souhaite tester peut avoir des dépendances vers d’autres objets plus ou moins complexes. Afin de focaliser uniquement les tests sur le comportement de l’objet, on remplace chaque dépendance de l’objet par un mock. Les mocks simulent alors le comportement des objets réels. Il existe plusieurs raisons de mocker un objet réel :
- l’objet réel n’existe pas encore, seule son interface est définie.
- l’objet réel est complexe à initialiser lors d’un test.
- l’objet réel exécute des traitements très long, ce qui va à l’encontre du premier principe Fast (F des principes FIRST des tests unitaires).
- l’utilisation de l’objet réel a un coût financier. C’est le cas d’appel d’API payante par exemple.
- etc …
Prenons un exemple totalement inventé pour clarifier tout ça.
Quel temps fait-il ?
Nous souhaitons proposer un service qui nous indique en temps réel s’il pleut ou non dans une ville donnée. Ce service s’appuie sur un autre service nommé WeatherService, développé par une autre équipe, et qui encapsule l’appel à une API Web. J’ai complètement inventé cette API mais il est probable qu’elle existe réellement (en tout cas je n’ai pas cherché !). Cette API nous fournit des informations sur le temps qu’il fait à un endroit précis du globe. Le service WeatherService qui l’invoque nous propose au départ une seule méthode :
Cette méthode prend en paramètre le nom d’une ville, invoque le Web Service distant (Web API) et renvoie un objet qui contient l’information sur le temps qu’il fait dans la ville en question. Pour nous simplifier la vie cet objet Weather nous indique la quantité des précipitations qui tombe sur la ville pendant le dernier quart d’heure à travers cette méthode :
int Weather.precipitationAmount()
C’est exactement ce qu’il nous faut pour implémenter notre propre service RainingService, qui ressemble donc à ceci :
On injecte le service WeatherService à notre service RainingService via son constructeur, et la méthode isRaining invoque la méthode byCityName de WeatherService en lui fournissant le bon paramètre.
Viens ensuite l’écriture du test unitaire. Ce n’est pas très TDD tout ça, puisque l’écriture vient après l’implémentation (dernier principe T pour Timely des principes FIRST), mais j’y reviendrai plus tard.
Pour ne pas invoquer la Web API pour des raisons de coûts et de temps d’exécution principalement, nous décidons de mocker le service WeatherService.
Le test ressemble alors à ceci (j’utilise Mockito) :
Dans les deux tests on simule notre objet WeatherService en créant « son » mock. On lui indique de renvoyer l’objet Weather lorsque sa méthode byCityName est invoquée.
L’exécution du test fonctionne parfaitement comme en témoigne la petite barre verte qui apparaît sous Eclipse. On est content on a bien travaillé.
Viens le temps du refactoring (et pas le temps qu’il fait dehors bien sûr !)
Le service weatherService évolue et il propose maintenant une méthode supplémentaire permettant de récupérer un objet Weather à partir d’un code. En effet le nom de la ville n’est pas un critère suffisant puisque, par exemple, la ville de Paris existe en France mais également aux Etats-Unis.
L’interface du service weatherService avec la nouvelle méthode :
Je décide donc de remplacer dans notre classe RainingService la méthode byCityName par la méthode byCityCode :
La signature de la méthode isRainning n’est pas modifiée par le changement : le contrat du service est maintenu. Je lance les tests unitaires et comme je n’ai pas modifié le comportement de la classe RainingService, je m’attends à une belle barre verte. Et là je tombe des nues, j’ai deux « jolies » exceptions de type NullPointerException. Que s’est-il passé ?
Dans le test unitaire on a mocké la méthode byCityName et non la méthode byCityCode. Du coup le mock weatherService renvoi un objet null à l’appel de cette méthode, provoquant un NullPointerException lors de l’invocation de precipitationAmount.
Certain que mon code était OK (et fainéant également) j’ai commité (sur Git) mes modifications sur le dépôt central sans vérifier si mon test était toujours valide (c’est pas bien !!). Je me suis rendu compte du problème le lendemain lors de l’exécution du build lancé par Jenkins automatiquement dans la nuit.
Le lendemain donc, j’analyse le problème et je modifie donc mon test unitaire comme ceci :
Mockito.when( weatherService.byCityCode( city.getCode() ) ).thenReturn( weather );
J’ai remplacé uniquement la méthode byCityName par la méthode byCityCode dans les paramètres de la méthode when de Mockito.
Cette fois-ci je lance mes tests avant de commiter. Les tests unitaires sont OK : ouf ! On est sauvé.
La cause réelle du problème
Qu’ai-je fait pour résoudre mon problème ?
J’ai ouvert le code de ma classe RainingService et j’ai cherché quelle méthode était invoquée sur l’objet weatherService. Et c’est bien là le problème !
Dans un test unitaire, l’objet testé (ici notre service RainingService) doit être vu et utilisé comme une boîte noire. En effet, pour écrire un bon test unitaire, nous devons tester notre service sur le ou les résultats qu’ils renvoient et non pas sur comment il renvoie ses résultats (son implémentation). En connaissant l’implémentation, nous avons lié notre test à notre implémentation. Et c’est justement ce que nous avons fait en utilisant un mock. Autrement dit nous ne devons pas connaître les détails de notre implémentation sous peine de rendre délicat le refactoring du code. C’est d’ailleurs l’un des objectifs de la TDD où en écrivant d’abord le test, on s’oblige (on est forcé d’ailleurs) à ne pas connaître l’implémentation. En utilisant un mock j’enfreins donc cette règle. Voilà pourquoi utiliser un mock est mal.
Conclusion
Lors d’un refactoring de code, c’est à dire lorsqu’on modifie l’implémentation d’une classe sans modifier son comportement, on ne devrait pas avoir besoin de mettre à jour les tests unitaires. Le faire doit même être interdit, puisque les tests sont supposés être des garde-fous. En refactorant du code de production et en lançant des tests unitaires je suis certain de ne pas engendrer des régressions (si tous les tests sont couvrants bien évidemment). Modifier les tests unitaires lors d’un refactoring est d’une part très pénible et d’autre part, très dangereux. Qui nous assure en effet qu’en modifiant le test unitaire, cela n’est pas fait pour « ignorer » un problème que le test à détecté. C’est alors une véritable régression qui part en production ! L’utilisation de mock rend difficile voire impossible le refactoring de code sans modifier également les tests unitaires qui y sont rattachés, surtout lorsqu’une classe utilise une, voire plusieurs dépendances.
Dans un second article, je vous proposerai une solution alternative au mock. J’en profiterai également pour vous montrer d’autres problèmes générés par l’utilisation d’un mock.