Tester son code
Modifier son code sans avoir peur, et ne jamais corriger deux fois le même bug.
Tu testes déjà ton code : tu recharges la page, tu remplis le formulaire, tu ajoutes un dump(). Le problème, c'est que ces vérifications disparaissent dès que tu passes à autre chose. Trois mois plus tard, quand tu modifies le calcul des frais de port, personne ne refait les quinze vérifications d'origine. Et le bug revient.
Un test automatisé, c'est du code qui vérifie ton code. Il s'écrit une fois et se relance en quelques secondes, aussi souvent que tu veux. C'est ce qui te permet de modifier un projet sans avoir peur de casser quelque chose sans le voir.
1.Un test, c'est quoi ?
Sans aucun outil, un test est une simple comparaison : « j'attendais ceci, j'ai obtenu cela ». Voici une fonction de dix lignes qui fait ce travail :
<?php
declare(strict_types=1);
// Un mini « framework de test » : compare le résultat attendu au résultat obtenu
function check(string $description, mixed $expected, mixed $actual): void
{
if ($expected === $actual) {
echo "OK $description\n";
return;
}
printf(
"ÉCHEC %s (attendu %s, obtenu %s)\n",
$description,
var_export($expected, true),
var_export($actual, true),
);
}Testons la fonction de frais de port de la fiche 2 :
<?php
declare(strict_types=1);
require __DIR__ . '/check.php';
const FREE_SHIPPING_FROM_CENTS = 5000;
const SHIPPING_FEE_CENTS = 499;
// Le code à tester (repris de la fiche 2)
function shippingFeeInCents(int $totalInCents): int
{
return $totalInCents >= FREE_SHIPPING_FROM_CENTS ? 0 : SHIPPING_FEE_CENTS;
}
// Les tests : préparer, agir, vérifier
check('frais de port sous le seuil', 499, shippingFeeInCents(1000));
check('gratuit au seuil exact', 0, shippingFeeInCents(5000));
check('gratuit au-dessus du seuil', 0, shippingFeeInCents(12000));OK frais de port sous le seuil OK gratuit au seuil exact OK gratuit au-dessus du seuil
Chaque test suit trois étapes, qu'on appelle souvent AAA (Arrange, Act, Assert) :
- Préparer les données de départ (ici, un total de 1 000 centimes).
- Agir : appeler le code testé, une seule fois.
- Vérifier que le résultat est celui attendu. Cette vérification s'appelle une assertion.
2.Passer à PHPUnit
Notre fonction check suffit pour comprendre le principe, mais elle ne trouve pas les tests toute seule, ne donne pas de résumé et ne sait pas isoler un test qui plante. PHPUnit fait tout ça : c'est l'outil de test standard en PHP, et Symfony l'utilise.
- Dans un projet Symfony, installe les outils de test :
composer require --dev symfony/test-pack. - Range les tests dans le dossier
tests/, en reprenant l'arborescence desrc/: la classesrc/Shipping/ShippingCalculator.phpest testée partests/Shipping/ShippingCalculatorTest.php. - Lance-les avec
php bin/phpunit(ouvendor/bin/phpunit, selon le projet).
PHPUnit affiche un point par test réussi et un F par test en échec. Il termine par un résumé : OK (6 tests, 6 assertions) si tout passe, ou FAILURES! suivi du détail de chaque échec, avec la valeur attendue et la valeur obtenue.
PHPUnit n'est pas installé dans l'environnement de ces fiches. Les classes de test marquées « illustration, non exécuté » ont été relues et leur syntaxe vérifiée, mais pas exécutées. Les scripts suivis d'une sortie, eux, ont bien été exécutés.
3.Unitaire, fonctionnel ou de bout en bout
Le test unitaire
Un test unitaire vérifie un petit morceau de code isolé, en général une classe, sans base de données ni réseau. Il tourne en quelques millisecondes.
<?php
declare(strict_types=1);
namespace App\Tests\Shipping;
use App\Shipping\ShippingCalculator;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
final class ShippingCalculatorTest extends TestCase
{
#[DataProvider('shippingCases')]
public function testShippingFeeDependsOnTotal(
int $totalInCents,
int $expectedFeeInCents,
): void {
$calculator = new ShippingCalculator(); // préparer
$fee = $calculator->feeInCents($totalInCents); // agir
self::assertSame($expectedFeeInCents, $fee); // vérifier
}
/** @return iterable<string, array{int, int}> */
public static function shippingCases(): iterable
{
yield 'panier à 0 €' => [0, 499];
yield 'juste sous le seuil' => [4999, 499];
yield 'seuil exact' => [5000, 0];
yield 'juste au-dessus' => [5001, 0];
yield 'grosse commande' => [1_000_000, 0];
}
public function testNegativeTotalIsRejected(): void
{
$this->expectException(\InvalidArgumentException::class);
(new ShippingCalculator())->feeInCents(-100);
}
}Quelques points à remarquer :
- La classe hérite de
TestCase, et chaque méthode qui commence partestest un test. assertSamecompare avec===: valeur et type. Préfère-le àassertEquals, qui compare avec==(fiche 2).expectExceptionvérifie qu'une erreur est bien levée. Refuser un total négatif est un comportement à tester comme un autre.- Le nom de la méthode décrit le comportement attendu, comme une phrase. Quand un test échoue, son nom dit déjà ce qui est cassé.
Le test fonctionnel
Un test fonctionnel vérifie l'application de l'extérieur, comme le ferait un navigateur : il envoie une requête HTTP et regarde la réponse. Symfony fournit WebTestCase pour ça.
<?php
declare(strict_types=1);
namespace App\Tests\Controller;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
final class NewsletterControllerTest extends WebTestCase
{
public function testSubscriptionPageShowsAnAccessibleForm(): void
{
$client = static::createClient();
$client->request('GET', '/newsletter');
self::assertResponseIsSuccessful();
self::assertSelectorExists('label[for="newsletter-email"]');
}
public function testAnonymousVisitorIsSentToLogin(): void
{
$client = static::createClient();
$client->request('GET', '/admin');
self::assertResponseRedirects('/login');
}
}Ce test traverse tout : routage, contrôleur, sécurité, Twig. Il est plus lent, mais il vérifie que les morceaux fonctionnent ensemble. Le second test vérifie une règle de sécurité : un visiteur anonyme ne doit pas voir l'administration.
Attention à une limite : le client de WebTestCase n'est pas un vrai navigateur. Il lit le HTML renvoyé par le serveur, mais n'exécute pas le JavaScript. Un bouton qui fonctionne grâce à JavaScript ne peut pas être vérifié ainsi.
Entre le test unitaire et le test fonctionnel, Symfony propose aussi KernelTestCase pour les tests d'intégration : ils utilisent les vrais services du projet, par exemple un repository Doctrine avec une vraie base de test, sans passer par HTTP.
Le test de bout en bout
Un test de bout en bout (en anglais end-to-end, souvent abrégé E2E) pilote un vrai navigateur, comme le ferait un utilisateur : il ouvre la page, clique, remplit les champs, et JavaScript s'exécute. Il vérifie un parcours complet, du clic jusqu'à la base de données.
Dans Symfony, l'outil prévu est Panther : il pilote Chrome ou Firefox, et s'écrit comme un test fonctionnel.
<?php
declare(strict_types=1);
namespace App\Tests\EndToEnd;
use Symfony\Component\Panther\PantherTestCase;
final class CheckoutTest extends PantherTestCase
{
public function testCustomerCanAddAProductToTheCart(): void
{
$client = static::createPantherClient(); // un vrai navigateur, sans écran
$client->request('GET', '/produits/clavier');
$client->clickLink('Ajouter au panier'); // le clic déclenche du JavaScript
// On attend que la page réagisse, au lieu d'un sleep() au hasard
$client->waitForElementToContain('.cart-count', '1');
self::assertSelectorTextContains('.cart-count', '1');
}
}- Installe-le avec
composer require --dev symfony/panther. Il a aussi besoin d'un navigateur et de son pilote (ChromeDriver pour Chrome, geckodriver pour Firefox) : suis la page d'installation de sa documentation. - Une page qui réagit à JavaScript ne change pas tout de suite. Attends un élément précis (
waitFor,waitForElementToContain) plutôt qu'unsleep(): trop court, le test échoue au hasard, trop long, il ralentit toute la suite. - D'autres outils font le même travail, écrits en JavaScript ou en TypeScript : Playwright et Cypress. Ils sont fréquents quand une équipe front gère ses propres tests.
Utile pour une poignée de parcours critiques, ceux qui coûtent cher s'ils cassent : inscription, connexion, commande, paiement. Et pour tout ce qui dépend de JavaScript.
À limiter pour tout le reste : un test de bout en bout prend plusieurs secondes, demande un navigateur installé, et casse plus souvent pour de mauvaises raisons (un délai, une animation, un texte de bouton modifié). Si une règle peut être vérifiée par un test unitaire, vérifie-la là.
| Unitaire | Fonctionnel | De bout en bout | |
|---|---|---|---|
| Ce qu'il vérifie | Une classe, une règle de calcul | Une page, une règle d'accès | Un parcours complet dans un vrai navigateur |
| JavaScript | Non concerné | Non exécuté | Exécuté |
| Vitesse | Quelques millisecondes | Dixièmes de seconde | Plusieurs secondes |
| Quand il échoue | Il pointe la classe fautive | Il faut chercher où ça casse | Tout peut être en cause, y compris un délai |
| Combien en écrire | Beaucoup | Pour les pages et les accès importants | Une poignée, pour les parcours critiques |
On représente souvent cette répartition par une pyramide des tests : une large base de tests unitaires, un étage de tests fonctionnels et d'intégration, et une petite pointe de tests de bout en bout.
4.Quoi tester, et quoi ne pas tester
Tu n'auras jamais le temps de tout tester, et ce n'est pas le but. Teste en priorité, dans cet ordre :
- Les règles métier : prix, remises, droits, états d'une commande. C'est ce qui coûte de l'argent quand c'est faux.
- Les règles de sécurité : qui a le droit de voir, modifier, supprimer.
- Chaque bug corrigé : un test qui empêche son retour (section 7).
- Le code que tu as peur de modifier : c'est le signe qu'il lui manque des tests.
- Les getters et setters sans logique : le test ne ferait que répéter le code.
- Le code de Symfony ou de Doctrine : il est déjà testé par leurs équipes. Teste ton usage, pas leur fonctionnement.
- Les méthodes privées directement : teste-les à travers la méthode publique qui les utilise. Sinon, chaque refactoring casse tes tests alors que le comportement n'a pas changé.
Le taux de couverture (le pourcentage de lignes exécutées par les tests) est un indicateur, pas un objectif. 100 % de couverture avec de mauvaises assertions ne protège de rien.
5.Les data providers
Dans ShippingCalculatorTest, un seul test vérifie cinq cas. C'est le rôle d'un data provider : une méthode statique qui fournit des jeux de données, et PHPUnit exécute le test une fois par jeu.
- L'attribut
#[DataProvider('shippingCases')]relie le test à sa source de données. - La méthode
shippingCasesestpublic staticet renvoie des tableaux d'arguments, ici avecyield. - Chaque cas a un nom (
'seuil exact'). En cas d'échec, PHPUnit l'affiche : tu sais tout de suite lequel a cassé.
Quand les cas demandent des préparations différentes, ou vérifient des choses différentes. Un data provider plein de if et de paramètres optionnels est plus difficile à lire que trois tests séparés.
6.Les cas limites
Les bugs se cachent rarement au milieu des valeurs normales. Ils sont aux bords : les cas limites. Pour chaque règle, pense à tester :
- Les bornes exactes et leurs voisines : 4 999, 5 000 et 5 001 centimes pour un seuil à 5 000.
- Le vide et le zéro : liste vide, chaîne vide, quantité 0,
null. - Le négatif : une quantité ou un montant négatif est-il refusé ?
- Le très grand : 10 000 articles, un texte de 1 Mo.
- Les textes piégeux : espaces autour, majuscules, accents, emojis.
- Les dates : 29 février, 31 décembre à 23 h 59, changement d'heure, fuseau horaire.
Les cas limites sont exactement ce qu'un attaquant essaie en premier : une quantité négative pour faire baisser le total, un identifiant qui n'est pas le sien, un champ beaucoup trop long. Un test qui vérifie qu'une entrée invalide est refusée vaut autant qu'un test qui vérifie qu'une entrée valide est acceptée.
7.Écrire le test avant de corriger
Un ticket arrive : « Commande de 50,00 € pile, on m'a facturé les frais de port. » Le réflexe est d'ouvrir le code et de corriger. Prends plutôt cet ordre :
- Écris un test qui reproduit le bug, et vérifie qu'il échoue. S'il passe, tu n'as pas compris le bug.
- Corrige le code.
- Relance le test : il doit passer.
- Garde-le pour toujours. On parle de test de non-régression : si le bug revient un jour, ce test le dira.
<?php
declare(strict_types=1);
require __DIR__ . '/check.php';
// Ticket : « Commande de 50,00 € pile, on m'a facturé les frais de port. »
// Le code actuel, avec le bug
function shippingFeeBuggy(int $totalInCents): int
{
return $totalInCents > 5000 ? 0 : 499;
}
// Le code corrigé
function shippingFeeFixed(int $totalInCents): int
{
return $totalInCents >= 5000 ? 0 : 499;
}
echo "1. On écrit le test qui reproduit le ticket, AVANT de corriger :\n";
check('gratuit à 50,00 € pile', 0, shippingFeeBuggy(5000));
echo "\n2. On corrige, puis on relance le même test :\n";
check('gratuit à 50,00 € pile', 0, shippingFeeFixed(5000));1. On écrit le test qui reproduit le ticket, AVANT de corriger : ÉCHEC gratuit à 50,00 € pile (attendu 0, obtenu 499) 2. On corrige, puis on relance le même test : OK gratuit à 50,00 € pile
Voir le test échouer d'abord est important : un test qui n'a jamais échoué ne prouve pas qu'il sait détecter le problème. Pousser cette idée jusqu'au bout, en écrivant toujours le test avant le code, s'appelle le TDD (test-driven development, développement piloté par les tests).
Quand tu explores, que tu ne sais pas encore ce que le code doit faire (un prototype, un essai de librairie), écrire les tests d'abord te ralentit. Écris-les dès que le comportement est décidé. En revanche, pour corriger un bug, le test d'abord est presque toujours rentable.
8.Des tests fiables
Un test qui échoue « une fois sur dix » est pire que pas de test : l'équipe apprend à ignorer le rouge. Un bon test est :
- Rapide, pour qu'on le lance souvent.
- Indépendant : il ne dépend ni de l'ordre d'exécution, ni des données laissées par un autre test.
- Déterministe : même code, même résultat, à chaque fois. L'ennemi classique est l'heure actuelle.
<?php
use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\Clock\MockClock;
// ✗ Le résultat dépend du jour où le test tourne : vert en semaine, rouge le week-end
final class DeliveryPlanner
{
public function isWeekend(): bool
{
return (int) (new \DateTimeImmutable())->format('N') >= 6;
}
}
// ✓ L'heure est une dépendance comme une autre : on la reçoit au lieu de la lire
final class DeliveryPlannerWithClock
{
public function __construct(private ClockInterface $clock) {}
public function isWeekend(): bool
{
return (int) $this->clock->now()->format('N') >= 6;
}
}
// Dans le test, on fixe l'heure (un samedi) : le résultat est le même tous les jours
$planner = new DeliveryPlannerWithClock(new MockClock('2026-11-07 10:00:00'));Le composant Clock de Symfony fournit ClockInterface pour le code, et MockClock pour les tests. Le même principe vaut pour tout ce qui sort de ton contrôle (une API externe, l'envoi d'e-mails, le hasard) : ton code les reçoit comme des dépendances, et le test les remplace par une doublure, un faux objet qui répond ce qu'on lui dit.
Les tests ne doivent jamais toucher aux données ni aux services de production. Utilise une base de test dédiée et un fichier .env.test avec des valeurs factices, n'envoie pas de vrais e-mails, et ne mets jamais de vraie clé d'API dans un test : il finira dans Git, donc dans l'historique, pour toujours.
Pièges fréquents
| Piège | Ce qui arrive | La parade |
|---|---|---|
| Un test qui n'a jamais échoué | Il passe peut-être quoi qu'il arrive | Le voir échouer une fois, avant la correction |
assertEquals par habitude | assertEquals(1, '1') passe, alors que les types diffèrent | assertSame |
| Tester seulement le cas normal | Les bugs aux bornes passent | Bornes, vide, négatif, très grand |
| Tester une méthode privée | Chaque refactoring casse les tests | Tester à travers l'API publique |
new DateTime() dans le code testé | Un test qui échoue certains jours | ClockInterface et MockClock |
Un sleep() dans un test de bout en bout | Un test lent qui échoue au hasard | Attendre un élément précis avec waitFor… |
| Tout tester de bout en bout | Une suite lente et fragile, que plus personne ne lance | La pyramide : surtout de l'unitaire, quelques E2E |
| Des tests qui dépendent les uns des autres | Ils échouent si on change leur ordre | Chaque test prépare ses propres données |
| Viser 100 % de couverture | Des tests vides de sens, écrits pour le chiffre | Prioriser règles métier, sécurité et bugs corrigés |
| Un test lié à la base de production | Données modifiées, e-mails envoyés à de vrais clients | Base de test et .env.test |
Checklist avant de proposer ton code
- Chaque règle métier que j'ai ajoutée ou modifiée a un test.
- Le bug que j'ai corrigé a un test, que j'ai vu échouer avant la correction.
- J'ai testé les bornes, le vide et les entrées invalides.
- Mes tests utilisent
assertSame, et leurs noms se lisent comme des phrases. - Aucun test ne dépend de l'heure, de l'ordre d'exécution ou du réseau.
- Aucun test n'utilise de donnée ou de secret de production.
- Toute la suite de tests passe, pas seulement le test que j'ai écrit.
Exercice : trouver la faille par les tests
Le fichier exemples/03/exercice.php contient la fonction orderTotalInCents de la fiche 2 et deux tests. Aujourd'hui, il affiche :
OK panier vide OK un article, frais de port
- Ajoute un test pour un client VIP, et un autre pour un total exactement au seuil de livraison gratuite après la remise. Calcule les valeurs attendues à la main, avant de lancer le script.
- Pense comme un attaquant : quelle valeur de
quantitydonne un total absurde ? Écris le test qui montre le problème, et vérifie qu'il échoue. - Corrige la fonction pour qu'elle refuse ce cas (une
InvalidArgumentExceptionpar exemple), et relance tous les tests. - Bonus : dans un de tes projets Symfony, transforme ces vérifications en une vraie classe de test PHPUnit avec un data provider.
docker compose run --rm php php exemples/03/exercice.php
Conseils
- Commence petit. Un seul test sur la règle la plus critique de ton projet vaut mieux qu'un grand plan de tests jamais commencé.
- Lance les tests souvent, avant chaque commit. Plus un échec est détecté tôt, plus il est facile à comprendre.
- Fais-les tourner automatiquement dans l'intégration continue (GitHub Actions, GitLab CI) : une branche dont les tests échouent ne doit pas être fusionnée.
- Lis un test qui échoue avant de le modifier. Le réflexe de « réparer le test » au lieu du code cache souvent un vrai bug.
- Un code difficile à tester est souvent un code mal découpé. Si tu ne peux pas tester une règle sans base de données, c'est peut-être qu'elle devrait vivre dans sa propre classe (fiches 6 et 16).
Documentation officielle
- Les tests dans Symfony
- La documentation de PHPUnit, dont les data providers et les assertions
- Le composant Clock de Symfony
- Tester avec une base de données
- Panther, les tests de bout en bout avec Symfony