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é.