← Toutes les fiches

Fiche 3 Parcours : les fondamentaux Lecture : 15 minutes

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 :

exemples/03/check.php
<?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 :

exemples/03/first_test.php
<?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));
Sortie
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) :

  1. Préparer les données de départ (ici, un total de 1 000 centimes).
  2. Agir : appeler le code testé, une seule fois.
  3. 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.

  1. Dans un projet Symfony, installe les outils de test : composer require --dev symfony/test-pack.
  2. Range les tests dans le dossier tests/, en reprenant l'arborescence de src/ : la classe src/Shipping/ShippingCalculator.php est testée par tests/Shipping/ShippingCalculatorTest.php.
  3. Lance-les avec php bin/phpunit (ou vendor/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.

À propos des exemples de cette fiche

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.

exemples/03/ShippingCalculatorTest.php · illustration, non exécuté
<?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 :

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.

exemples/03/NewsletterControllerTest.php · illustration, non exécuté
<?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.

exemples/03/CheckoutTest.php · illustration, non exécuté
<?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');
    }
}
  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.
  2. Une page qui réagit à JavaScript ne change pas tout de suite. Attends un élément précis (waitFor, waitForElementToContain) plutôt qu'un sleep() : trop court, le test échoue au hasard, trop long, il ralentit toute la suite.
  3. 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.
Quand l'utiliser, quand s'en passer

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

Ce que traverse chaque sorte de test Six couches de l'application, du navigateur à la base de données. Le test unitaire ne vérifie qu'une classe de service, en quelques millisecondes. Le test d'intégration ajoute la vraie base de données. Le test fonctionnel part d'une requête HTTP simulée et traverse le routage, la sécurité, le contrôleur et les services, sans exécuter le JavaScript, en quelques dixièmes de seconde. Le test de bout en bout part d'un vrai navigateur et traverse tout, en plusieurs secondes. Les couches de l'application Vitesse et outil Navigateur + JavaScript Requête HTTP Routage, sécurité Contrôleur, Twig Services métier Base de données Unitaire quelques ms TestCase Intégration entre les deux KernelTestCase Fonctionnel : requête simulée, sans JavaScript dixièmes de s WebTestCase De bout en bout : un vrai navigateur, JavaScript exécuté plusieurs secondes Panther Plus un test traverse de couches, plus il est lent, et plus il peut casser pour une raison sans rapport avec ce qu'il vérifie. D'où beaucoup de tests unitaires, et peu de bout en bout.
Schéma 3.1 · Ce que traverse chaque sorte de test
UnitaireFonctionnelDe bout en bout
Ce qu'il vérifieUne classe, une règle de calculUne page, une règle d'accèsUn parcours complet dans un vrai navigateur
JavaScriptNon concernéNon exécutéExécuté
VitesseQuelques millisecondesDixièmes de secondePlusieurs secondes
Quand il échoueIl pointe la classe fautiveIl faut chercher où ça casseTout peut être en cause, y compris un délai
Combien en écrireBeaucoupPour les pages et les accès importantsUne 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 :

  1. 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.
  2. Les règles de sécurité : qui a le droit de voir, modifier, supprimer.
  3. Chaque bug corrigé : un test qui empêche son retour (section 7).
  4. Le code que tu as peur de modifier : c'est le signe qu'il lui manque des tests.
Ce qui ne vaut pas un test

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.

  1. L'attribut #[DataProvider('shippingCases')] relie le test à sa source de données.
  2. La méthode shippingCases est public static et renvoie des tableaux d'arguments, ici avec yield.
  3. Chaque cas a un nom ('seuil exact'). En cas d'échec, PHPUnit l'affiche : tu sais tout de suite lequel a cassé.
Quand ne pas l'utiliser

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 :

  1. Les bornes exactes et leurs voisines : 4 999, 5 000 et 5 001 centimes pour un seuil à 5 000.
  2. Le vide et le zéro : liste vide, chaîne vide, quantité 0, null.
  3. Le négatif : une quantité ou un montant négatif est-il refusé ?
  4. Le très grand : 10 000 articles, un texte de 1 Mo.
  5. Les textes piégeux : espaces autour, majuscules, accents, emojis.
  6. Les dates : 29 février, 31 décembre à 23 h 59, changement d'heure, fuseau horaire.
Sécurité

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 :

  1. Écris un test qui reproduit le bug, et vérifie qu'il échoue. S'il passe, tu n'as pas compris le bug.
  2. Corrige le code.
  3. Relance le test : il doit passer.
  4. Garde-le pour toujours. On parle de test de non-régression : si le bug revient un jour, ce test le dira.
exemples/03/test_before_fix.php
<?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));
Sortie
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 ne pas le faire

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 :

  1. Rapide, pour qu'on le lance souvent.
  2. Indépendant : il ne dépend ni de l'ordre d'exécution, ni des données laissées par un autre test.
  3. Déterministe : même code, même résultat, à chaque fois. L'ennemi classique est l'heure actuelle.
exemples/03/clock.php · illustration, non exécuté
<?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.

Sécurité

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ègeCe qui arriveLa parade
Un test qui n'a jamais échouéIl passe peut-être quoi qu'il arriveLe voir échouer une fois, avant la correction
assertEquals par habitudeassertEquals(1, '1') passe, alors que les types diffèrentassertSame
Tester seulement le cas normalLes bugs aux bornes passentBornes, vide, négatif, très grand
Tester une méthode privéeChaque refactoring casse les testsTester à travers l'API publique
new DateTime() dans le code testéUn test qui échoue certains joursClockInterface et MockClock
Un sleep() dans un test de bout en boutUn test lent qui échoue au hasardAttendre un élément précis avec waitFor…
Tout tester de bout en boutUne suite lente et fragile, que plus personne ne lanceLa pyramide : surtout de l'unitaire, quelques E2E
Des tests qui dépendent les uns des autresIls échouent si on change leur ordreChaque test prépare ses propres données
Viser 100 % de couvertureDes tests vides de sens, écrits pour le chiffrePrioriser règles métier, sécurité et bugs corrigés
Un test lié à la base de productionDonnées modifiées, e-mails envoyés à de vrais clientsBase de test et .env.test

Checklist avant de proposer ton code

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 :

Sortie
OK     panier vide
OK     un article, frais de port
  1. 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.
  2. Pense comme un attaquant : quelle valeur de quantity donne un total absurde ? Écris le test qui montre le problème, et vérifie qu'il échoue.
  3. Corrige la fonction pour qu'elle refuse ce cas (une InvalidArgumentException par exemple), et relance tous les tests.
  4. 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

  1. 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é.
  2. Lance les tests souvent, avant chaque commit. Plus un échec est détecté tôt, plus il est facile à comprendre.
  3. 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.
  4. 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.
  5. 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