Tests automatisés : pensez système testé pour des tests robustes et dignes de confiance

03/12/2024 — 11 minutes de lecture (2355 mots)

Une bonne pratique quand on écrit des tests automatisés, c'est de « Ne pas coupler ses tests à l'implémentation ». Ça peut paraître simple pour certaines personnes, difficile pour d'autres, mais le fait est que je vois fréquemment des tests qui présentent ce problème. Dans cet article, je vous propose une méthode pour réfléchir explicitement au système testé et faciliter l'écriture de tests robustes qui apportent plus de confiance et nécessitent moins de maintenance.

Contents

Le problème

De quoi parle-t-on exactement quand on mentionne un test « couplé à l’implémentation » ? Le couplage en développement, c’est le degré d’interdépendance entre deux parties du code. Il y a toujours un peu de couplage (des appels de fonction, des attentes communes sur le format d’un message, etc.), mais s’il y en a trop le code commence à avoir des caractéristiques néfastes, et en particulier : plus deux parties du code sont fortement couplées, plus les changements sur l’une nécessitent de faire aussi des changements sur l’autre.

Dans le cas des tests, le couplage va avoir tendance à nous faire changer le code de test à chaque fois qu’on touche au système testé. Cela pose deux problèmes :

S’il est admis qu’on doit diminuer le couplage entre tests et code de production, il est plus difficile de savoir comment le faire. En discutant avec les développeurs dans les équipes où j’ai pu intervenir, j’ai remarqué que le système concerné par un test n’est pas toujours très bien identifié. Pour éviter un trop fort couplage entre tests et implémentation, il est important d’expliciter le système testé et son interface. On peut ensuite appliquer des règles simples pour améliorer la robustesse de nos tests et s’assurer qu’ils apportent le plus de valeur possible.

Identifier le système testé

Utilisateurs et dépendances

Représenter un programme

Un programme, c’est du code organisé en différents composants (classes, modules, fonctions, composants react, services, bases de données, etc.). On peut représenter ces composants sous forme de graphe, les nœuds étant les composants eux-mêmes et les liens (orientés) étant les dépendances entre ces composants.

Un programme composé de trois fonctions. La première dépend des deux autres.
Un programme composé de trois fonctions. La première dépend des deux autres.

On peut choisir le niveau de zoom qui nous arrange pour cette représentation, et on peut également choisir de représenter tout ou partie du produit selon ce qu’on veut faire.

Deux programmes représentés sous forme de graphes. Le programme de gauche est composé de microservices, deux applications frontend, une base de données et un bus d'évènements. Le système de droite est composé de plusieurs classes et d'un cache mémoire
La représentation fonctionne bien peu importe le niveau de zoom auquel on veut se représenter le système

Il ne s’agit pas ici d’une représentation destinée à documenter le système. Ça n’est pas un schéma d’architecture, mais une manière de raisonner sur le système. Ces représentations peuvent être griffonnées sur une serviette en papier ou rester dans votre tête, elles vont dans les deux cas vous être utiles pour la suite de la méthode.

Isoler le système

Tous les tests automatisés vont tester une partie du logiciel. Cette partie, que j’appellerai par la suite le système testé (en anglais SUT pour System under test) doit être bien définie pour éviter le couplage test/implémentation.

On peut représenter le système testé sur le graphe précédent par une frontière qui englobe certains des composants du logiciel. On peut alors facilement identifier :

Un programme représenté sous forme de graphe. Le système testé est défini par un cadre.
Ici le programme pourrait être une API HTTP Java. Le système testé comporte 3 classes (probablement utilisées par un même cas d’usage) mais exclut le contrôleur et le code d’accès à la BDD

Pour découpler les tests de l’implémentation, on remplace les utilisateurs par le code de test, et les dépendances par des Test doubles (stubs, mocks, etc.). Le code de test doit appeler le système exactement comme les utilisateurs réels, donc toute intéraction entre le test et le système testé doit correpsondre à l’une des flèches de l’API du système testé. De la même manière, tous les doubles de test doivent correspondre à l’une des dépendances directes du système testé.

En respectant ces règles, tout changement à l’intérieur du système testé, mais qui ne change pas son interface (la définition d’un refactoring) ne nécessitera aucun changement dans les tests, et le risque de régression sera minime.

Deux représentations d'un même système testé avant et après un refactoring
Le même système testé avant et après un refactoring : la classe 3 avait deux responsabilités distinctes et a été séparée en deux sans aucun impact sur les tests.

Comportement

Identifier le système testé sous l’angle de ses dépendances va nous aider à éviter toute intéraction entre nos tests et l’intérieur du système. Cependant, ça ne va pas nous aider à écrire des scénarios de test robustes face aux futurs changements du système testé. Changer un comportement du système devrait avoir un impact sur un nombre restreint de tests, ceux qui concernent ce comportement justement. Ajouter un comportement ne devrait avoir aucun impact sur les tests et supprimer un comportement devrait faire disparaitre des tests. Sinon, nous avons une autre forme de couplage, cette fois-ci entre plusieurs tests qui pourraient être plus indépendants.

Pour éviter ce problème, on peut utiliser des diagrammes de séquence. En centrant un diagramme de séquence sur le système testé, ses utilisateurs et ses dépendances directes, nous pouvons faire apparaitre différents cas d’usages du système et en faire des scénarios de test.

De la même manière que précédemment, on remplacera les utilisateurs par le code du test et les dépendances directes par des doubles de test. Un cas de test devrait refléter fidèlement l’un des diagrammes de séquence du système testé.

Un diagramme de séquence correspondant au système testé du précédent diagramme
Le système testé du diagramme précédent pourrait donner ce diagramme de séquence. On retrouve les mêmes flèches qui rentrent et sortent du système.

Bien entendu, un seul système testé aura beaucoup de scénarios différents, donc plein de diagrammes de séquence. Mais ils devraient tous se ressembler au niveau des frontières du système testé.

En respectant cette règle, on écrira plus facilement des tests qui vérifient un véritable comportement attendu du système (et non un comportement qu’il a acquis par hasard). On évitera aussi de tester plusieurs comportements à la fois.

Les différentes topologies et la façon de les tester

Les bonnes pratiques exposées ci-dessus sont assez génériques, mais dans la pratique, on rencontre un certain nombre de « topologies » de systèmes testés en fonction des dépendances et de ce qu’il y a à l’intérieur. Ces différentes topologies sont chacunes associées à une façon différente de tester le système.

« fonction pure »

La topologie "fonction pure".
La topologie “fonction pure” décrit un système qui n’a ni dépendances ni état interne.

La topologie « Fonction Pure » désigne un système qui n’a ni dépendances ni état interne. Le système produit toujours le même résultat en fonction de l’ensemble des données en entrée.

J’appelle cette topologie une fonction pure car le terme est parlant et désigne bien ces caractéristiques, mais ça ne veut pas dire que le système est constitué d’une seule fonction (ni, d’ailleurs, qu’on l’appelle en une seule fois). C’est tout l’objectif de la démarche : peu importe la forme du système à l’intérieur, ce qui compte ce sont ses frontières avec l’extérieur.

Cette topologie est la plus facile à tester : le test appelle le système avec des données de test, puis fait des assertions sur les données récupérées en retour.

« Dépendances externes »

La topologie "système avec dépendances".
La topologie “système avec dépendances” nécessite l’utilisation de doubles de test.

Lorsqu’un système testé a des dépendances externes, il ne suffira plus d’appeler des fonctions ou des méthodes pour le tester. Le système lui-même aura besoin d’appeler ses dépendances. C’est dans cette situation qu’on introduit des doubles de test.

Les doubles introduisent une complexité supplémentaire dans les tests, il est donc important de ne pas en abuser. Lorsqu’on a le choix, il vaut mieux favoriser un système testé un peu plus grand ou une réorganisation du code pour repasser sur la topologie « Fonction pure ». Néanmoins, cette topologie est extrêmement fréquente.

Pour tester ce genre de système, on configure les doubles pour qu’ils retournent à notre système des données pré-configurées cohérentes avec les données qu’on fournit au système en entrée. Le scénario de test (qu’on peut identifier grâce au diagramme de séquence) nous aide à identifier les mocks à configurer en fonction du cas de test.

Il ne faut pas oublier les assertions en entrée des doubles de test. Le système doit bien se comporter avec les données reçues des doubles, mais il doit aussi les appeler correctement. Certaines façons de configurer les mocks font automatiquement une partie des assertions (Mockito si vous évitez d’utiliser any() partout par exemple, ou playwright qui matche ses routes en fonction de l’url complète), mais d’autres répondent toujours peu importe la façon dont on les appelle (jest.fn() par exemple)

L’avantage de cette topologie, c’est qu’elle permet souvent d’éviter d’avoir un système testé à état, ce qui permet d’avoir des tests plus rapides, centrés sur le domaine métier, et/ou avec des scénarios de test plus courts.

« Système à état »

La topologie "système à état interne".
La topologie “système à état interne”. L’inconvénient est la complexification des scénarios de test.

Même pour un fervent défenseur de l’immutabilité (que je suis), il arrive que le système testé contienne des classes avec un état interne mutable. Le cas des tests end-to-end implique aussi fréquemment un état interne au système (une BDD par exemple).

Dans ce cas, la tentation est grande d’aller regarder l’état interne du système pour vérifier qu’une action effectuée par le test a eu l’effet désiré. À l’inverse, on voit aussi fréquemment un test accéder directement à l’état interne pour y insérer des données en tant qu’état « initial » du système.

Si l’état fait réellement partie du système, respecter les règles énoncées plus haut devrait nous interdire ce genre de pratique. Encore une fois, cela produit un test couplé à l’implémentation du système en nous empêchant par exemple de restructurer cet état interne sans devoir aussi modifier les tests qui accèdent à cet état par des moyens inexistants dans le système réel.

À la place, il faut élaborer des scénarios de test qui impliquent cet état, mais sans directement y faire référence. Si ces scénarios n’existent pas, c’est que l’état interne ne sert à rien ou que le système testé est mal défini (il en manque un bout). Pour simplifier, le scénario de test va commencer par une action qui écrit dans l’état, et ensuite faire une action dont le comportement ou la valeur de retour lit dans cet état.

Diagramme de séquence pour un test de système stateful
Le fait d’avoir un état à l’intérieur du système testé empêche le test d’y accéder directement pour faire des assertions ou gérer un état initial. On a un scénrio de test en deux étapes, alors qu’en sortant l’état du système testé on aurait pu faire deux cas de test plus petits, au prix d’un mock à gérer.

Si on ne veut pas de ce scénario parce qu’il est trop long ou trop complexe par exemple, il faut explicitement décider de sortir l’état du système testé et de le remplacer par un mock ou une BDD de test, mais au prix de scénarios moins proches des cas d’utilisation réels et qui utilisent des mocks.

Conclusion

Récapitulatif

Coupler les tests à l’implémentation du système testé diminue leur efficacité

  1. Les tests changent trop souvent, entrainant un coût de développement plus élevé
  2. Les tests sont décorrélés des usages réels du système, ce qui diminue la confiance qu’ils apportent et cause parfois des faux positifs (tests rouges alors que les fonctionnalités sont correctes)

Pour éviter ça, il est important d’expliciter le système testé, son interface et ses dépendances puis de respecter quelques bonnes pratiques :

  1. Le code de test intéragit avec le système testé via son interface uniquement
  2. Les scénarios de test correspondent à des cas d’usages réels du système
  3. Les doubles de test remplacent uniquement des dépendances directes du système testé

Et le TDD dans tout ça ?

Le TDD c’est un peu le cheat code de l’écriture de tests. Quand on écrit un test avant d’écrire le code du système testé, on est naturellement incité à penser à l’interface du système plutôt qu’à son implémentation. Quand on écrit un mock d’une classe qui n’existe pas encore, c’est parce qu’on sait que notre système en aura besoin, pas parce qu’elle est un attribut d’une des classes du système.

La méthode expliquée dans cet article s’applique aussi au TDD, mais le TDD la rend plus naturelle et plus facile. Je ne peux que vous encourager à le pratiquer.