Explorations dans le monde des tests

Mes tout premiers tests ont été visuels. Je lance le serveur, je lance un client, je clique là, puis là, je cherche la donnée dans le rapport et je regarde si elle est bonne. Je mettais un temps incroyable à identifier les causes des problèmes, comme toute l'application devait être lancée et que les causes possibles étaient très nombreuses. Débogguer à la main, c'est long. Pire, l'implémentation de nouvelles fonctionnalités créaient des bugs sur les précédentes.

J'ai poursuivi avec des tests automatiques grâce à JUnit. Le problème de régression était moins présent mais l'identification des causes restait laborieuse car mes tests n'étaient pas unitaires. Ils traversaient plusieurs couches.

Puis j'ai appris ce qu'était un véritable test unitaire. Mes tests JUnit sont devenus réellement indépendants et bien séparés. Les collaborateurs ont été mockés : toute action extérieure était vérifiée. Le cout d'entrée a été assez élevé mais une fois que l'habitude est prise, l'écriture systématique de tests unitaires allait vite.

Parallèlement, nous nous servions de Fitnesse pour tester des unités fonctionnelles : par exemple que si l'action Struts reçoit telle paramètre, alors en base il y a tel champ d'enregistré. L'utilisation du framework a couté encore plus cher que pour JUnit, mais le ROI est incroyable. Je ne compte pas le nombre de fois où il nous a sauvé la mise en nous signalant une régression sur un module à première vue sans rapport.

De plus, une fois que les classes de base sont en place, le cout d'un test supplémentaire baisse significativement. Enfin, le fait d'avoir des spécifications visibles par tous, facilement retrouvables et constamment vérifiées est très appréciable. Par rapport à une approche Behaviour Driven Development, il n'y a besoin de chercher dans le code quel comportement on attend déjà quand un utilisateur fait une recherche malformée sur l'application.

Nous avons aussi utilisé Selenium. Séduisant au premier contact par la facilité avec laquelle on peut construire un scénario (concrètement on clique sur un bouton "record"...), je l'ai vu peu à peu être délaissé sur plusieurs projets, pour cause non maintenabilité. Il y a énormément de duplication dans les tests. Cadremploi utilise aujourd'hui Tellurium qui serait moins pire. Je vous renvoie au billet d'Emmanuel si vous souhaitez en savoir plus.

Un gout bizarre

Le projet marchait bien et pourtant, je ressentais parfois un malaise, quelque chose clochait dans notre manière de faire. J'avais l'impression de faire deux fois la même chose quand j'écrivais un test avec JMock puis que j'implémentais le code. Voire une troisième fois au moment d'écrire le test fitnesse.. Ca pour être robuste, l'application était robuste. On ne pouvait pas changer grand chose sans se taper les tests qui allaient avec, même si la modification était iso-fonctionnelle. A contrario, une fois, une ligne avait été supprimée par mégarde. Hudson l'a tout de suite signalé. Donc c'est bien ?

Tester les appels ou tester les états ?

Par rapport au coût de tous ces filets de sécurité, nous pourrions nous demander si cela vaut vraiment le coup? Pour pallier à cette unique fois où un code a été supprimé par erreur?

C'est ainsi qu'après avoir été une fanatique du mock grâce à la puissance de JMockit, qui permettait de tout mocker, j'ai eu un doute.

Une prez de Misko laisse penser qu'il faut mixer les deux, en fonction de ce que l'on teste. Je me suis dit que quand l'objet le permettait, il vallait mieux tester des états que des façons de faire. Autrement dit, des assertions sur les valeurs des objets sont préférables à des vérifications d'appels de méthodes avec des mocks. Cela permet d'avoir des tests moins couplés à l'implémentation. Mais cette pratique a aussi des défauts.

Imaginons que nous devions tester ce code :

  1. String generateFrenchUrl(String englishUrl){
  2. // traduit englishUrl en francais et l'encode avec URLEncoder
  3. }

A l'époque, j'aurais eu tendance à vérifier les appels à un service de traduction et à URLEncoder. Car ces derniers sont eux unitairement testés par ailleurs.

  1. void testGenerateFrenchUrl_parMock
  2. String returned = myClass.generateFrenchUrl("http://monSite.com/iLoveSummer");
  3.  
  4. verify(traductionService.translate(englishUrl));returns("urlEnFrancais");
  5. verify(URLEncoder.encode("urlEnFrancais"));returns("urlEnFrancaisEncode");
  6.  
  7. assertThat(returned, is(equalTo("urlEnFrancaisEncode")):
  8. }

Si je choisis de tester les états dès que c'est possible, je me retrouve à tester plusieurs fois la même chose. Oh nooooooooooooooon. Et bien si, et à maintenir, c'est la galère. Je teste que l'accent aigu est bien encodé dans le test du service URLEncoder, mais aussi dans testGenerateFrenchUrl. Où est ce que cela s'arrete? Faut il aussi dupliquer le test des accents circonflexes, des umlaut ? C'est aussi la meme chose au sujet des tests du service de traduction. Une fois que l'on a tranché sur les tests à dupliquer (par exemple si on se dit qu'une fois que l'accent aigu et l'umlaut sont testés, c'est suffisant pour les tests "indirects"), il se peut aussi que nous ayons à le faire une troisième fois pour la classe qui appelle generateFrenchUrl ...

C'est parce que j'en avais marre de d'avoir des tests dupliqués que les mocks m'ont paru mieux. Les rares fois où je vérifiais les états, c'était que je n'avais besoin que de quelques comportements bien délimités du service, par exemple si je n'ai besoin que d'avoir un encodage pour l'accent aigu, le circonflexe et le grave.

Donc :

  • Avec des tests de moyens, on fait plusieurs fois la meme chose entre l'implémentation et le code. Parce que la grunalurité est très très fine, la marge de refactoring est très faible et/ou fortement ralentit par les corrections des tests.
  • Avec des tests par états, on a de la duplication dans les tests. Ces derniers sont aussi plus éparpillés dans les projets et c'est "difficile" de voir quels tests manquent.

Qu'attendons nous des tests ?

En fait, il faut bien se rendre compte de ce que l'on attend des tests unitaires. Un collègue (Coucou JP si tu es là !) me dit que tester des passe-plats (ie : qu'une méthode X est appelée avec tel argument) n'a absolument aucun intérêt. Là où ça me gène un peu, c'est que sans tester ce passe-plat, rien ne me dit que la fonctionnalité cible est branchée à l'application.

C'est donc déjà la fonction numéro un d'un test : 1. Vérifier qu'une fonctionnalité demandée par le client marche.

Réponse possible : "Nous avons un test d'intégration qui valide déjà cela.".

  1. Est ce que c'est vrai ? => A vérifier en commentant l'appel pour voir s'il y a un test d'intégration qui échoue. Les tests d'intégrations étant très couteux à faire, on peut plus facilement oublier de gérer tous les cas.
  2. La fonctionnalité est couverte, mais sans ce test, il est beaucoup plus difficile de trouver la cause en cas de régression.

C'est une autre de mes attentes : 2. Cibler les causes de régression rapidement

Réponse de JP : "Nous avons trop soufferts des mocks. C'était impossible de refactorer quoique ce soit, les tests U étaient des copier-coller de l'implémentation avec des verify dedans. On finissait par refactorer, vérifier que fitnesse était vert et supprimer les tests unitaires cassés".

Encore une autre attente : 3. Constituer un filet de sécurité pour permettre d'améliorer le code.

Dans ce cas, si Fitnesse couvre le fonctionnel, et que le test unitaire aussi (dans un scope plus étroit), je ne vois pas l'un intérêt des tests unitaires (qui n'en sont plus finalement). JUnit et Fitnesse font la même chose et remplissent les objectifs 1 et 3.

Par contre, les tests unitaires ont deux avantages sur fitnesse : un feedback d'échec plus rapide et l'aide à la construction du code.

C'est un 4e intérêt du TDD plus particulièrement : 4. Permettre de construire le code en pensant à ce que l'on attend.

Je pourrais aussi dire l'inverse en fait : que je ne vois pas l'intérêt de Fitnesse ou concordion si les tests unitaires vérifient déjà les fonctionnalités. Surtout qu'on est globalement plus à l'aise avec JUnit qu'avec d'autres outils externes pour écrire des tests. Avec Fitnesse, les tests servent aussi de spécifications. Le fait qu'elles soient exécutables permet de lever toute ambiquité des coté client et développeur.

5. Servir de spécifications

En l'état, JUnit remplit mal ce rôle car le client ne peut pas les voir. C'est un peu le problème des tests fonctionnels sous forme de code comme JBehave. En tant que développeur, c'est aussi difficile d'y voir clair parmi tous les tests à moins de se mettre d'accord sur des standards.

Sans être des objectifs, il est primordial que les tests s'exécutent à peu près rapidement et soient maintenables, sans quoi ils seront vite abandonnés.

Conclusion

En fonction de l'importance que vous accordez à ces points, vous préférerez n'avoir que des tests fitnesse car c'est ce qui compte pour le client, n'utiliser que selenium parce que c'est plus user friendly à créer ou n'avoir que du JUnit pour tout couvrir. Personnellement, l'intérêt 2 me tient encore trop à coeur pour me contenter de faire des tests fonctionnels globaux. Je vais peut être déjà essayer de faire des tests fonctionnels par tronçon pour ne pas non plus bloquer le refactoring. Le juste milieu n'est pas simple à trouver.

Mon autre doute récent autour des tests concerne son ROI. J'étais convaincue qu'il fallait toujours, toujours en faire, que cela en valait toujours la peine. Est ce toujours le cas? Quand allons nous trop loin? Ce sera le sujet d'un prochain billet.