dimanche 21 novembre 2021

TDD avec tests intégrés

Ce que nous attendons des tests dans une CI est qu'ils soient parfaitement reproductibles, extrêmement rapides, faciles à tourner n'importe où.

Ce que nous attendons des tests lorsque nous développons du nouveau code en TDD est que les tests soient représentatifs de la réalité, que la différence avec la production soit faible, que l'on puisse démarrer en TDD au plus vite (pour écrire le minimum de tests après-coup) et qu'on puisse appliquer le TDD sur une partie importante du code.

Ce sont des besoins très différents, qui de plus nous importe à des phases séparées! Pourtant nous nous efforçons de répondre aux deux besoins avec les mêmes tests en même temps. Mais qui a eu une idée pareille? 

Que se passerait-il si nous commencions les stories avec des tests intégrés et si on isolait uniquement "au besoin"? Si à la fin de la story on s'assurait d'avoir les tests maintenables et adaptés à tourner dans une CI personne ne saurait qu'on fait des tests sales entre temps. Peut-être qu'on en tirerait une grande valeur?

L'approche classique

Typiquement pour répondre aux besoins de maintenabilité des tests nous nous isolons des dépendances externes tels que apis externes, bases de données etc. Ce qui implique que nous introduisons une couche d'isolation de ces dépendances, en simulant ces dépendances avec des mocks ou des simulateurs fait main. Du côté framework qui nous appelle, on va plutôt ignorer le framework dans nos tests en appelant directement notre code.

Imaginons la situation où nous devons écrire une fonction lambda ou cloudfunction pour modifier une valeur persisté en bdd. Nous devons décrire la fonction, ses paramètres, les ressources auxquelles elle a droit, faire de la validation des droits utilisateur, extraire des données de la requête, récupérer une valeur en bdd, récupérer une autre valeur dans une api externe, sauver une nouvelle valeur en bdd et qq part dans tout ça faire un petit calcul. A quel moment faisons nous du TDD avec tests unitaires? 

Probablement assez tardivement, une fois que nous avons réussi à mettre en place tout le reste et ce uniquement sur la partie calcul - le moins risqué de tout. On comprend bien que l'apport du TDD dans l'ensemble de la story semble dérisoire! Certes au bout d'un moment la complexité grandit et les tests unitaires changent tout, mais le problème est qu'il faut attendre un certain temps et pendant ce temps les tests et le TDD semblent apporter peu, donc ne sont pas faits.

La bonne volonté de faire des tests de bonne qualité, notamment en termes d'isolation, nous contraint de ne les utiliser que pour une petite partie du problème, après avoir presque terminé la tâche. Pas étonnant qu'on commence pas avec les tests!

La bonne question à se poser serait plutôt; Comment puis-je faire pour que les tests me donnent un feedback de qualité le plus tôt possible? Quand je dis "feedback de qualité" je pense à rapide et proche de la réalité sur le code en construction.

Par exemple, il est tout à fait possible de lever temporairement les contraintes de besoins tel que la répétabilité, la rapidité et tout autre aspect de la maintenabilité. Des tests qui prennent 1 seconde à tourner, voire plus, ne poseront pas de problème pendant qq heures. Des tests qui ne tournent pas sur la machine de mes collègues ne poseront pas de problème tant que je ne les mets pas dans le pipeline. Même des tests que je suis le seul à comprendre (avec mon pair) suffisent amplement tant que personne d'autre ne travaille dessus. Je pourrais alors faire du TDD avec des tests très haut niveau en totale intégration avec toute dépendance externe....

TDD en totale intégration avec toute dépendance

Cette approche est le mieux illustré par le workflow d'une story lorsqu'il n'existe aucun code existant. Mon workflow dans ce cas est de commencer avec un test très haut niveau, au niveau de l'api, sans rien mocker, ni même des prestataires externes. En pur TDD je vais faire passer ce test en faisant du code sale, dans cet exemple en faisant tout le code dans mon contrôleur. Mon code ne sera pas très gros, car il n'aura pas de branches, pas de validation, pas de configurabilité, ce sera pour plus tard grâce à d'autres tests (justement TDD), ainsi j'aurai fait passer le test assez rapidement. Je passe ensuite à un deuxième test aussi haut niveau, je le fais passer rapidement, et ainsi de suite.

Au bout d'un moment, typiquement au bout de 3-5 tests la logique commence à se cristalliser. Le comportement d'un service aura été découvert, les interactions avec la BDD stabilisés. Il y aura aussi de la logique pure de transformation de données et validation. C'est un bon moment pour concevoir une interface mockable pour s'isoler de la dépendance externe, concevoir l'interface d'une classe repository, une classe de service et peut-être des value objects. Ces extractions rendent possible l'isolation des dépendances et je peux ainsi continuer avec d'une part des tests haut niveau mais cette fois-ci sans dépendances, soit des tests haut niveau de l'hexagone, et d'autre part des tests bas niveau de mes adapteurs (le code qui invoke des services externes) si j'ai besoin de les explorer davantage.

C'est à ce moment du workflow que les tests commencent à devenir maintenables. C'est donc qu'à partir d'un certain moment que je fais mes choix de design interne et ça c'est clé! Certes on peut faire ces choix au début, mais cela implique de prendre des décisions avec moins d'informations, donc plus souvent sous-optimales. Si on doit revenir sur un choix de design cela coûtera plus cher si des tests sont déjà écrits contre ces choix! Cerise sur le gâteau, ces tests ne pourront pas protéger le refactoring. Mon choix par défaut est donc de commencer avec des tests intégrés afin de retarder la prise de décisions, avoir le maximum de feedback proche de la réalité et assurer la refactorabiltié de l'ensemble le cas échéant. 

Mais là où je trouve l'isolation "d'office" néfaste c'est pour les débutants en TDD, soit presque la totalité des devs… Les inciter à résoudre les problèmes de maintenabilité des tests avant même de commencer à les écrire leur rend la tâche plus dure. Le tout pour un bénéfice discutable, ou inexistant, voire même négatif quand cela, comme souvent, résulte dans des tests pas écrits du tout! Pour apprendre à travailler avec des tests il faut déjà en faire. 

Conclusion

Ce n'est pas parce qu'on veut des tests maintenables qu'on dois s'infliger cette obligation dès le 1er test.

En particulier il peut y avoir un certain nombre d'avantages à faire autrement :

  • Dans la phase TDD (construction) les tests totalement intégrés peuvent nous apporter plus que des tests isolés.
    • En terme de fidélité avec la réalité
    • En terme de couverture
  • Il est plus facile de commencer en TDD avec des tests intégrés.
  • Plus les tests sont haut niveau, plus ils permettent des refactorings

Pour être clair je ne transige pas sur les qualités des tests à la fin de la story. Simplement j'utilise souvent des tests intégrés au début et pendant une story. Je pense aussi que c'est plus facile d'en tirer un bénéfice dans la phase de construction, ce qui inciterait plus de gens à essayer le TDD sur du vrai code.

Surtout, n'hésite pas à essayer.

Notes de fin

En pratique je fais des variantes dans le workflow ci-dessus, plus classiques comme approche. Par exemple, pour intégrer un service externe on peut très bien directement explorer l'api avec des tests bas niveau. Un autre exemple c'est lorsque le code d'intégration avec une dépendance externe existe déjà j'utilise directement son simulateur, au lieu d'intégrer avec le service externe. 

Encore un autre exemple est lorsque la story ne concerne pas les dépendances alors évidemment on utilise pas de test intégré.