Blog // Exirel.me

Fantastic Tests

Par Florian Strzelecki - 18:25 - 13.04.2018

Tags : Programmation, Bonne pratique, Développement, Unit Testing, Tests

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 :

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 :

  1. écrire des spécifications techniques de l'interface,
  2. écrire les tests,
  3. é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 :

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 :

Ensuite, j'effectue l'appel, et je vérifie :

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 :

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 :

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 :

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.