Aller au contenu principal
Lance-toi : 40% de réduction sur toutes les formations jusqu'au 30 juin

Tests fiables : pourquoi les tiens cassent au mauvais moment, et comment changer ça

Un test qui passe une fois sur deux n'est pas un test. Voici ce qui rend une suite de tests vraiment utile, et les pièges qui transforment ton dossier tests/ en zone de friction permanente.

Guides & tutoriels ·
Adel LATIBI
Adel LATIBI

Le Briefing Dev - les ressources et actus de la semaine, droit dans ta boîte chaque vendredi gratuitement.

Tu as déjà vécu cette scène. Un test qui passait hier échoue ce matin sans que personne n'ait touché au code concerné. Tu relances la pipeline, il repasse. Tu hausses les épaules, tu mergues. Trois semaines plus tard, le bug arrive en production.

Le problème, c'est qu'on les écrit pour cocher une case, pas pour gagner de la confiance dans le code. Et une suite de tests dans laquelle personne n'a confiance, c'est pire qu'une suite vide : elle donne l'illusion d'une sécurité qui n'existe pas.

Si tu es en reconversion, junior, ou que tu codes en solo sur tes projets, tu n'as probablement jamais reçu de vraie formation sur les tests. On te dit "il faut tester", on te montre un assertEquals(2, 1+1), puis on te laisse dans la nature.

Cet article te donne ce qu'il manque : les critères qui distinguent un test fiable d'un test décoratif, les principes à appliquer, des exemples qui marchent, et la liste des pièges qui te coûtent du temps sans que tu t'en aperçoives.

Le problème : tu as des tests, mais ils ne te protègent pas

Voici les symptômes qui reviennent dans presque tous les projets juniors et même chez beaucoup d'équipes confirmées.

Premier symptôme : tu n'oses pas refactorer. Quand tu modifies une fonction, dix tests cassent. Tu passes ton après-midi à les réparer un par un, sans savoir si tu corriges un vrai problème ou si tu maquilles une régression. Au final tu préfères ne plus toucher au code, donc le code pourrit.

Deuxième symptôme : les tests dépendent de l'ordre d'exécution. Lancés un par un ils passent, lancés ensemble certains échouent. Quelqu'un, quelque part, laisse traîner un état partagé entre deux scénarios.

Troisième symptôme : les tests passent, mais le bug est en production quand même. Tu testais ce que la fonction renvoie, pas ce que l'utilisateur fait avec.

Quatrième symptôme : la suite est tellement lente que personne ne la lance en local. On la subit sur la CI, on attend dix minutes pour savoir si on a cassé quelque chose. La boucle de rétroaction est morte.

Le principe : un test fiable est rapide, isolé, déterministe et lisible

Quatre critères. Si l'un manque, le test devient une dette. Prends le temps de les comprendre, c'est ce qui va structurer tout ce que tu écris ensuite.

Rapide

Un test unitaire doit s'exécuter en quelques millisecondes. Une suite complète de tests unitaires d'un projet moyen doit tourner en moins de dix secondes en local. Au-delà, tu vas la lancer une fois par jour, donc tu vas casser des choses entre deux exécutions sans le savoir.

La lenteur vient presque toujours d'une mauvaise frontière : tu testes "unitairement" du code qui touche une base de données, fait des requêtes HTTP, ou attend que des promesses se résolvent. Ce n'est plus un test unitaire, c'est un test d'intégration déguisé.

Isolé

Chaque test doit pouvoir tourner seul, dans n'importe quel ordre, sans dépendre du résultat d'un autre. Si le test B suppose que le test A a inséré une ligne en base, tu as déjà perdu.

L'isolation se travaille à deux niveaux : isoler les tests entre eux (chaque test crée ses propres données et les nettoie), et isoler la fonction testée de ses dépendances externes quand c'est pertinent.

Déterministe

Même entrée, même sortie. Toujours. Si ton test échoue une fois sur dix, ce n'est pas une malchance, c'est une faille. Les sources classiques de non-déterminisme : la date du jour, l'ordre des éléments dans une HashMap, l'aléatoire non maîtrisé, les délais réseau, les opérations parallèles non synchronisées.

Un test qui échoue parfois est l'ennemi numéro un de la confiance dans la suite. À la première occurrence, tu enquêtes, tu corriges, ou tu supprimes le test. Pas de mode "on relance et on verra".

Lisible

Le test est la première documentation du comportement attendu. Quand un développeur le lit six mois plus tard, il doit comprendre en dix secondes ce qui est testé et pourquoi.

La structure AAA aide énormément : Arrange (prépare les données), Act (exécute l'action), Assert (vérifie le résultat). Trois blocs séparés visuellement par une ligne vide. C'est bête, ça change tout.

Exemples : à quoi ressemble un bon test

Prenons un cas typique. Une fonction qui calcule le prix TTC à partir d'un prix HT et d'un taux de TVA.

// app/Pricing/PriceCalculator.php
class PriceCalculator
{
    public function computeTtc(float $ht, float $vatRate): float
    {
        if ($ht < 0) {
            throw new InvalidArgumentException('Prix HT négatif');
        }
        return round($ht * (1 + $vatRate), 2);
    }
}

Un test propre en PHPUnit ressemble à ça :

public function test_calcule_le_prix_ttc_avec_tva_a_20_pourcent(): void
{
    // Arrange
    $calculator = new PriceCalculator();

    // Act
    $result = $calculator->computeTtc(100.0, 0.20);

    // Assert
    $this->assertSame(120.0, $result);
}

public function test_rejette_un_prix_ht_negatif(): void
{
    $calculator = new PriceCalculator();

    $this->expectException(InvalidArgumentException::class);

    $calculator->computeTtc(-10.0, 0.20);
}

Quatre choses à remarquer. Le nom du test décrit le comportement attendu, pas le nom de la méthode. La structure AAA est explicite. Chaque test vérifie un seul comportement. Et si le test échoue, le message d'erreur te pointe directement la valeur attendue contre la valeur reçue.

Quand tu testes du code qui dépend d'une ressource externe, tu remplaces cette ressource par un double. Exemple avec un service qui envoie un email après une commande :

public function test_envoie_un_email_apres_la_commande(): void
{
    $mailer = $this->createMock(MailerInterface::class);
    $mailer->expects($this->once())
           ->method('send')
           ->with($this->callback(fn($email) => 
               $email->getSubject() === 'Confirmation de commande'
           ));

    $service = new OrderService($mailer);
    $service->placeOrder(new Order(id: 42));
}

Tu ne testes pas le mailer (ce n'est pas ton code), tu testes que ton OrderService l'utilise correctement. C'est cette frontière qui rend le test rapide, isolé et déterministe.

La pyramide des tests, en pratique

Tu as probablement déjà vu ce schéma : beaucoup de tests unitaires en bas, moins de tests d'intégration au milieu, très peu de tests end-to-end en haut. Ce n'est pas une mode, c'est une question de coût.

Un test unitaire coûte une milliseconde et te dit précisément où est le bug. Un test end-to-end coûte trente secondes, peut casser pour dix raisons différentes (réseau, base, UI, état du navigateur), et te dit juste "quelque chose ne marche pas".

Concrètement, pour un projet web typique : 70% de tests unitaires sur la logique métier pure, 20% de tests d'intégration sur les endpoints HTTP ou les requêtes en base, 10% de tests end-to-end sur les parcours utilisateurs critiques (inscription, paiement, action principale).

Si ton ratio est inversé, tu vas passer ta vie à attendre la CI et à débugger des tests fragiles. Si tu n'as que de l'unitaire, tu vas livrer des bugs d'intégration en production.

Pièges classiques à éviter

Tester l'implémentation au lieu du comportement

Le piège le plus fréquent. Tu vérifies que telle méthode privée est appelée, que telle variable interne vaut tel chose. Résultat : la moindre modification de structure casse trente tests, même si le comportement externe n'a pas bougé.

Règle simple : un test doit pouvoir survivre à un refactoring complet de la fonction tant que le comportement public ne change pas. Si ce n'est pas le cas, le test est trop intrusif.

Multiplier les assertions dans un seul test

Un test qui contient quinze assert et qui s'arrête à la troisième te masque les douze problèmes suivants. Tu corriges, tu relances, nouveau échec, et ainsi de suite.

Un test, un comportement. Si tu as besoin de vérifier cinq aspects différents, écris cinq tests.

Partager l'état entre les tests

Tu crées une instance partagée en début de classe pour "économiser du temps". Le test 1 modifie son état, le test 2 hérite de ce changement, et tu te retrouves avec une suite qui dépend de l'ordre d'exécution.

Utilise les méthodes setUp() / beforeEach() pour recréer l'état à chaque test. Le coût est négligeable, la robustesse est totale.

Ignorer les tests qui échouent

Le test est flaky, tu le marques @skip "en attendant". Six mois plus tard, ton dossier tests/ ressemble à un cimetière, et personne ne sait plus ce que la suite couvre vraiment.

Deux options seulement : tu répares, ou tu supprimes. Un test désactivé est pire qu'un test absent, parce qu'il pollue la lecture.

Viser 100% de couverture

La couverture mesure les lignes exécutées, pas le comportement testé. Tu peux atteindre 100% en faisant traverser ton code sans rien vérifier. C'est un indicateur trompeur quand on en fait une cible.

Vise une couverture significative sur la logique métier (souvent 80% suffit largement), et oublie-la sur le code trivial (getters, setters, mappers). Ton temps a plus de valeur ailleurs.

Écrire les tests après le bug

"Je teste ça après, quand le code marchera." En général, ça ne se fait jamais. Et quand un bug apparaît en production, tu corriges sans écrire le test de non-régression. Six mois plus tard, le même bug revient.

À chaque bug en production, le réflexe est : un test qui reproduit le bug, puis la correction. Ce test reste pour toujours dans la suite. C'est comme ça que la qualité augmente, pas autrement.

Les tests dans le pipeline, c'est non négociable

Une suite de tests fiable n'a de valeur que si elle tourne automatiquement à chaque commit. Sinon, tôt ou tard, quelqu'un mergera une régression sans s'en apercevoir.

Tu mets en place un workflow simple sur GitHub Actions : sur chaque push et chaque pull request, la pipeline installe les dépendances, lance les tests, refuse le merge si quelque chose échoue. C'est dix lignes de YAML, et ça change tout. Si tu débutes sur ce volet, l'article sur le principe Fail Fast donne le bon angle mental : détecter au plus tôt vaut toujours mieux que corriger en aval.

Garde aussi en tête que tes tests font partie du code. Quand tu modifies une fonction, tu mets ses tests à jour dans le même commit. La règle du scout s'applique : un test mal nommé que tu croises, tu le renommes en passant. Pas besoin d'une grande refonte, juste de l'hygiène continue.

Par où commencer si ta suite est déjà en mauvais état

Si tu hérites d'un projet avec une suite de tests pourrie, ne tente pas la grande purge. Tu vas y passer des semaines pour un résultat invisible.

Adopte plutôt cette progression. Premièrement, identifie les tests flaky et supprime-les. Mieux vaut zéro test que cinq tests qui mentent. Deuxièmement, ajoute des tests sur chaque nouvelle fonctionnalité, en suivant les quatre critères. Troisièmement, à chaque bug en production, écris le test de non-régression avant de corriger. Quatrièmement, à chaque fois que tu touches du code mal testé, ajoute un ou deux tests utiles avant ta modification.

En trois mois, sans grand projet d'envergure, la suite redevient utile. C'est plus lent qu'une réécriture complète, mais ça tient dans le temps et ça n'arrête pas la production de fonctionnalités.

Pour aller plus loin

Les tests sont indissociables des principes d'architecture. Plus ton code est mal structuré, plus tes tests sont douloureux à écrire. C'est même un bon signal : si tester une fonction est difficile, c'est souvent que la fonction a trop de responsabilités.

Si tu veux approfondir, l'article sur la single source of truth et celui sur la composition plutôt que l'héritage traitent de structures qui rendent les tests beaucoup plus simples à écrire.

Côté pratique, nos formations en Symfony 7, Spring Boot et FastAPI intègrent toutes un module de tests appliqué au framework concerné. Et pour la partie pipeline, la formation CI/CD avec GitHub Actions couvre l'intégration complète des tests dans un workflow automatisé.

Questions fréquentes

Faut-il écrire les tests avant ou après le code ?

Le test-driven development (écrire le test d'abord) est utile sur du code à la logique claire et bien isolée. Sur du code exploratoire où tu cherches encore la forme de la solution, écrire les tests après est souvent plus efficace. L'important n'est pas l'ordre, c'est que les tests existent dans le même commit que la fonctionnalité, pas trois semaines plus tard.

Combien de temps faut-il consacrer aux tests sur un projet ?

Sur un projet sérieux, le temps passé à écrire des tests représente environ 20 à 40% du temps de développement. Cela paraît énorme au début, mais c'est largement récupéré dès la deuxième vague de modifications, quand tu peux refactorer sans peur. Si tu n'as jamais écrit de tests, prévois plus au début, le temps de prendre l'habitude.

Quelle est la différence entre un mock, un stub et un fake ?

Un stub renvoie des valeurs prédéfinies quand on l'appelle, sans rien vérifier. Un mock fait pareil mais en plus il vérifie qu'on l'a appelé d'une certaine manière. Un fake est une implémentation simplifiée mais fonctionnelle, par exemple une base de données en mémoire au lieu de PostgreSQL. Tu utilises un stub pour fournir des données, un mock pour vérifier des interactions, un fake pour remplacer une dépendance lourde par une version rapide.

Mes tests passent en local mais échouent sur la CI, pourquoi ?

Trois causes habituelles. Une dépendance environnementale : ton test repose sur un fichier, une variable d'environnement, ou un service présent en local mais absent sur la CI. Un problème d'ordre d'exécution : la CI peut paralléliser ou ordonner différemment les tests. Un problème de fuseau horaire ou de locale : ta machine est en français, le runner CI en anglais, et tu compares une date formatée. Reproduis le problème en lançant la suite dans un container Docker propre, tu trouveras la cause en quelques minutes.

Faut-il tester le code frontend ?

Oui, mais pas n'importe comment. Sur React, Vue ou Angular, tu testes le comportement utilisateur (le clic sur ce bouton ouvre cette modale, ce formulaire validé envoie ces données) avec des outils comme Testing Library ou Vitest. Tu ne testes pas les détails d'implémentation des composants. Pour les parcours critiques de bout en bout, ajoute quelques tests Playwright ou Cypress. Le reste, la logique métier pure, se teste comme du code backend.

Quel framework de test choisir quand on débute ?

Choisis le framework standard de ton écosystème. PHPUnit ou Pest pour PHP, pytest pour Python, JUnit pour Java, Vitest ou Jest pour JavaScript. Évite les outils exotiques tant que tu apprends, l'écosystème principal a toujours plus de documentation, plus de réponses sur Stack Overflow, et plus d'intégration avec ton IDE.

Articles similaires

Tests et TDD : sortir de la peur de toucher au code qui marche

Tests et TDD : sortir de la peur de toucher au code qui marche

Tu as livré une fonctionnalité la semaine dernière. Aujourd'hui, on te demande une petite modification dessus. Et tu hésites. Pas parce que tu ne sais pas faire, mais parce que tu n'as aucun moyen de vérifier que tu ne casses rien d'autre. C'est exactement ce problème que les tests automatisés résolvent.

26/05/2026

Comprendre les index : pourquoi ta requête est lente

Comprendre les index : pourquoi ta requête est lente

Il arrive un moment, dans la vie d'une application, où une page qui s'affichait en un quart de seconde commence à en prendre plusieurs. Le code n'a pas bougé, le serveur non plus. Ce qui a changé, c'est la quantité de données en base. Si tu développes, tu as peut-être déjà croisé ce genre de situation. Une table qui comptait quelques centaines de lignes en contient maintenant des centaines de milliers, et tout se met à traîner. Le premier réflexe est souvent de soupçonner l'hébergeur, le framework ou la machine. La cause est pourtant souvent ailleurs. Pour répondre, la base lit la table entière, ligne par ligne, parce que rien ne lui indique où chercher. Plus il y a de lignes, plus ce travail s'allonge. Les index servent à régler ce problème. Cet article explique ce qu'ils font, pourquoi ils peuvent faire passer une requête de plusieurs secondes à quelques millisecondes, et comment les poser au bon endroit sans en abuser.

03/06/2026

SQL ou NoSQL : comment choisir sans se tromper sur son premier projet

SQL ou NoSQL : comment choisir sans se tromper sur son premier projet

Tu démarres un projet et la première vraie question technique tombe vite : quelle base de données ? Tu cherches, et tu tombes sur des avis qui se contredisent. Un tutoriel utilise MongoDB en trois lignes, le suivant jure par PostgreSQL, et un fil sur X transforme tout ça en guerre de tranchées.

01/06/2026

Vous êtes expert ?

Partagez votre expertise sur notre blog

Tutoriel, retour d'expérience, analyse - publiez un article invité et gagnez en visibilité.

Écrire pour nous