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.
II- Quelles solutions envisager ?
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.