Fantastic Tests
Par Florian Strzelecki -
And How to Write Them
J'ai donné le mois dernier un talk mystère à Software Crafts·wo·manship, car j'avais envie de parler des tests (en programmation). Le format ne permettant pas de s'étendre beaucoup sur le sujet, je prends le temps aujourd'hui de l'explorer un peu plus par écrit.
J'aime les tests. Tous les types de tests : unitaire, intégration, QA, utilisateur, performance, chaos, etc. il en existe plus que je ne peux en lister. C'est, avec la documentation, un outil indispensable pour moi. Si je ne suis pas un extrémiste du TDD, je tente de le pratiquer aussi souvent que possible, et je ne cesse d'en défendre l'utilité et les usages.
Pourtant, je tombe encore et toujours sur les mêmes débats sans fin, les mêmes remarques subjectives, les mêmes traits d'esprit qui se veulent toujours plus malins, et les mêmes conflits stériles qui cristallisent des positions bien campées là où il faudrait plutôt résoudre les problèmes - et de répondre aux vrais besoins.
2 unit tests. 0 integration tests
Je prends souvent en exemple le cas des blagues "2 unit tests. 0 integration tests". Il en existe beaucoup de variantes, toute plus ou moins drôles.
Je n'aime pas cette blague. Ce n'est pas tant qu'elle soit employée à tort et à travers, que le fait qu'elle se base sur un paradigme que je trouve fondamentalement inadapté : il y aurait d'un côté les tests unitaires, et de l'autre les tests d'intégration. Certains verront dans cette blague une défense des tests d'intégrations, d'autre une attaque des tests unitaires, alors que le problème de fond, ce n'est pas l'un ou l'autre, ou l'un sans l'autre, mais l'inadéquation des tests avec les besoins réels. Passer son temps à dire qu'il faut les deux ne permet toujours pas de dire comment écrire de bons tests.
Le problème de fond qui m'intéresse, moi, c'est d'écrire des tests qui ont un sens ; des tests qui m'apportent quelque chose, non pas en statistique ou par respect de certains principes, mais en sécurité, en maintenabilité, et surtout, en utilisabilité.
À la fin de la journée, lorsque je reçois un rapport de test positif, ce qui compte, c'est que je sois rassuré : l'utilisateur va pouvoir utiliser mon application.
Pas ma guerre
Les débats stériles autour des tests ont ceci d'agaçant qu'ils sont souvent récurrents, d'autant plus que les sujets pour s'écharper sont nombreux :
- unit vs integration,
- définition des types de tests,
- qui doit écrire quels tests (le dev ? la QA ? les deux ? le chef de projet ? une société externe ?),
- TDD ou pas TDD,
- Pureté extrême d'une approche vs l'approche opposée tout aussi extrême,
- les réactions sur la défensive, ou d'orgueil mal placé ("mon outil est meilleur que le tiens"),
- la couverture de code ne sert à rien/est une valeur clé,
- "tester c'est douter" et tous ces traits d'esprits pour se sentir malin sans jamais remettre en question le status quo d'une culture dans laquelle les tests ne sont pas encore parfaitement dans le quotidien des développeurs,
C'est d'autant plus fatiguant que j'ai l'impression de toujours lire et entendre les mêmes poncifs dans nos cercles de développeurs, sans rien de nouveau. Pourtant, je suis certain qu'il y a une réelle diversité d'approches et d'options autour des tests : j'aimerais que ce soit ces options qui soient mises en avant, et pas les blagues Carambar.
Par où commencer ?
Dans mon intervention, je prends pour exemple le développement d'un micro-service qui expose une API relativement simple : du CRUD au-dessus d'un modèle de données basique. J'ai réduit au maximum la complexité pour l'exemple.
Je donne ensuite mes 3 étapes idéales du développement :
- écrire des spécifications techniques de l'interface,
- écrire les tests,
- écrire le code (jusqu'à ce que les tests passent).
C'est un monde idéal, où l'interface est suffisamment spécifique pour pouvoir être complètement décrite dès le départ. Ce ne sera pas toujours le cas (voire rarement), et il faut savoir s'adapter à la situation - pour la démonstration, cet exemple fera néanmoins l'affaire.
Il en ressort deux concepts importants : le comportement d'un côté, tel qu'il est décrit par les spécifications ; et de l'autre l'implémentation, c'est-à-dire le code. Les tests, quant à eux, sont entre les deux : ils vérifient que l'implémentation correspond au comportement, et que le comportement est bien implémenté comme il est attendu.
C'est à partir de là que je peux proposer une nouvelle classification de mes tests, non pas en fonction de critères arbitraires purement technique, mais en fonction de leurs intentions, en fonction de ce qu'ils m'apportent.
Pourquoi ces tests sont-ils écrits ?
Pour cela, je propose 3 catégories principales :
- tester l'implémentation,
- tester le workflow,
- tester ou vérifier un cas de bug,
L'important n'est ici ni l'outil, ni le type "unitaire" ou "intégration : ce qui compte, c'est l'intention, l'objectif des tests. La question que je me pose toujours, c'est à quoi et à qui servent-ils ?
Tester l'implémentation
Les premiers tests s'intéressent à l'implémentation, pour vérifier qu'elle fonctionne comme prévue. Généralement, nous connaissons l'implémentation, comme si c'était une boîte blanche et transparente, dont les données nous sont accessibles, dont l'état peut être fabriqué à la demande pour nos tests. Le but est de vérifier qu'à son niveau, l'implémentation est cohérente : ce qui est donné en entrée se retrouve dans l'état du système, et l'état du système est cohérent avec ce qu'il produit en sortie.
C'est le plus souvent là que nous retrouvons les "tests unitaires", et de nombreux débats existent pour savoir où se trouve la limite avec les tests d'intégrations. Des débats complètement stériles, puisque fondamentalement, ce n'est pas ça qui apporte de la valeur : c'est transformer maladroitement des pratiques en une sorte de fin en soi ultime.
Par exemple, si je dois vérifier l'implémentation derrière une requête POST /article
, qu'est-ce qui compte ? Que la fonction au bout du code soit capable d'enregistrer un article ? Que la réponse à la requête contienne les bons headers ? Ou bien encore que l'implémentation a bien enregistrée l'article ainsi publié dans une base de données et qu'en même temps la réponse contienne bien les données cohérentes avec ladite base ?
Aucune de ces questions ne trouve sa réponse dans le choix des outils ou des paradigmes de test (unitaire, intégration, etc.), car elles questionnent l'ensemble de l'implémentation.
Voici un exemple de scénario écrit en Gherkin pour tester le cas nominal :
Scenario: Create a Person at a given ID
Given I want to call "/person/3" with JSON data
| FIELDS | VALUES |
| givenName | Robert |
| familyName | Bosch |
And I use test credentials
And there is no other persons in database
When I perform a POST request
Then the response is OK (created)
And the response content type is "application/json"
And the response contains the data of person "3" as JSON
And the response contains these JSON fields
| FIELDS | VALUES |
| givenName | Robert |
| familyName | Bosch |
Note : j'utilise le programme behave pour exécuter ce genre de tests. Gherkin a cet avantage qu'il n'y a pas besoin de connaître un langage de programmation en particulier pour comprendre cet exemple.
Au début, je détermine :
- quelle URL je souhaite appeler,
- avec quels paramètres,
- ainsi que l'état du système (ici, pas de données en base)
Ensuite, j'effectue l'appel, et je vérifie :
- que la réponse est bien celle attendue,
- que ses paramètres sont ceux attendus,
- qu'elle contient des données cohérentes avec l'état du système,
- et enfin qu'elle contient certaines valeurs spécifiques
C'est un test de l'implémentation : je fixe un état de mon système, et je vérifie qu'à une valeur en entrée j'obtiens un résultat cohérent avec l'état dudit système. Je m'intéresse autant au résultat qu'à l'état lui-même, tout en sachant exactement où intervenir et comment interagir avec le système.
Tester le workflow
Mon second type de test s'intéresse aux comportements : l'état du système m'est inconnu, mais je sais néanmoins ce que j'attends de lui lors de mes interactions. Mon point de départ, ici, est la spécification.
Pour reprendre mon exemple précédent, je ne m'intéresse pas, dans ce genre de test, à savoir ce qui se passe lorsque je fais une requête POST /person/3
: je m'intéresse à ce qui se passe lorsque je veux créer un utilisateur : est-ce que je reçois la bonne réponse ? Est-ce que je peux manipuler l'API pour aller plus loin ? Que se passe-t-il sur un cas d'erreur ? Est-ce que cela correspond bien aux comportements attendus par l'utilisateur final ?
C'est à ces questions que je tente alors de répondre : je ne peux plus tricher avec l'état du système pendant le test, je ne peux pas non plus le vérifier autrement que par l'interface. C'est le fait que cette interface soit définie par des spécifications qui me permet d'effectuer mon test, et pas une connaissance de l'implémentation.
Voici l'exemple de scénario écrit en Gherkin qui correspond à ce genre de test :
Scenario: Create Person Workflow
Given there is no person yet
And I use test credential
When I create a new person with data:
| FIELDS | VALUES |
| givenName | Robert |
| familyName | Bosch |
| email | robert@bosch.com |
Then the response is OK (created)
And the response contains a person
And this person has a given name
And this person has a family name
But this person has an email
But this person does not have a telephone
When I retrieve this person's email
Then the response is OK
And the response contains this person's email
And this email is not verified
When I generate a verification token for this email
Then the response is OK (created)
And the response contains a verification token for this email
When I verify this verification token for this person
Then the response is OK
When I retrieve this person's email
Then this email is verified
Les différences notables sont :
- l'état initial est limité au maximum,
- aucune URL n'apparaît : les mots clés "create", "retrieve", "generate" correspondent aux verbes de la spécification,
- l'état interne du système n'est jamais vérifié : seul la sortie est vérifiée
- le système n'est manipulé que par son interface publique
Je tiens à souligner un aspect important dans ce genre de tests : j'utilise toujours et autant que possible les termes (noms, verbes, adjectifs, etc.) de la spécification dans mes tests. Si j'ai utilisé les mots "retrieve a person" dans la spécification, alors j'essaie d'utiliser "retrieve a person" dans mes tests : les mots ont un sens, et la précision avec laquelle nous les employons est l'une des clés de la compréhension.
Tester les cas de bugs
Les bugs n'existent pas. Les erreurs ne sont jamais comises. Nous sommes infaillibles, et tels sont nos systèmes à notre image : parfait et sans faille.
Mais juste au cas où voici à quoi ressemble l'un de mes tests qui pourrait vérifier un hypothétique cas de bug :
Scenario: To retrieve an inactive person result in 410 GONE and not a 404
Given I want to call "/person/3"
And there is an existing person "3" in database
But the person "3" is marked as inactive
When I perform a GET request
Then The response is gone
And the reponse has no body
Dans ce cas hypothétique, mon application aurait retourné une 404 alors que l'état du système lui indiquait devoir retourner une 410. Ce test permet de vérifier que ce bug de l'implémentatioin n'est plus là, qu'il a bel et bien été corrigé. Si jamais il devait réapparaître, ce test ne passerait pas, m'alertant alors, me jugeant faillible et imparfait. C'est là toute la dure réalité de notre métier.
Vous noterez que ce test s'appuie sur la même approche que le test d'implémentation : logique, pour un bug de l'implémentation ! Mais que se passe-t-il si ce bug a été détectée uniquement par l'usage - et donc par le comportement ?
Voici un exemple, j'espère qu'il sera assez lisible pour tout le monde :
Scenario: A person can not be retrieve after it has been deleted
Given there is no person yet
And I use test credential
When I create a new person with data:
| FIELDS | VALUES |
| givenName | Robert |
| familyName | Bosch |
| email | robert@bosch.com |
Then the response is OK (created)
And the reponse contains a person
When I retrieve this person
Then the response is ok
And the response contains the same person
When I delete this person
Then the response is OK (deleted)
But the response has no body
When I retrieve this person
Then the response is gone
And the response has no body
Ici, je vérifie que l'articulation suivante :
- création d'une personne,
- récupération de la personne,
- suppression de la personne,
- récupération impossible de la personne à partir de là,
fonctionne comme attendue par la spécification. Je m'intéresse moins au détail de l'implémentation, et beaucoup plus à la cohérence du comportement avec ce qui a été décidé : un utilisateur de l'API qui tomberait sur un bug n'aurait pas accès à l'état du système pour vérifier ce qu'il en est, il est donc plus juste de se mettre à sa place dans les tests aussi.
Que ce soit avec cet exemple ou le précédent, je vérifie que la réponse est belle et bien 410 GONE
, mais avec deux approches différentes. Sont-ce des tests unitaires ? Des tests d'intégrations ? Je n'ai pas envie de répondre à cette question, parce qu'elle n'a aucune valeur pour mon utilisateur final.
Le premier vérifie que l'implémentation n'a pas de bug, le second vérifie que l'interface répond bien à l'usage qui en est attendu par l'utilisateur. Le premier permet de garantir une bonne maintenance, et le second que mon utilisateur sera satisfait.
Et la couverture du code ?
Elle ne m'intéresse pas. Elle n'est pas inutile, car elle permet à un développeur d'identifier du code mort, ou du code qui n'est jamais testé, mais en soi elle n'apporte pas d'indicateur fiable sur la qualité au-delà d'une certaine couverture.
Le seuil de couverture à partir duquel on peut considérer que l'application est un minimum bien testée peut varier énormément d'une application à une autre, d'un domaine à un autre, mais ce n'est finalement pas le code qui compte.
Aujourd'hui, je ne connais pas d'outil qui me permette de vérifier intégralement que les spécifications soient bien testées à 100 % : si le code peut avoir des bugs, les spécifications (la documentation) ne sont pas exemptes d'erreurs non plus.
Ce qui compte, passé les détails, c'est de faire en sorte que nos tests couvrent au mieux toutes les fonctionnalités. Pour moi, cela passe par un mélange intelligent de tests de l'implémentation, du workflow, et par la correction de tous les bugs.
Écrivez de la doc et des tests
Ne vous battez pas autour de vos outils, c'est inutile. Certains préfèrent Clojure, d'autre Scala, quand d'autres ne jugeront que par Python, Java, ou Ruby. Quand certains ne comprendront que pytest ou nosetest, d'autres répondront par Mocha, Behave, Cucumber, ou bien QUnit. Vim vs Emacs. Eclipse vs tout le reste. Et je ne parle même pas des gens qui trouvent que PHP est un bon langage de programmation, en 2018. Ils ne peuvent pas être pris au sérieux. Nous avons tous nos outils, nous avons tous nos préférences, mais la question ne doit pas être "faut-il utiliser X ou Y ?".
Lorsque la question des tests arrive sur la table, ou lorsque la qualité des tests débarque sans prévenir dans une réunion, ne perdez pas de temps sur les détails d'implémentations, et posez les bonnes questions :
- demandez à quoi servent ces tests,
- remettez en question ce qu'ils testent,
- discutez de ce qu'il est important de tester,
- pourquoi est-ce que vous testez ? quel est votre contexte ? qu'est-ce qui en rend l'usage facile/compliqué ?
- cherchez à comprendre pourquoi certains sont plus simple à écrire, pourquoi d'autres sont impossibles à maintenir,
Mais ne perdez pas de temps à partager des GIFs rigolo pour démarrer une guerre de religion autour de pratiques qui n'apportent strictement rien à la qualité de notre travail.
Tant que vous y êtes, écrivez de la doc et des tests, ce sera toujours un bon début.