Pourquoi veut-on rendre un test unitaire indépendant ?

L’indépendance n’existe pas uniquement dans certaines régions de France ou dans un film avec Will Smith. Il se trouve aussi dans les tests unitaires. Lorsque vous écrivez des tests, il y a un moment qui arrive où vous souhaitez être indépendant de quelque chose. Vous allez naturellement casser cette dépendance et injecter autre chose pour remplacer ce qui gêne.

Ce n’est cependant pas une décision à prendre à la légère. Certaines raisons sont parfaitement justifiées, alors que d’autres peuvent nuire au projet. Pourquoi a-t-on besoin de casser une dépendance dans certains tests ? Et de quoi veut-on être indépendant ? À quoi faut-il faire attention ? C’est de tout ça dont j’ai envie de parler aujourd’hui.

Qu’est-ce qui nous pose problème ?

Commençons par le commencement. On veut savoir pourquoi rendre un test indépendant, regardons du côté des problèmes auxquels cette envie d’indépendantisme répond.

Test déterministe

Avoir des tests qui échouent sans raison apparente à première vue, c’est embêtant. Encore plus lorsque ça semble être aléatoire. Et parfois, on a un passage au rouge du jour au lendemain, ce qui nous vaut la célèbre phrase “ça marchait avant”. Un test devrait avoir une intention claire et n’avoir qu’une seule raison d’échouer. On n’a pas envie de se lancer dans une session de debug pour comprendre ce qu’il se passe.

Alors qu’est-ce qui peut bien causer ces problèmes ? Un autre test. Ce bienfaiteur qu’est le test peut en effet être une source d’ennuis pour d’autres tests. Lorsqu’on lance un ensemble de tests, l’environnement et la mémoire sont partagés entre eux. Si deux tests lisent et écrivent dans un même endroit, ils vont éventuellement affecter le résultat de l’autre. Vous voyez où je veux en venir ? Base de données, fichiers, singletons, variables globales, … Tout ça représente une zone partagée et peut créer un lien de dépendance entre deux tests.

On arrive donc à notre première et principale raison. Pourquoi rendre un test indépendant ? Pour éviter un effet de bord venant d’un autre test. C’est dans ce but que l’on va casser une dépendance envers une base de données, un système de fichiers ou un singleton. Et que l’on mettra une fausse implémentation, avec un base de données en mémoire par exemple, pour avoir un état déterministe.

C’est d’ailleurs une autre raison, être déterministe. On veut rendre un test indépendant pour obtenir toujours le même résultat. Du côté des causes, on retrouvera celles vues précédemment et on peut notamment y ajouter d’autres éléments perturbateurs comme les requêtes HTTP, la date du jour, les fonctions aléatoires (quoi de mieux qu’un bon random() pour avoir un résultat imprévisible ? :p). Ce sont des sources qu’on ne contrôle pas. Mais pour être déterministe, il faut les contrôler : on cassera alors les dépendances afin de les remplacer. Ou peut-être que l’on s’orientera vers la création de fonctions pures.

Test rapide

Ajoutons un autre problème, sinon ce n’est pas marrant. Une suite de tests lente à exécuter est assez fâcheux. Les tests unitaires nous guident dans la conception et parfois nous rattrapent lors de sorties de route. On a besoin d’un feedback rapide, et encore plus lorsque l’on pratique le TDD.

On aura une nouvelle fois les mêmes fautifs. Feraient-ils preuve d’intelligence ? On se le demande, mais en tout cas ça nous arrange puisqu’il n’y a pas de centaines de causes différentes. Principalement, tout ce qui accède au réseau, aux internets, ralentira fortement vos tests. On peut rester dans la centaine de millisecondes comme dépasser aisément la barre des secondes. C’est sûrement la source la plus lente. Viennent ensuite les bases de données et le système de fichiers. Unitairement, le temps est peut-être faible, mais quand on dézoom et qu’on regarde le cumul sur l’ensemble des tests, ça devient (trop) long.

On en vient à notre dernière raison. On veut rendre un test indépendant pour qu’il soit rapidement exécuté. Là encore, on cassera éventuellement des dépendances qu’on substituera par quelque chose répondant immédiatement.

Couper les ponts

Si on résume en une phrase, on souhaite rendre un test indépendant afin d’éviter qu’il soit affecté par les autres tests, pour être déterministe, et pour être rapide. Voilà. Ce sont les raisons principales. Pas besoin d’aller chercher plus loin et d’isoler complètement un test.

Un autre moyen d’entrevoir ces raisons est de regarder du côté des principes FIRST. Ce sont des principes pour guider l’écriture des tests, et je trouve qu’ils reflètent bien les besoins d’indépendance. On parle de rapidité d’exécution (F pour Fast), de test indépendant des autres tests (I pour Independent) et de résultat déterministe (R pour Repeatable).

Quelques mauvaises compréhensions

Maintenant que nous avons bien en tête les raisons qui motivent les tests à devenir indépendant, revenons sur quelques mauvaises compréhensions. C’est essentiel d’en parler, car il peut y avoir un impact fort : tests fragiles, maintenance lourde, démotivation des développeurs.

System Under Test

Quand on parle de SUT, on parle de SYSTEM Under Test. Ce n’est ni Class Under Test ni Fonction Under Test. C’est très important. Le système, ce peut être une fonction, ou une classe, ou un ensemble de classes et de fonctions. Peu importe, on va tester l’ensemble et tout ce qu’il y a derrière. On peut voir ça comme un module, ou une boîte noire - on teste toute la boîte depuis notre point d’entrée.

On ne cherche pas à isoler le système que l’on teste des autres classes et fonctions qu’il utilise. On rend le test indépendant, oui, mais pas la classe. On s’en tiendra aux raisons évoquées plus haut pour casser des dépendances. C’est parfaitement ok qu’une méthode que l’on teste appelle d’autres classes, qui appelleront aussi d’autres classes.

Heavy mocking

En comprenant bien ce qu’est le SUT et ce qu’il faut rendre indépendant, on s’évite au passage la sur-utilisation de mocks. On ne va pas essayer de tout isoler et donc de tout mocker.

Le problème avec les mocks, c’est qu’ils connaissent trop les détails de l’implémentation. Si on change notre implémentation (on a une meilleure idée, on fait du refactoring), on va casser les tests. Les assertions vont être fausses et c’est normal, le mock vérifie la mécanique interne.

La sur-utilisation des mocks va entraîner des problèmes en cascade : on couple les tests à l’implémentation, on a du mal à faire du refactoring, le code devient difficile à changer, le développeur se démotive, il y a moins de tests, moins de qualité, fin. Vraiment, on ne veut pas coupler les tests à l’implémentation :)

Comportement

Ça me fait une bonne ouverture pour terminer. Ce que l’on veut tester, c’est le comportement. Quand on fait des applications, c’est pour répondre à un besoin métier. C’est ça que l’on veut tester. On se limitera à enlever les dépendances gênantes pour les tests unitaires. Testez le comportement plutôt que les détails de l’implémentation.

Crédits photos : Anne Nygård, Richard Lee.