50 shades of tests

Tous les tests ne rentrent pas dans une seule pyramide de tests. Il faut envisager plusieurs aspects : finalité, granularité et modalité d'assertion.
Publié le 5 février 2025Dernière mise à jour le 7 octobre 2025
Voilà un moment que j'avais envie de faire cette dissertation sur les tests. Enfin, je parle de "dissertation" quand il s'agit en fait d'un recensement. La valeur que j'espère apporter au travers de cet article repose sur la catégorisation des tests en une tripartition finalité / granularité / type d'assertion.
Partons d'un exemple : on me demande souvent comment les tests d'acceptation se positionnent dans la pyramide de tests : ma réponse est "presque partout", parce que différents types de tests peuvent jouer ce rôle, du test unitaire jusqu'au test end-to-end. C'est donc que tous les tests ne se comparent pas, tous les tests ne s'excluent pas, si bien qu'il faut en envisager différentes facettes. Ces facettes, nous les appellerons "dimensions".
Ces dimensions (finalité / granularité / type d'assertion) sont presque indépendantes :
  • Au sein d'une même dimension, les alternatives s'excluent mutuellement : un test ne peut pas être tout à la fois déterministe et rentrer dans la catégorie du monkey testing (dimension "type d'assertion") ;
  • En revanche, vous pourriez très bien envisager un test de performance (type d'assertion) qui soit un test d'appel d'une API, donc end-to-end (granularité) et qui ait valeur de test d'acceptation (finalité).
Bien sûr, tout est dans le "presque", car il y a des contre-exemples. Cette classification n'en reste pas moins utile à mes yeux.
C'est parti, suivez le guide !

Dimension : finalité

C'est bien sûr par là qu'il faut commencer : tous les tests n'ont pas la même finalité. Certains visent à valider la conception, d'autres le développement, d'autres à éviter les régressions etc. Voyons cela en détail :
  • Les tests utilisateurs : ils ont lieu en amont du développement et servent à affiner la conception fonctionnelle et l'interface utilisateur (UI). Ils consistent à faire interagir un panel d'utilisateurs avec des wireframes ou bien, après quelques itérations, avec des maquettes haute fidélité et partiellement interactives. Lors de ces séances, souvent enregistrées, le testeur propose à l'utilisateur de réaliser un Job To Be Done (JTBD) et voit comment il s'en sort, quelles sont ses difficultés, ses incompréhensions et ses suggestions. J'évoque ce type de test par souci d'exhaustivité mais pour mieux le laisser de côté, car, quoique essentiels, ils ne relèvent pas du développement.
  • Les tests écrits dans le cadre du Test-driven development (TDD) : il s'agit de tests unitaires, qui servent à guider et faciliter le développement. Co-produit magnifique, ces tests ont valeur de documentation une fois les développements achevés, en même temps qu'ils contribuent à éviter les régressions. Mais leur valeur première s'exprime, on n'insistera jamais assez là-dessus, au moment du développement. D'ailleurs, tout est dans le titre ! Le Test-driven development  est donc une pratique de développement, que l'on ne saurait comparer à des pratiques telles que le Test-First et le Test-After qui, elles, relèvent du test.
  • Les tests d'acceptation ("acceptance" en anglais) : ces tests permettent de valider les développements au regard de la conception fonctionnelle. Ils s'entendent donc nécessairement dans le cadre du développement d'une nouvelle fonctionnalité et doivent être définis au moment de la conception – une évidence, me direz-vous, mais ça va mieux en le disant. Ils constituent ainsi une forme de contrat entre Product Owner et Développeur. L'Example Mapping  vous aidera dans cette pratique.
  • Les tests de non-régression : tout développement comporte un risque, le risque d'occasionner des régressions. En français : ce qui fonctionnait avant ne fonctionne plus. Évidemment, une bonne modularité  minimise ce risque à grande échelle en évitant les couplages inutiles, mais à petite échelle, là où la cohésion est forte, des tests sont le seul moyen de repérer d'éventuelles régressions. Les tests sont un filet de sécurité : plus le maillage est fin, moins vous risquez de passer au travers des mailles du filet et de chuter. D'où viennent les tests de non-régression ? La plupart des tests que nous évoquons ici jouent ce rôle, à commencer par les tests d'acceptation, mais également les tests unitaires, les tests d'intégration et les tests end-to-end.
  • Les tests d'un Golden Master : ils sont un cas particulier des tests de non-régression et s'entendent dans un contexte de refactoring . Impossible de figer le comportement par des tests unitaires, puisque ces derniers sont couplés à l'implémentation et que l'implémentation va changer. La seule façon de faire est de constituer une piste de logs exprimés en termes purement fonctionnels (par exemple : "Le solde du compte bancaire de Paul est de 1400€ au 31/10/2024"), appelée Golden Master. À chaque instant du refactoring, les logs générés par l'application doivent correspondrent au master, parce que si l'implémentation change, le métier, lui, ne change pas. En cours de route, d'autres types de tests doivent être écrits pour figer le comportement (tests unitaires, d'intégration etc.), et une fois le refactoring effectué, le master peut être supprimé.
  • Les tests exploratoires : ces tests visent à "casser" l'application. Hein ? Oui, mieux vaut que vous trouviez un bug avant qu'un utilisateur ne s'en charge. Ce type de test est l'apanage des QAs , dont l'esprit vicieux semble conçu pour trouver tous ces cas tordus 🫶 Je dis cela en plaisantant, car la valeur de ce travail est inestimable et demande en réalité une connaissance très fine du fonctionnement de l'application. Le test exploratoire, c'est tout sauf faire n'importe quoi. Le test exploratoire, c'est le fait de se placer à la croisée des spécifications : ces 2 sujets, que nous avons considérés comme indépendants, le sont-ils vraiment ? Le QA, parce qu'il est moins le nez dans le guidon que le reste de l'équipe, porte sur l'application un regard légèrement extérieur, transverse, qui lui permet d'aller plus loin dans la recherche d'anomalies. Précision importante : cette exploration est nécessairement manuelle. En revanche, si un bug est constaté, sa correction devra se traduire par un autre type de test qui, lui, devra être automatisé à terme.

Dimension : granularité

C'est la dimension que l'on évoque le plus souvent, au travers de la notion de pyramide de tests. Cette notion est essentielle parce qu'elle nous incite à utiliser toute l'étendue du spectre des tests dans l'objectif de tester de manière efficiente. La question sous-jacente est : "comment puis-je obtenir le maximum de sécurité en écrivant le moins de tests possibles ?"
  • Tout commence par les tests unitaires : les tests unitaires sont les tests qu'un développeur fait pour faciliter ou valider le développement d'une brique élémentaire du logiciel. Cette brique est à rapprocher d'une tâche technique, un développement qui n'apporte pas de valeur en tant que tel à l'utilisateur, mais qui n'en reste pas moins nécessaire. Ces tests sont donc effectués par le développeur pour lui-même, par opposition aux tests d'acceptation. Ils peuvent être écrits en Test-After, en Test-First ou bien dans le cadre d'une pratique de TDD. Ces tests travaillent à petite échelle et en isolation, ce qui implique parfois d'utiliser des mocks, ou plus généralement des tests doubles. De ce fait, ils sont rapides à écrire (quelques minutes), mais aussi rapides à exécuter (de l'ordre de 10ms). Ainsi, ils permettent d'explorer et de "figer" dans le détail la complexité cyclomatique d'un algorithme. Vous devez en écrire beaucoup, des centaines voire des milliers dès qu'il y a un peu de métier, raison pour laquelle ces tests constituent la base de la pyramide.
  • Tests de composants : à ranger avec les tests unitaires. La seule spécificité étant que l'on teste les composants d'une interface graphique et non des fonctions ou les méthodes d'une classe.
  • Les tests d'intégration : effectuer des tests unitaires est nécessaire, mais l'on ne saurait s'en satisfaire. Que 2 modules fonctionnent bien unitairement, en isolation, ne garantit pas en effet qu'ils soient correctement coordonnés. Pour cela, il faut des tests d'intégration, et tout test se trouvant au-dessus des tests unitaires dans la pyramide de tests joue nécessairement ce rôle. La question est de savoir quel équilibre trouver entre les 2 types de tests. Prenons pour cela un exemple : un module
    A
    possédant 2 chemins d'exécution,
    A1
    et
    A2
    , testés unitairement ; un module
    B
    possédant 3 chemins d'exécution,
    B1
    ,
    B2
    et
    B3
    , testés unitairement. En supposant que
    A
    appelle
    B
    , un test d'intégration se placera par exemple dans les cas
    A1/B1
    et
    A2/B2
    , parce que la façon d'appeler
    B
    sera différente dans les branches
    A1
    et
    A2
    . En revanche, seront laissés volontairement de côté tous les autres cas de la combinatoire (le produit cardinal des chemins d'exécution), à savoir
    A1/B2
    ,
    A1/B3
    ,
    A2/B1
    et
    A2/B3
    .
  • Les tests end-to-end (e2e) se situent quant à eux au sommet de la pyramide et constituent justement un cas particulier de tests d'intégration : ils reproduisent le comportement d'un utilisateur interagissant au travers de l'interface (interface graphique ou API), selon un scénario métier complet et en traversant le maximum de couches applicatives, de l'interface graphique jusqu'à la base de données. Ces tests sont très longs à écrire (parfois plusieurs heures pour un seul test), mais aussi longs à exécuter (plusieurs secondes par test). Vous devez donc les utiliser à bon escient , c'est-à-dire pour quelques chemins représentatifs (happy paths) ou critiques de l'application. Vous devez considérer les tests e2e comme des smoke tests, en application du proverbe selon lequel "il n'y a pas de fumée sans feu" : s'il y a de la fumée, c'est qu'il y a le feu quelque part dans l'application (condition nécessaire) ; mais pas de fumée n'implique pas qu'il n'y a pas de feu (condition non suffisante).

Dimension : type d'assertion

Dernière dimension, le type d'assertion. Autrement dit "qu'est-ce que je teste", et donc "quelle sécurité ce type de test m'apporte-t-il ?"
  • L'Example-based testing (a.k.a. test déterministe) : compte tenu de tel état de départ (Given) et de telle action (When), alors j'attends telle valeur de sortie ou tel état (Then). C'est le test que nous connaissons tous, néanmoins il n'offre que très peu de sécurité. Prenons un exemple : si vous implémentez la fonction affine
    y=1+x/2
    , vous pourriez prendre les points
    (0,1)
    et
    (2,2)
    comme cas de test et vous en satisfaire. C'est raisonnable, parce que vous savez que vous construisez une droite, mais théoriquement une infinité de fonctions passent par ces 2 points, à commencer par la parabole
    y=1+(x^2)/4
    . Si ces tests constituent un filet de sécurité, avouez que le filet a des trous !* En revanche, ces tests sont particulièrement utiles pour guider nos développements (TDD, encore une fois).
  • Le property-based testing nous offre un peu plus de sécurité, puisqu'il se propose de vérifier qu'un invariant métier est toujours vérifié pour des valeurs d'entrée prises au hasard et en grand nombre (10, 100, 1000 ?) Dans le cas qui nous occupe (
    y=1+x/2
    ), un invariant est le fait que pour 2 abscisses quelconques, les ordonnées étant calculées à partir de votre implémentation, la pente du segment reliant les 2 points ainsi formés vaut toujours
    1/2
    . Je vous propose une étude détaillée de cet exemple ici . Là encore, ce test ne vous offre pas de garantie absolue, vous n'aurez toujours pas la preuve que votre implémentation est bonne, mais la statistique rend l'erreur moins probable. Pour ce qui est du développement, nous pouvons nous en satisfaire !
  • Le snapshot testing : comme dans le property-based testing, vous ne spécifiez pas qu'une valeur ou un état est attendu en sortie du test. Non, plus simplement ici vous exécutez le test une première fois et stockez le résultat (snapshot), dont vous avez vérifié manuellement qu'il vous convenait (ex : le DOM virtuel d'un composant React). Les fois suivantes, à chaque fois que le test est joué, le résultat est comparé au snapshot. Si les deux sont identiques, le test passe, sinon le test est en échec. Cas d'usage : exclusivement pour de la non-régression.
  • Le mutation testing : tout part du constat que l'on peut facilement avoir une très bonne couverture de code sans que les tests associés ne constituent un vrai filet de sécurité anti-régressions. Autrement dit, la couverture en lignes de codes est bonne, mais toute la complexité cyclomatique n'est pas explorée. Un vrai sujet, que nous ne développerons pas ici. Le mutation testing permet d'identifier ces lacunes en introduisant aléatoirement des mutations dans le code. Par exemple,
    age > 18
    devient
    age > 10
    ou
    age <= 18
    , et si aucun test n'échoue, c'est que votre code n'est pas correctement testé. Très puissant et franchement meta : c'est en quelque sorte le test des tests.
  • Le monkey testing répond quant à lui à une problématique très spécifique : la bonne gestion des cas d'erreur, au travers notamment des contrôles de surface implémentés dans l'interface graphique. L'idée est que votre application devrait résister à une saisie totalement aléatoire et donc incohérente, supposément produite par un singe. Votre application doit rester dans un état stable en toutes circonstances et répondre proprement à l'utilisateur que sa saisie est invalide. D'expérience, je peux vous confirmer qu'un chat fait très bien l'affaire, et l'on me dit qu'un bébé humain fait également un travail très convenable. Dans les faits, c'est le genre de chose que l'on automatise, afin que les singes puissent vaquer tranquillement à d'autres occupations.
  • Les tests de performance : ici, c'est bien la rapidité d'exécution qui compte. Pour que la mesure soit significative, elle doit être considérée sur un grand nombre de "tirs", en percentiles. Par exemple,
    P50=100ms
    signifie que 50% des requêtes ont abouti en maximum 100ms, et
    P95=400ms
    signifie que 95% des requêtes ont abouti en maximum 400ms. Le délai de réponse est une exigence non-fonctionnelle clé, en particulier pour les API, parce qu'un tour de vis (-150ms sur le P95) peut vous amener à revoir complètement votre architecture.
  • Les smoke tests : nous les avons évoqués précédemment pour mentionner que les tests end-to-end sont des smoke tests. Plus généralement, on fait des smoke tests notamment lors du déploiement de l'application ou des services qui la composent. Tous les autres types de tests ont été joués sur les environnements précédents et vous ont donné suffisamment confiance pour passer en production, c'est à présent chose faite et vous n'avez plus qu'à vérifier que l'application est disponible et fonctionne "dans les grandes lignes". Le smoke test extrême, c'est l'affichage de la mire d'authentification lorsque vous voulez vérifier que le certificat HTTPS est bien pris en compte. Ça vous parle ?

Conclusion

Dernière remarque : tous les tests peuvent et doivent autant que possible être automatisés et joués sur la CI, à l'exception des tests utilisateurs et des tests exploratoires, pour lesquels cela n'aurait pas de sens. À cela 2 raisons :
  • Pour la reproductibilité (le "R" de l'acronyme F.I.R.S.T.) : qui dit "test manuel" dit "tôt ou tard une erreur dans l'exécution du test". Je n'ai pas pris les bonnes valeurs et le test passe (faux positif), ou bien j'ai pris les bonnes valeurs mais j'ai indiqué à tort que le test passe (faux positif également).
  • Pour la rapidité (le "F" de l'acronyme F.I.R.S.T.) : sans vouloir vous offenser, je doute que vous alliez plus vite que l'ordinateur 😉
Ensuite, savoir quels tests utiliser demande un peu d'expérience et beaucoup de pragmatisme. Le tout est d'éviter de tomber dans la loi de l'instrument , c'est-à-dire "je ne connais qu'un type de test et donc je ne fais que ça". Votre application ne se portera pas bien s'il n'y a que des tests unitaires. Elle ne se portera pas mieux s'il n'y a que des tests end-to-end.
(*) Scott Wlaschin illustre très bien la chose au travers de cette image : "Unit tests have been compared with shining a flashlight into a dark room in search of a monster. Shine the light into the room and then into all the scary corners. It doesn’t mean the room is monster free — just that the monster isn’t standing where you’ve shined your flashlight."