Menu
Sortir de l’enfer des tests : reprendre le contrôle de ses tests automatisés

Sortir de l’enfer des tests : reprendre le contrôle de ses tests automatisés

Par Jonathan GAFFIOT

Date de publication le 28.10.2026
Article actualisé le 28.10.2026

Temps de lecture : 15 minutes

L’enfer des tests est aussi pavé de bonnes intentions

La nécessité d’écrire des tests automatisés est maintenant bien établie chez les développeurs. Il s’agit du seul moyen de tester systématiquement et complètement, pour éviter les régressions ou tout simplement ne pas avancer à l’aveugle.

Mais une fois posé ce consensus, les ennuis commencent. Écrire les tests est considéré comme une corvée, souvent repoussée à la fin du projet, et qui n’attire pas les volontaires. Ensuite les tests sont trop souvent lents, fragiles, longs à maintenir, pas évidents à lancer, incomplets… et on finit par s’habituer à des rapports de test négatifs (« la CI est tout le temps rouge, mais c’est normal »).

On appelle ça la normalisation de la déviance, et ça peut conduire à la catastrophe, comme faire se crasher Challenger et Columbia.

Bon d’accord, quand les tests auto sont inutiles ou absents, plutôt qu’une navette c’est une app qui se crashe, mais le processus est le même.

Alors, comment reprendre le contrôle de ses tests auto ?

Étape 1 : Se poser les bonnes questions

L’objectif : la qualité logicielle, le moyen : investir dans un système de tests

Plus que les tests eux-mêmes, l’objectif est d’assurer la qualité et la maîtrise du code, un code qu’on peut donc livrer et faire évoluer en confiance. Les tests constituent un système logiciel, parallèle au projet principal, dont l’objectif est d’en assurer la qualité : fiabilité, répétabilité, maintenabilité, évolutivité.

Comme tout système logiciel, il demande du temps de développement et des compétences chères à acquérir. Ainsi, la suite de tests constitue un investissement pour le projet principal, qui doit donc en retirer un bénéfice sur la qualité. Il est donc crucial de définir ce qui est attendu des tests. Comme il est toujours possible d’ajouter des tests, il faut aussi définir les moyens alloués aux tests, qui permettront d’arbitrer plus tard entre ajouter des tests, ou ajouter des fonctionnalités.

Les tests automatisés : la ceinture de sécurité

Les tests automatisés, par opposition aux tests manuels, forment tout ou partie de la suite de tests, et sont lancés automatiquement et systématiquement, typiquement à chaque commit. Ainsi, on s’assure contre la plupart des régressions, et cette information est apportée rapidement au développeur. Il s’agit donc d’une partie centrale du processus d’intégration continue (CI), au point que « CI » désigne maintenant les pipelines de tests automatisés !

Certains tests restent le plus souvent manuels, par exemple les tests utilisateurs (dont tests d’ergonomie, tests A/B…), les tests de charge, les tests de performances, les tests de pénétration…
Les tests automatisés sont là pour apporter un certain niveau de confiance sur la qualité du code, afin d’atteindre les objectifs de qualité du projet, dans les moyens alloués. Jamais oubliés, ils préviennent les bugs et empêchent la qualité de dériver, mais sont souvent limités par la difficulté à automatiser.

Étape 2 : Repartir de la base : les outils qualité avant les tests

Les tests font partie de la boîte à outils pour atteindre l’objectif de qualité, mais ils ne sont pas seuls. Plusieurs couches successives d’outils sont nécessaires pour intercepter les différents problèmes de qualité.

Première couche : le formateur de code

La première couche est un formateur de code, qui permet d’uniformiser le code. Les avantages sont de faciliter la lecture du code (pour ceux qui lisent encore le code), de diminuer la charge du développeur (pour ceux qui l’écrivent encore), et de nettoyer les comparaisons de code de toutes les différences de caractères d’espacement, ce qui diminue la taille des Pull Request / Merge Request.

Deuxième couche : l’analyse statique et dynamique

La seconde couche est constituée de logiciels d’analyse, et en premier lieu des outils d’analyse statique. Ces derniers n’exécutent pas le code (d’où le nom de « statique ») mais cherchent des mauvaises pratiques en cherchant des motifs dans le code ou l'arbre syntaxique. Leur gros avantage est de détecter des bugs très rapidement et sans risque (le code ne tourne pas) et sans condition (pas besoin d’environnement de test ou autre).

On trouve aussi dans cette catégorie les compilateurs, surtout avec leurs warnings activés, et les analyseurs de types (pour les codes interprétés), qui permettent de s’assurer de la cohérence des types, toujours sans avoir exécuté une ligne du code.

Enfin, les analyseurs dynamiques vont étudier le code pendant son fonctionnement, souvent au prix d’une perte en performances (mémoire et CPU), pour chercher des erreurs logiques plus difficiles à détecter (typiquement les erreurs de mémoire).

Sur l’analyse statique appliquée à un référentiel de codage, voir aussi : MISRA C : une référence pour la sécurité du code.

Troisième couche : automatiser au plus près du développeur

La dernière couche est bien sûr d’automatiser tous les outils choisis, au plus près du développeur. Déjà, un lanceur comme Doit ou Just permet de lancer tous les outils de qualité choisis en une commande simple.

Ensuite, nous pouvons tirer parti des hooks de Git pour lancer automatiquement ces outils lors de certaines commandes, et typiquement lors du commit. Des utilitaires comme le fameux pre-commit ou le plus récent prek permettent de configurer facilement les hooks de Git.

Pour éviter de perdre le fil à attendre la fin de tests longs au moment du commit, n’utiliser lors du pre-commit que des outils dont le temps d’exécution est court (jusqu’à quelques secondes).

Pour la suite du pipeline, du linting au déploiement, voir : DevOps 2025 : GitOps avec ArgoCD et les nouvelles pratiques CI/CD.

Étape 3 : Nettoyer les tests : instables, trop longs, obsolètes

Continuons par nettoyer les tests existants, car on peut bien sûr supprimer des tests.
D’abord, un test non fiable (on dit aussi instable, ou flaky) doit être désactivé ou supprimé, parce qu’il n’apporte plus l’information dont l’équipe a besoin : que le test passe ou pas, on n’en sait pas plus sur la qualité du code testé. Ce test pourra être réactivé une fois stabilisé, ce qui suppose d’identifier la cause de l’instabilité. Les causes les plus probables sont :

  • le tirage de nombre aléatoire
  • le déclenchement à partir de l’heure réelle
  • la (non-)synchronisation de code exécuté en parallèle (multi threading, asynchrone…)
  • la dépendance à des processus ou des API externes.

Par exemple, ce test échoue de façon imprévisible, car il dépend de l’heure réelle :

    # Fragile : dépend de l'horloge système... et reste bloqué une heure !
def test_reporting_each_hour():
    manager = Manager()            # crée un rapport à chaque heure pile
    assert manager.report is None  # faux si le test est lancé à une heure pile
    time.sleep(3600)               # c'est long...
    assert manager.report is not None
La solution est d’injecter l’horloge pour la contrôler depuis le test, ce qui le rend à la fois fiable et instantané :
    # Fiable : le temps est injecté, donc maîtrisé
def test_reporting_each_hour():
    clock = FakeClock(now=datetime(2025, 1, 1, 12, 1))
    manager = Manager(clock)           # refacto de Manager
    assert manager.report is None      # démarrage 1 minute après midi : pas de rapport
    clock.advance(timedelta(hours=1))  # on avance le temps sans attendre
    assert manager.report is not None  # nouveau rapport

Ensuite, les tests trop longs n’apportent pas l’information à temps aux développeurs. Ils peuvent être supprimés, accélérés, ou séparés dans une seconde suite de tests si le test est nécessaire et le temps d’exécution vraiment incompressible.

Cette seconde suite de tests sera toujours automatisée, mais pas lancée à chaque commit, à une fréquence plus faible, comme une fois par jour (souvent la nuit), ou seulement juste avant fusion sur la branche principale. L’important est que le développeur n’ait pas besoin du résultat immédiat du test pour continuer à travailler, mais que l’information apportée par cette suite de tests longs ne soit nécessaire par exemple que pour une nouvelle version.

Enfin, il ne faut pas hésiter à élaguer la suite de tests : certains tests ne sont pas pertinents, parce que trop vieux, trop spécifiques, trop couplés ou qui testent trop peu de code.

Dans la suite, plutôt que d'utiliser la typologie de la pyramide de test (unitaire, intégration...) dont la définition ne fait pas consensus, nous allons plutôt penser nos tests selon deux axes : isolation (code testé plus ou moins séparé de son environnement, donc avec plus ou moins d'effets de bord), et quantité de code testé.

Le test souvent considéré comme unitaire est celui qui teste peu de code tout en étant isolé, et le test dit "end to end" testerait tout le code sans isolation. Mais on peut aussi tester tout le code complètement isolé, comme on peut tester une seule fonction mais reliée à une vraie API, et tous les cas intermédiaires.

Des tests automatisés fiables sont aussi une condition des stratégies de branche les plus rapides : voir Stratégie de branching Git : pourquoi elle doit refléter votre produit.

Étape 4 : Rendre le code testable : isoler les effets de bord

Les problèmes de test les plus sérieux sont le plus souvent dus au code à tester : trop couplé et pas assez observable, il oblige le test à mettre en place tout un environnement, à lancer tout le code d’un coup, à jouer tout un scénario complexe pour atteindre l’état initial dans lequel le test pourra enfin être lancé, et à passer par des moyens détournés ou fragiles (analyse des logs…) pour savoir si le test a réussi.

Isoler les entrées/sorties de la logique métier

La première chose est d’isoler au maximum les effets de bord, en particulier les entrées/sorties, pour pouvoir tester le code spécifique au projet et la logique métier indépendamment. Pour tous les projets qui ne sont pas de simples « passe-plats », un maximum de code doit être testable sans préparation particulière, juste en l’exécutant.

Trous noirs, fontaines blanches et le reste : trier les effets de bord

Trions ensuite les effets de bord en 3 catégories : trous noirs, fontaines blanches, et le reste.
Un trou noir est en écriture seule, on ne peut qu’y jeter des données qui sont immédiatement perdues. On trouve dans cette catégorie les systèmes de log et de télémétrie.

Une fontaine blanche, l’inverse théorique d’un trou noir, est en lecture seule et ne peut être qu’initialisée, une seule fois. On trouve dans cette catégorie le système de configuration, qui réconcilie variables d’environnement, fichiers, ligne de commande… et met le résultat à disposition du reste du code.

Trous noirs et fontaines blanches peuvent avoir une durée de vie équivalente à celle du programme entier et une portée globale. Ce sont les seuls effets de bord qu’on peut tolérer à travers toute la base de code. Bien identifiés, une mise en place commune à tous les tests permettra de les gérer une fois pour toutes.

Injecter les dépendances plutôt que les détenir

Tous les autres effets de bord doivent être isolés, et si possible injectés dans le code métier propre à leur utilisation, plutôt que détenus par le code métier. En effet, une dépendance injectée donne toute liberté au test pour déconnecter l’effet de bord et isoler le code, alors qu’un couplage fort rend le test difficile, surtout avec les langages statiques.

Avec ces langages, il faut par contre prévoir un type approprié pour les membres de classes qui seront remplacés par un substitut. La testabilité doit alors être prévue à la conception.

    // Difficile à tester, instancie toujours une vraie connexion pas substituable
class MyClassWithInternalConnection {
private:
    Client client;
public:
    MyClassWithInternalConnection(const char* host, int port): client(host, port) {}
};
// Facile à tester, peut instancier un substitut comme une vraie connexion. Mais plus
// complexe avec un langage statique, propriété et cycle de vie du client à définir.
class MyClassWithInvertedDependency {
private:
    // Nouveau type pour contenir la vraie connexion ou un substitut
    AbstractClient& client;
public:
    // Dans cet exemple l'instance de classe ne prend *pas* la propriété du client
    MyClassWithInvertedDependency(AbstractClient& client_): client(client_) {}
};

Enfin, travailler sur la modularité du code, en s'astreignant à faire des morceaux de code au rôle bien défini et à la taille raisonnable à toutes les échelles (fonction, classe, fichier, module, lib...), permet de valider séparément et de diminuer les cas à tester, au prix de plus d'interfaces à tester.

Ainsi il sera facile de tester la logique métier en isolation, comme de tester de la manière appropriée les effets de bords.

Pour une approche d’architecture qui pousse cette logique jusqu’au bout, voir : Une approche différente de l’architecture hexagonale.

Étape 5 : Prendre en main son framework de test

Tous les principaux langages ont un ou plusieurs frameworks de test, qui apportent leur lot de fonctionnalités en plus de faciliter l’écriture des tests, comme :

  • lancement depuis un point unique 
  • organisation en suite de tests, suite de suites… 
  • gestion de l’environnement, de l’OS, des signaux 
  • fichiers et dossiers temporaires 
  • interception de l’entrée standard et des sorties standards 
  • rassemblement et structuration des résultats des tests 
  • setup/teardown : une paire de fonctions spéciales pour respectivement créer et détruire l’état initial d’un test 
  • fixture : une fonction qui « fixe » l’état initial pour un test (généralisation du setup) 
  • assertions pour comparer des résultats à des valeurs attendues, ou vérifier un comportement attendu (exception, appel de fonction…)
  • injection de substitut ou mock
  • parallélisation, plugins…

Utiliser un framework de test apporte donc énormément, au prix du temps vite rentabilisé de prise en main du framework.

Qu’est-ce qu’un substitut (mock) ?

L’injection de substitut consiste à remplacer sélectivement du code difficile à tester (souvent avec un effet de bord) par du code propre au test, en réimplémentant l’interface du code original. Elle mérite un point d’attention. Par exemple, un objet de connexion à une base de données peut être remplacé par un objet ne faisant rien, en renvoyant immédiatement une valeur fixée.

Le substitut peut être développé spécifiquement pour émuler un comportement complexe ou aléatoire, mais avec un retour instantané et répétable. Il peut aussi être complètement vide, mais permettre de vérifier que l’interface a été appelée, avec quels arguments…

Certains frameworks de langages dynamiques permettent même de créer des substituts sans déclarer l’interface du tout, l’interface étant créée à la volée, pour juste débrancher un effet de bord. De nombreux plugins ou librairies proposent des substituts clés en main pour les cas les plus courants : connexion, temps, aléatoire, base de données, logs…

Vingt cas pratiques de substituts en Python, du temps à l’aléatoire en passant par les fichiers : Tout comprendre des mocks Python.

Structurer chaque test en given/when/then

En combinant ces fonctionnalités, un test reste court et lisible. La structure given/when/then (« étant donné / quand / alors », ou encore arrange/act/assert) sépare nettement la préparation, l’action testée et la vérification, ce qui en fait une documentation exécutable :

    def test_alerte_envoyee_si_solde_negatif():
    # given : un compte à découvert et un service d'envoi de mail substitué
    mailer = FakeMailer()
    compte = Compte(solde=-50, mailer=mailer)

    # when : on déclenche la vérification du découvert
    compte.verifier_decouvert()

    # then : une alerte, et une seule, a été envoyée au titulaire
    assert mailer.envois == [("alerte_decouvert", compte.titulaire)]

Ici le substitut FakeMailer n’envoie aucun vrai mail : il se contente d’enregistrer les appels, ce qui permet d’affirmer que l’alerte a bien été déclenchée, et une seule fois, sans dépendre d’un serveur de messagerie.

Étape 6 : Accélérer les tests : les leviers

De nombreuses pistes peuvent permettre d’accélérer les tests :

  1. Modulariser le code et éventuellement le séparer en plusieurs librairies. Ainsi, il y a moins de code, d’entrées, de cas limites à tester, et chaque librairie est validée indépendamment.
  2. Préparer les artefacts (libs, exécutables, images…) pour le test (et la compilation si nécessaire) pour qu’ils soient prêts à l’emploi et en cache. Utiliser une lib précompilée est plus rapide que la recompiler avec tout le projet. Si un test a besoin d’un conteneur, l’image doit être disponible en cache et prête à l’emploi dès que le conteneur est lancé (pas d’entrypoint qui finit la mise en place par exemple).
  3. Profiler ses tests, pour d’abord avoir le temps par test, et ensuite savoir où les tests longs passent leur temps. Cette étape est essentielle pour ne pas avancer à l’aveugle.
  4. Paralléliser les tests (mais les workers de CI peuvent n’avoir qu’un seul cœur).
  5. Factoriser la mise en place et le nettoyage entre plusieurs tests.
  6. Éviter d’attendre pendant un test (sleep(…)), à la place réagir quand l’action est terminée. Il faut parfois modifier le code pour qu’il reporte son état (code de retour, log, variable interne…) ou permette au test de l’obtenir avec une nouvelle API.
  7. Réduire la quantité de valeurs différentes testées, pour se concentrer sur les valeurs attendues et les cas limites.
  8. Focaliser les tests sur une séquence claire given/when/then, en évitant d’enchaîner trop d’actions
  9. Optimiser le code dont le test est irréductiblement long. Parfois le problème vient du code.
  10. Activer sélectivement certaines options qui ralentissent les tests, comme la mesure de la couverture ou la vérification de l’intégrité de la mémoire.
  11. Lancer sélectivement les tests selon le code modifié, facile quand le code est bien structuré et modulaire.

Exemple : remplacer une attente fixe par une attente conditionnelle

Le remplacement d’une attente fixe par une attente conditionnelle illustre bien ce gain : au lieu de dormir « assez longtemps » en espérant que l’action soit terminée (lent, et fragile si la machine est chargée), on rend la main dès que la condition est remplie.

    # Lent et fragile : on attend une durée arbitraire
start_job()
time.sleep(5)
assert result_file.exists()

# Rapide et fiable : on réagit dès que la condition est vraie
job = start_job()
wait_until(job.is_done, timeout=5)   # sort dès que c'est prêt
assert result_file.exists()

Étape 7 : Compléter les tests : couverture et entrées/sorties

Mesurer la couverture de test

Une fois les tests existants remis en ordre de marche, il faut vérifier que la suite de tests atteint son objectif, et en particulier qu’il ne reste pas de gros trou dans la couverture de test. Si ce n’est pas déjà fait, il faut ajouter la mesure de la couverture à son framework. Attention, mesurer la couverture a tendance à ralentir les tests, parfois beaucoup, et il faut alors prévoir une option pour l’activer sélectivement.

Analyser la couverture en trois temps

  1. Homogénéité : la couverture de test doit déjà être à peu près homogène sur la base de code, et donc la première chose à rechercher est une partie du code non testée (fichier complet, classe, fonction…).
  2. Logique métier : ensuite, la couverture doit permettre de vérifier que la logique métier (sans effets de bord) est très bien testée, avec une couverture tendant vers 100 %.
  3. Objectifs et ressources : enfin, la couverture doit être analysée par rapport aux objectifs et ressources. Est-ce que le code non testé peut l’être facilement ? Est-il réutilisé massivement dans toute la base de code ? Est-il critique ? Est-ce de la logique métier ?

Tester les entrées/sorties autrement qu’avec la couverture

Une fois la couverture analysée, c’est le moment de revenir sur les tests des entrées/sorties, dont la couverture de test n’est pas la métrique pertinente. Il est en effet facile de les couvrir par des tests qui déconnectent tellement d’effets de bord que le test n’apporte plus beaucoup de garantie. Il faut plutôt vérifier que les cas normaux, limites et d’erreurs sont testés, vérifier que le lien avec le reste du système est réellement testé à un moment ou un autre (connexion à l’API, base de données…), ou envisager un contrat de communication à valider (OpenAPI par exemple). Il s'agit d'un exercice difficile, où on cherche à tester de moins en moins isolé, parfois en plusieurs étapes, par exemple pour une base de donnée : test avec substitut, puis avec un conteneur, puis avec une base de test, et même parfois en lecture seule sur la base de production.

Quel taux de couverture de test viser ?

Il n’y a pas de chiffre précis de taux de couverture à atteindre, surtout qu’il est relativement facile de biaiser ce chiffre en déclarant des lignes hors couverture ou en abusant des substituts. De plus, les derniers pourcents sont bien plus difficiles à atteindre que les premiers. Un chiffre de 80 % est une bonne base de réflexion, les projets de qualité étant au-dessus (Google donne une estimation similaire).

Il est plus important de suivre la tendance : au fur et à mesure que le projet évolue, la couverture ne doit pas reculer, mais monter (même lentement) au fur et à mesure que des bugs sont corrigés et donc des tests ajoutés. Ajouter à ses pipelines de test la vérification que la couverture ne recule pas empêche en particulier d’ajouter des fonctionnalités sans leurs tests.

Étape 8 : utiliser l’IA à bon escient pour écrire ses tests

Pour toutes ces tâches, plutôt rébarbatives et vues comme une perte de temps par rapport à l’avancée du projet, l’IA est un auxiliaire précieux. Il peut être très tentant de déléguer tout le problème à l’IA (et les derniers modèles feront un bon travail).

Mais les tests sont aussi le système qui garantit le fonctionnement du code, et qui permet au développeur d’engager sa responsabilité sur le code livré.

Est-ce que le test généré teste vraiment les points importants ? Ou est-ce qu’il s’agit juste d’un spaghetti de substituts qui ne teste en fait rien du tout ? Dans tous les cas la responsabilité est sur les épaules du développeur.

Selon les développeurs et les projets, plusieurs stratégies sont possibles :

  • générer mais relire soigneusement
  • travailler par étape à partir des spécifications
  • écrire manuellement les tests, ou au moins les tests principaux
  • utiliser une IA vérificatrice après la génération…

Par-delà l’appropriation des tests générés par le développeur, l’IA est surtout une opportunité en or d’aller plus loin dans les tests : test de performances et optimisation, test d’interface, fuzz testing, mutation testing… Autant de stratégies de test avancées qui deviennent accessibles à tous les projets.

Conclusion : du test-corvée au test écrit d’abord

Une fois la technique réglée, il reste à changer ses habitudes de travail, personnelles et d'équipe, et monter son niveau d'exigence. Par exemple, écrire ou mettre à jour ses tests spontanément lors de l'implémentation d'une fonctionnalité, ne pas tolérer que la CI reste rouge, rester attentif aux dérives qui avaient conduit à un enfer des tests et traiter les problèmes dès qu'ils se manifestent...

Une fois repris en main, les tests apportent la confiance indispensable pour avancer, et deviennent un outil de développement qui permet de livrer vite et bien. À ce moment, l’équipe a acquis toute une gamme de nouvelles compétences, de la prise en main du framework à l’optimisation, de la refactorisation du code à l’ajout des tests manquants, qui rendent l’écriture de nouveaux tests banale.

Devenu un outil du quotidien, le test peut être écrit d’abord, dans certains cas en partant des spécifications, faisant d’une corvée repoussée en fin de projet une étape préparatoire qui signalera la fin du développement et garantira la qualité.

À propos de l’auteur

Basé à l'agence de Grenoble, Jonathan GAFFIOT est développeur expert, spécialiste IA et intervient depuis 3 ans sur la conception, l'évolution et l'industrialisation de systèmes intégrant de l'IA, ainsi que sur le développement logiciel avec IA.

Retour aux articles

C'est à lire...