Tests unitaires ou tests automatisés sans tests unitaires, quelle stratégie choisir ?

La pratique des tests unitaires est de plus en plus populaire, mais bien qu’intéressante, elle n'est pas sans inconvénients...

Une mode a sévi dans le milieu informatique il y a quelques années, c'est celle du TDD (test driven development) qui est d'ailleurs assez mise en avant par la méthode agile XP (extreme programming).
Son succès n'est pas due qu'a l'effet « tout nouveau tout beau », mais aussi parce que cette méthodologie apporte les avantages des tests automatisés dans les milieux informatiques, ce qui n'est pas rien. Le fait de pouvoir en quelques dizaines de secondes, connaître l'état de fonctionnement d'une application facilite effectivement la vie des développeurs, ils peuvent par exemple s'assurer de la non régression de l'application au fil des développements. Mais TDD ne met pas en avant que les tests automatisés, cette pratique met aussi en avant le fait de les écrire avant d'écrire le code applicatif et le test unitaire, ce qui n'est pas sans inconvénients.
Nous allons justement nous pencher sur le test unitaire qui a un avantage : étant donné que les tests unitaires se basent sur une seule méthode à la fois, ils sont relativement simples.

NB : Dans cet article, j' entend par contexte applicatif (ou contexte de l'application), le contexte qui regroupe l'ensemble des classes (du code plus généralement) , des librairies, des paramètres et autres éléments indispensables au bon fonctionnement d'une application.

I-Quels sont les inconvénients des tests unitaires ?

-Le coût :
En effet, chaque méthode publique de chaque classe (à l'exception peut-être des accesseurs et des mutateurs) sera testée, on peut en déduire (à la louche) une multiplication par deux des coûts de développement par rapport à une équipe qui n'écrit pas de tests automatisés. Cette même équipe aura normalement un surcoût lors des tests de recette, mais par expérience, il est inférieur à celui des tests unitaires.

- Complexification du code :
L'utilisation de tests unitaires nécessite parfois de complexifier l'implémentation (sur-interfacage, rajout de méthodes et de paramètres pour pouvoir injecter des doublures et évaluer l'état d'un objet etc.). Ce problème n'est pas spécifique aux tests unitaires, mais plutôt aux tests automatisés en général. Cela dit, il est nettement plus fréquent dans les tests unitaires que dans les autres façon de pratiquer les tests automatisés, ceci est (entre autre) dû a un couplage fort entre les tests unitaires et le code applicatif (le code que l'on teste).

- Couplage fort entre les tests unitaires et le code applicatif :
On a un couplage fort entre les tests unitaires et le code. Pour chaque méthode publique de chaque classe de l'application, il existe au moins un test. On peut en déduire que tout changement d'implémentation dans le code applicatif (y compris dans les couches de bas niveau), nécessitera de modifier les tests, ce qui n'est pas normal ! En effet, lorsque l'on écrit des tests, on veut s'assurer que le comportement (d'un point de vue fonctionnel) de l'application est correct, pas son implémentation, ce n'est donc pas acceptable que tout changement d'implémentation (et donc purement technique) sans impact sur le comportement fonctionnel de l'application, impliquent une modification des tests.

- Risque de tests non exhaustif d'un point de vue fonctionnel :
Les tests unitaires sont un peu comme des bisounours qui vivent dans un monde parallèle, complètement décorrélés de la réalité, en effet, chaque test est focalisé sur le comportement d'une méthode bien précise. On est donc dans une vision d'assez bas niveau. Avec toutes ces doublures, ces méthodes de bas niveaux et ce contexte d’application spécifique aux tests, on peut facilement oublier le but final, tester intégralement l'exigence que l'on est en train d'implémenter. Et croyez-moi, cela arrive plus souvent qu'on ne le pense. C'est quand même dommage que l'augmentation significative des coûts de développement (due aux tests unitaires) ne garantissent pas une couverture totale du périmètre fonctionnelle d'une application, non ?

- Risque de non fiabilité des tests :
Le fait que l'on soit « détaché de la réalité » peut avoir une autre conséquence, la non fiabilité du test. En testant unitairement, notamment lorsque l'on utilise des doublures (simulacres, bouchons, etc...) pour chaque classe qui est en interaction directe avec celle que l'on teste, on est sûr d'une chose, que en production (dans la vraie vie), le contexte de l'application sera différent. On ne peut donc pas garantir que le fait qu'un test passe au vert, implique systématiquement le fait que le comportement testé fonctionnera comme il se doit en production. En clair, il y a des chances que le test soit totalement inefficace. Toujours, par expérience, je peux vous dire que ces chances sont non négligeables.

Maintenant que vous avez pris conscience des dangers des tests unitaires, une question vous turlupine : que faire ? Abandonner les tests automatisés ?

II- Quelles solutions envisager ?

Les avantages des tests automatisés étant malgré tout séduisants, je vous propose de faire du test automatisé sans faire de tests unitaires (du moins, avec le moins de tests unitaire possible). Pour y parvenir, il va falloir prendre un peu de hauteur. Il va falloir arrêter de tester bêtement chaque méthode publique d'une application, mais plutôt chaque fonctionnalité, et ce dans le contexte le plus réaliste possible.
Concrètement, cela veut dire que dans la grande majorité des cas, vos tests invoqueront directement les méthodes de plus hauts niveaux dans la pile d'appel d'un événement (clic sur un bouton, accession à une page etc...). Par exemple, dans une architecture MVP, vos tests invoqueront essentiellement les présenteurs. Dans une architecture Flex + BlazeDS, vous testerez la partie Java essentiellement en invoquant les classes de services qui sont elles mêmes invoquées par la partie cliente de l'application (celle qui est en Flex).

1- Comment s'y prendre ?

Pour chaque exigence, il existera au moins une méthode de test qui invoquera les méthodes de plus hauts niveaux. L'architecture de votre code de tests automatisés sera décorrélés du code de l'application. Vous regrouperez les tests par domaines fonctionnels (gestion des utilisateurs, écrans de recherche etc...) qui se retrouveront dans le code sous la forme de packages, namespace ou classes. Vous aurez donc des packages (ou namespace) qui auront des noms comme « gestion.utilisateur », « gestion.recherche ». Toujours dans le même genre, vous aurez des classes qui auront comme noms « TestResponsableDeBanque » , avec des noms de méthodes comme « aLeDroitDeModifierUnCompteBancaire() ».

Vous aurez bien souvent les mêmes données à initialiser dans vos tests, factorisez le code d'initialisation dans des assistants (helpers). Vous aurez bien souvent les mêmes données à vérifier dans vos tests, là encore, factorisez le code de récupération de ces données.

La solution que je vous propose n'est pas sans inconvénients, vos tests étant de plus hauts niveaux, ils auront tendances à êtres plus complexes, notamment au niveau de l' initialisation des données avant le test proprement dit, et de l'évaluation des données qui permettent de savoir si le comportement attendu a bien eu lieu. Mais cette complexité sera maîtrisé si vous factorisez votre code d'initialisation et de récupération de données.

2- Quels sont les avantages par rapport aux tests unitaires ?

- Relative indépendance vis à vis de l'implémentation :
Dans vos tests, vous invoquez les méthodes de plus hauts niveaux, les méthodes de récupération de données (dao, entrepôts etc..), et les classes du modèle représentant les données. Tout le reste n'est pas utilisé directement dans les tests, ainsi l'implémentation de ce « reste » peut être changé sans que cela ait le moindre impact sur les tests (sous réserve que le comportement fonctionnel de l'application ne change pas).

- Gain en productivité :
Il y a moins de tests à écrire, on écrit des tests pour chaque exigence, pas pour chaque méthode publique. Quand bien même certaines exigences (ou règles de gestion) nécessiteraient plusieurs tests, vous économiserez des jours et des jours de développement. Par expérience, une application contient beaucoup (mais alors beaucoup beaucoup) plus de méthodes publiques que d'exigences, le calcul est vite fait.

- Couverture fonctionnelle des tests plus importantes :
Cela peut paraître bête, mais en prenant de la hauteur et en se focalisant sur les exigences, on a moins de chances d'en oublier et réciproquement plus de chances que les tests couvrent le périmètre fonctionnel à 100%.

- Détection plus facile du code mort :
Prenons le cas idéal où votre périmètre fonctionnel est intégralement couvert par vos tests. Si vous utilisez un cadriciel (framework) de test de couverture de code pendant que vous lancez l'intégralité des tests automatisés , il se peut que certaines classes ou méthodes ne soient pas couvertes, si elles ne sont pas remplacées par des doublures lors des tests, il y a fort à parier qu'il s'agisse de code mort, vous savez ce qu'il vous reste à faire...

- Des tests plus fiables :
Les tests seront plus réalistes. Bien sûr, vous allez devoir utiliser des doublures de temps en temps, mais malgré tout le contexte de l'application sera bien plus proche de celui de la production que dans une approche « test unitaire puritaine ».

Vous l'avez compris, je prône le test par exigence au détriment du test unitaire, car, dans le fond, nous, développeurs, clients, managers etc... Se fichons éperdument de savoir si telle ou telle méthode marche ou pas, ce qui nous intéresse est de savoir quelle exigence est satisfaite ou pas.
Cette chronique ne manquera d'irriter une certaine communauté soit disant bien pensante de développeurs, mais j'espère que comme moi, vous préférez amplement le pragmatisme et l'objectivité, au dogme quasi religieux de certaines (bonnes ?) pratiques de développement.