Skip to content

Testing PHPUnit

TL;DR — Symfony fournit trois niveaux de tests : TestCase (unitaire pur), KernelTestCase (container booté, sans HTTP), WebTestCase (client HTTP simulé). Pour API Platform, ajoute ApiTestCase. La clé en prod : isolation DB (transactions ou refresh), temps déterministe (Clock interface), mocks ciblés (pas tout le container). Les snapshot tests sont séduisants mais cassent au moindre refactor — préfère des assertions explicites.

🧠 Mental model — ASCII diagram + analogy

┌─────────────────────────────────────────────────────────┐
│                    PHPUnit Test Levels                  │
├─────────────────────────────────────────────────────────┤
│                                                         │
│  TestCase           → pure unit, no Symfony             │
│       │              fastest, no container              │
│       ▼                                                 │
│  KernelTestCase     → container booted                  │
│       │              services, DB, no HTTP              │
│       ▼                                                 │
│  WebTestCase        → HTTP client (BrowserKit)          │
│       │              full request/response cycle        │
│       ▼                                                 │
│  ApiTestCase        → HttpClient (JSON/JSON-LD)         │
│   (API Platform)     content negotiation, schema check  │
│                                                         │
└─────────────────────────────────────────────────────────┘

Coût d'exécution : TestCase < KernelTestCase < WebTestCase < e2e
Volume         : TestCase > KernelTestCase > WebTestCase > e2e
                  (pyramide des tests)

Analogie : TestCase = isolé en chambre stérile. KernelTestCase = avec son équipe (services injectés). WebTestCase = mise en situation simulée (HTTP). E2E (Panther, Playwright) = vrai client. Plus tu montes, plus c'est lent et fragile — utilise le niveau minimal qui prouve ce que tu veux prouver.

🛠️ Code minimal — realistic snippet (PHP 8.2+)

php
<?php
// tests/Unit/SluggerTest.php — pure unit
namespace App\Tests\Unit;

use App\Service\Slugger;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\DataProvider;

final class SluggerTest extends TestCase
{
    #[DataProvider('cases')]
    public function testSlug(string $input, string $expected): void
    {
        self::assertSame($expected, (new Slugger())->slug($input));
    }

    public static function cases(): iterable
    {
        yield 'simple'   => ['Hello World', 'hello-world'];
        yield 'accents'  => ['Café Crème', 'cafe-creme'];
        yield 'punct'    => ['It\'s a test!', 'its-a-test'];
    }
}
php
<?php
// tests/Integration/BookRepositoryTest.php — KernelTestCase + DB
namespace App\Tests\Integration;

use App\Entity\Book;
use App\Repository\BookRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;

final class BookRepositoryTest extends KernelTestCase
{
    private EntityManagerInterface $em;
    private BookRepository $repo;

    protected function setUp(): void
    {
        self::bootKernel();
        $this->em   = self::getContainer()->get(EntityManagerInterface::class);
        $this->repo = self::getContainer()->get(BookRepository::class);
        $this->em->beginTransaction(); // isolation par transaction
    }

    protected function tearDown(): void
    {
        $this->em->rollback(); // rollback à chaque test → DB propre
        $this->em->close();
        parent::tearDown();
    }

    public function testFindPublished(): void
    {
        $book = new Book('Domain-Driven Design', new \DateTimeImmutable('2003-08-30'));
        $this->em->persist($book);
        $this->em->flush();

        $found = $this->repo->findPublished();
        self::assertCount(1, $found);
        self::assertSame('Domain-Driven Design', $found[0]->getTitle());
    }
}
php
<?php
// tests/Functional/BookControllerTest.php — WebTestCase
namespace App\Tests\Functional;

use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

final class BookControllerTest extends WebTestCase
{
    public function testListReturnsJson(): void
    {
        $client = static::createClient();
        $client->request('GET', '/books');

        self::assertResponseIsSuccessful();
        self::assertResponseHeaderSame('content-type', 'application/json');
        self::assertJson($client->getResponse()->getContent());
    }

    public function testCreateRequiresAuth(): void
    {
        $client = static::createClient();
        $client->request('POST', '/books', server: ['CONTENT_TYPE' => 'application/json'], content: '{}');
        self::assertResponseStatusCodeSame(401);
    }
}
php
<?php
// tests/Api/BookApiTest.php — API Platform
namespace App\Tests\Api;

use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
use App\Entity\Book;

final class BookApiTest extends ApiTestCase
{
    public function testGetCollection(): void
    {
        $client = static::createClient();
        $client->request('GET', '/api/books');

        self::assertResponseIsSuccessful();
        self::assertMatchesResourceCollectionJsonSchema(Book::class);
        $this->assertJsonContains(['@context' => '/api/contexts/Book']);
    }
}
php
<?php
// tests/Unit/ExpirationCheckerTest.php — faking time
namespace App\Tests\Unit;

use App\Service\ExpirationChecker;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Clock\MockClock;

final class ExpirationCheckerTest extends TestCase
{
    public function testTokenExpired(): void
    {
        $clock   = new MockClock('2026-01-01 12:00:00');
        $checker = new ExpirationChecker($clock);

        self::assertFalse($checker->isExpired(new \DateTimeImmutable('2026-01-01 12:30:00')));
        $clock->sleep(3600);
        self::assertTrue($checker->isExpired(new \DateTimeImmutable('2026-01-01 12:30:00')));
    }
}
xml
<!-- phpunit.xml.dist -->
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
         colors="true"
         bootstrap="tests/bootstrap.php"
         cacheDirectory=".phpunit.cache">
    <php>
        <ini name="display_errors" value="1"/>
        <env name="APP_ENV" value="test" force="true"/>
        <env name="KERNEL_CLASS" value="App\Kernel"/>
        <env name="DATABASE_URL" value="sqlite:///%kernel.project_dir%/var/test.db"/>
    </php>
    <testsuites>
        <testsuite name="Unit"><directory>tests/Unit</directory></testsuite>
        <testsuite name="Integration"><directory>tests/Integration</directory></testsuite>
        <testsuite name="Functional"><directory>tests/Functional</directory></testsuite>
    </testsuites>
</phpunit>

🎯 Patterns courants

  1. Pyramide des tests — 70% Unit, 20% Integration, 10% Functional/E2E. Si tu inverses, ta CI fait 20min et tes tests cassent au moindre refactor de template.

  2. Isolation DB par transactionbeginTransaction() / rollback() autour de chaque test. 5–10x plus rapide que doctrine:fixtures:load. Bémol : ça ne marche pas si ton code teste un flush après clear, ou si tu testes des contraintes deferred. Pour ces cas : DAMADoctrineTestBundle.

  3. Fixtures atomiques par testliip/test-fixtures-bundle ou zenstruck/foundry permettent de charger des fixtures ciblées (UserFactory::createOne(['role' => 'admin'])) au lieu d'un gros dump global.

  4. Clock injection — JAMAIS new \DateTime() dans le code métier. Injecte Symfony\Component\Clock\ClockInterface. En test, MockClock te donne un contrôle total.

  5. Mocks sur services externes uniquement — mocke HttpClientInterface, MailerInterface, S3, etc. Ne mocke PAS tes propres repositories : utilise une vraie DB de test, c'est plus représentatif.

  6. MockHttpClient — fourni par Symfony pour tester du code qui appelle des APIs externes. Préconçoit les réponses, vérifie les requêtes envoyées. Une callable te donne accès à la requête sortante pour asserter méthode/URL/body — bien plus robuste qu'un simple tableau de réponses.

    php
    // Réponse statique
    $client = new MockHttpClient([new MockResponse('{"ok":true}', ['http_code' => 200])]);
    
    // Forme callable : asserte la requête ET renvoie la réponse
    $client = new MockHttpClient(function (string $method, string $url, array $options): MockResponse {
        self::assertSame('POST', $method);
        self::assertStringContainsString('/v1/payment_intents', $url);
        self::assertJsonStringEqualsJsonString('{"amount":24000}', $options['body']);
        return new MockResponse('{"id":"pi_123","status":"succeeded"}', ['http_code' => 200]);
    });
  7. Tester le code asynchrone (Messenger) — un handler ne doit pas être testé via le bus réel en prod. Deux stratégies : (a) transport sync en test (framework.messenger.transports.async: 'sync://') pour exécuter le handler dans la requête ; (b) InMemoryTransport + assertions sur les messages dispatchés. La seconde est préférable car elle découple « le message a été émis » de « le handler a tourné ».

    php
    use Symfony\Component\Messenger\Transport\InMemoryTransport;
    
    /** @var InMemoryTransport $transport */
    $transport = self::getContainer()->get('messenger.transport.async');
    self::assertCount(1, $transport->getSent());
    self::assertInstanceOf(CvUploadedMessage::class, $transport->getSent()[0]->getMessage());
  8. Déterminisme du hasard — un test qui dépend de random_int, d'un UUID v4 ou de l'ordre d'un array_rand est flaky par construction. Injecte une source de hasard (Random\Randomizer avec un Mt19937 seedé en test) ou une fabrique d'UUID mockable. Même logique que le ClockInterface : tout effet non-déterministe doit être une dépendance.

Tradeoffs : quel niveau de test, et à quel coût

NiveauBoote le kernel ?Touche la DB ?HTTP réel ?Coût/testCe qu'il prouveCe qu'il NE prouve PAS
TestCaseNonNonNon~0.1 msLogique pure, branches, calculsCâblage DI, sérialisation, routing
KernelTestCaseOuiOui (optionnel)Non~5–50 msRepositories, services câblés, handlers MessengerStack HTTP, sécurité, content negotiation
WebTestCaseOuiOuiSimulé (BrowserKit, in-process)~30–150 msRouting, firewall, sérialisation, status codesComportement du vrai serveur, TLS, concurrence
ApiTestCaseOuiOuiSimulé (HttpClient)~30–150 msJSON-LD/Hydra, schéma, content negotiationIdem WebTestCase
Panther/E2EOuiOuiVrai (Chrome headless)~1–10 sJS, rendu navigateur, parcours réelRien de plus que le strict nécessaire — coûteux et flaky

Règle staff : descends d'un cran dès que possible. Un bug de calcul de TVA testé en WebTestCase est un test 1000× trop lent pour ce qu'il prouve. Remonte d'un cran seulement quand le bug ne peut pas être reproduit plus bas (ex. un firewall qui bloque une route).

🔄 Versions

SymfonyPHPUnitNotes
5.49.xKernelTestCase::$container (deprecated) → self::getContainer()
6.09.5/10self::getContainer() obligatoire (test container avec services privés exposés)
6.310Symfony\Component\Clock arrive — MockClock, ClockInterface
6.410/11LTS. Attribut #[DataProvider] PHPUnit 10 préféré aux annotations
7.011Doctrine annotations dépréciées, ApiTestCase mise à jour
7.1+11/12Clock recommandé partout, BrowserKit continue de marcher

API Platform : 2.7 utilisait ApiTestCase (Test\ApiTestCase), 3.x utilise Symfony\Bundle\Test\ApiTestCase. Renommer les use.

PHPUnit 10+ : annotations @dataProvider → attribut #[DataProvider('method')]. setUpBeforeClass typed void. Strict mode plus agressif sur les warnings.

⚠️ Pitfalls

  1. createClient() boote un nouveau kernel à chaque appel — appelle-le une fois par test, sinon les services sont réinitialisés au milieu et tes mocks disparaissent. Pour partager entre tests : factory dans setUp().

  2. Container test vs prod — en APP_ENV=test, les services private sont accessibles via getContainer(). En prod, NON. Donc un test qui passe ne garantit pas l'autowiring prod.

  3. Transactions imbriquées en SQLite — SQLite ne supporte qu'une transaction par connexion. Pour des tests parallèles, utilise pcov ou paratest qui isolent.

  4. MockClock::sleep() ≠ vrai sleep — il avance juste l'horloge interne. Si ton code appelle sleep(1) réel, le test attend vraiment. Refactor en ClockInterface::sleep().

  5. Snapshots qui dériventassertJsonStringEqualsJsonString sur un gros payload casse au moindre champ ajouté. Préfère assertJsonContains (subset match) ou décompose en plusieurs assertions ciblées.

  6. Tests qui dépendent de l'ordre@depends, état partagé via propriétés static. PHPUnit 10 le permet mais c'est une dette : un test seul doit pouvoir tourner.

  7. $client->followRedirects(false) — par défaut le client suit les redirects et tu testes la mauvaise page. Désactive pour vérifier le 302.

  8. Cache de prod dans les testsvar/cache/test/ peut contenir un container compilé périmé. Symptôme : "service X not found" en CI mais OK en local. Fix : bin/console cache:clear --env=test avant les tests.

  9. getContainer()->set() après le premier get() — remplacer un service mocké ne fonctionne que si le service n'a pas encore été instancié ni inliné par le compilateur. Pour les services largement câblés, marque-les public en test ou utilise un test double injecté via services_test.yaml. Sinon, ton ->set() est silencieusement ignoré et tu débugges pendant une heure.

🏭 Production : CI, parallélisme, couverture, observabilité

Un test qui passe sur ton laptop ne vaut rien tant qu'il n'est pas rapide, isolé et non-flaky en CI. Voici comment un staff engineer raisonne sur la suite.

Parallélisme (paratest) et isolation DB. paratest lance N processus PHP. Le piège : ils partagent la même DB. Deux stratégies de production :

  • Une base par worker : paratest expose TEST_TOKEN (1..N). Configure DATABASE_URL=...dbname=app_test_${TEST_TOKEN} et crée les schémas en amont. Isolation parfaite, coût mémoire = N connexions.
  • Transaction rollbackée par test (DAMADoctrineTestBundle) : compatible paratest si chaque worker a sa base. Combine les deux pour le meilleur rapport vitesse/isolation.
bash
# CI : créer N bases, puis lancer paratest
for i in $(seq 1 8); do
  DATABASE_URL="postgresql://app:app@db:5432/app_test_$i" \
    bin/console doctrine:schema:create --env=test --no-interaction
done
vendor/bin/paratest -p8 --runner=WrapperRunner

Couverture comme garde-fou, pas comme objectif. Vise une couverture différentielle (le code modifié dans la PR), pas un seuil global vanitaire. Un seuil global de 90% pousse à tester des getters ; un gate « la diff doit être couverte à 80% » pousse à tester ce qui change. Utilise pcov (10× plus rapide que Xdebug pour la couverture, sans step-debug) :

bash
# pcov : couverture rapide en CI ; Xdebug seulement pour le débogage local
php -d pcov.enabled=1 vendor/bin/phpunit --coverage-clover=coverage.xml

Observabilité de la flakiness. Un test flaky est pire qu'un test absent : il érode la confiance et entraîne le @retry réflexe. Mesure-la. PHPUnit peut sortir un JUnit XML (--log-junit) ; agrège ces rapports (ex. dans un dashboard) pour identifier les tests rouges intermittents. Politique staff : un test flaky est quarantiné (taggé @group flaky, sorti du gate bloquant) avec un ticket — jamais re-essayé en boucle.

bash
vendor/bin/phpunit --log-junit junit.xml --order-by=random --random-order-seed=$CI_RUN_ID

--order-by=random (PHPUnit 10/11) est volontaire : il fait exploser les tests qui dépendent d'un état partagé ou de l'ordre. Le seed est loggé pour reproduire un échec exact. C'est un détecteur de couplage caché.

Sécurité dans les tests. Ne teste jamais avec des secrets réels : APP_SECRET, clés Stripe, tokens — tous en valeurs test dans .env.test. Un test qui appelle une vraie API tierce est un test qui casse quand le tiers est down et qui fuite des credentials dans les logs CI. Tout appel sortant = MockHttpClient.

🧪 Testing — anti-patterns

  • Snapshot d'HTML complet : assertResponseContains('<full huge HTML blob>'). Casse à chaque CSS class renommée. Préfère des assertions sur le DOM via crawler : $crawler->filter('h1')->text().
  • Mocks à 5 niveaux : si tu dois mocker A qui contient B qui appelle C... ton code a un problème de design, pas tes tests.
  • Tests qui réimplémentent la logique : $expected = $svc->calculate($x); self::assertSame($expected, $svc->calculate($x)); ne teste rien. Utilise des valeurs en dur.

🎬 Cas d'usage concrets

Scénario 1 — Tests d'intégration Pennylane (fintech compta SaaS)

Pennylane, SaaS comptabilité B2B français, déploie 30 fois par jour avec une suite PHPUnit de 8 200 tests dont 1 800 d'intégration. Chaque PR exécute la suite complète en ~6 minutes sur GitHub Actions (parallélisation paratest sur 8 workers). Les tests d'intégration utilisent WebTestCase avec une base PostgreSQL dédiée par worker, fixtures Foundry (50 entités persistées par test moyennement), et un DAMADoctrineTestBundle qui wrappe chaque test dans une transaction rollbackée. Les tests bancaires (synchronisation Bridge, Powens) sont isolés derrière des MockHttpClient qui rejouent des fixtures HTTP enregistrées via VCR-like. Les tests E2E lancent l'OCR de factures sur des PDF de référence (Allianz, EDF, Orange) et assertent que les écritures comptables générées correspondent (compte 401, TVA 20%, montant HT exact). La règle : zéro flakiness toléré, un test rouge bloque le merge.

Scénario 2 — Tests e-commerce checkout (Veepee, Showroomprivé, Mirakl)

Sur une plateforme e-commerce flash sales, le checkout est le code le plus testé : CartTest, CheckoutFlowTest, PaymentProviderTest, OrderConfirmationTest. Le panier multi-vendeurs (Mirakl-like) doit calculer correctement : frais de port par marchand, code promo cumulables ou non, TVA par pays, taxes locales (eco-mobilier, DEEE). Les tests unitaires couvrent chaque calculateur (ShippingCalculator, DiscountEngine, VatCalculator) avec 200+ combinaisons. Les tests d'intégration utilisent KernelTestCase pour valider le flow complet : ajout panier → application promo → calcul TVA → simulation paiement Stripe (test mode via fixtures de webhooks) → création commande → décrément stock. Les tests WebTestCase valident les endpoints /api/cart, /api/checkout/start, /api/checkout/confirm avec 35 cas (panier vide, code promo expiré, stock insuffisant en cours de checkout, fraude détectée). L'invariant : un checkout ne doit jamais débiter sans créer la commande, donc un test testPaymentSucceedsButOrderFailsTriggersRefund est obligatoire.

Scénario 3 — Tests cabinet ATS (Welcome to the Jungle, Teamtailor, Lever-like)

Une plateforme ATS SaaS RH teste le pipeline de candidature : un candidat postule → CV parsé (OCR) → matching scoring → routing vers recruteur → notifications. Les tests unitaires couvrent les scorers (SkillsMatchingScorer, LocationProximityScorer, ExperienceLevelScorer) avec des CV anonymisés en fixtures JSON. Les tests d'intégration valident l'enchaînement Messenger : CvUploadedMessage → handler OCR (mock Textract) → CvParsedMessage → handler matching → CandidateMatchedMessage → handler notification (mock Mailer). MessengerTestTrait (transport_names: ['async'] en sync) permet de traiter les messages dans le test. Les tests d'API valident /api/jobs/{id}/applications avec RGPD : un test vérifie que le DELETE /api/candidates/{id} purge bien les données (table candidates, table cv_files, table matching_scores, S3 bucket avec mock). Le RefreshDatabaseTrait recharge le schéma entre groupes de tests.

🛠️ Exemple end-to-end

Cas : tester le flow de checkout e-commerce avec calcul TVA multi-pays, promo, et simulation Stripe.

php
<?php
// tests/Functional/CheckoutFlowTest.php
declare(strict_types=1);

namespace App\Tests\Functional;

use App\Factory\CustomerFactory;
use App\Factory\ProductFactory;
use App\Factory\PromoCodeFactory;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Response;
use Zenstruck\Foundry\Test\Factories;
use Zenstruck\Foundry\Test\ResetDatabase;

final class CheckoutFlowTest extends WebTestCase
{
    use Factories;
    use ResetDatabase;

    public function testCheckoutFrenchCustomerAppliesFrenchVat(): void
    {
        $client = static::createClient();
        $customer = CustomerFactory::createOne(['country' => 'FR', 'email' => '[email protected]']);
        $product = ProductFactory::createOne(['priceHt' => '100.00', 'vatRate' => '0.20']);

        $client->loginUser($customer->_real());

        $client->jsonRequest('POST', '/api/cart/items', [
            'productId' => $product->getId(),
            'quantity' => 2,
        ]);
        self::assertResponseStatusCodeSame(201);

        $client->jsonRequest('POST', '/api/checkout/start', []);
        self::assertResponseIsSuccessful();

        $data = json_decode($client->getResponse()->getContent(), true);
        self::assertSame('200.00', $data['totals']['ht']);
        self::assertSame('40.00', $data['totals']['vat']);
        self::assertSame('240.00', $data['totals']['ttc']);
        self::assertSame('FR', $data['totals']['vatCountry']);
    }

    public function testPromoCodeAppliesBeforeVat(): void
    {
        $client = static::createClient();
        $customer = CustomerFactory::createOne(['country' => 'FR']);
        $product = ProductFactory::createOne(['priceHt' => '100.00', 'vatRate' => '0.20']);
        PromoCodeFactory::createOne(['code' => 'SOLDES10', 'percent' => 10, 'active' => true]);

        $client->loginUser($customer->_real());
        $client->jsonRequest('POST', '/api/cart/items', ['productId' => $product->getId(), 'quantity' => 1]);
        $client->jsonRequest('POST', '/api/cart/promo', ['code' => 'SOLDES10']);
        $client->jsonRequest('POST', '/api/checkout/start', []);

        $data = json_decode($client->getResponse()->getContent(), true);
        self::assertSame('90.00', $data['totals']['ht']);
        self::assertSame('18.00', $data['totals']['vat']);
        self::assertSame('108.00', $data['totals']['ttc']);
    }

    public function testCheckoutFailsWhenStockInsufficient(): void
    {
        $client = static::createClient();
        $customer = CustomerFactory::createOne(['country' => 'FR']);
        $product = ProductFactory::createOne(['priceHt' => '100.00', 'stock' => 1]);

        $client->loginUser($customer->_real());
        $client->jsonRequest('POST', '/api/cart/items', ['productId' => $product->getId(), 'quantity' => 5]);
        $client->jsonRequest('POST', '/api/checkout/start', []);

        self::assertResponseStatusCodeSame(Response::HTTP_CONFLICT);
        self::assertJsonContains(['type' => 'urn:problem:insufficient-stock']);
    }
}
php
<?php
// tests/Integration/Payment/StripePaymentProviderTest.php
declare(strict_types=1);

namespace App\Tests\Integration\Payment;

use App\Payment\StripePaymentProvider;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

final class StripePaymentProviderTest extends KernelTestCase
{
    public function testSucceededWebhookCreatesOrderAndDecrementsStock(): void
    {
        $kernel = self::bootKernel();
        $container = static::getContainer();

        $container->set('http_client.stripe', new MockHttpClient([
            new MockResponse(json_encode([
                'id' => 'pi_test_123',
                'status' => 'succeeded',
                'amount' => 24000,
                'currency' => 'eur',
            ])),
        ]));

        /** @var StripePaymentProvider $provider */
        $provider = $container->get(StripePaymentProvider::class);
        $result = $provider->confirmIntent('pi_test_123');

        self::assertTrue($result->isSucceeded());
        self::assertSame('eur', $result->currency);
        self::assertSame(24000, $result->amountInCents);
    }
}
php
<?php
// tests/Unit/Cart/VatCalculatorTest.php
declare(strict_types=1);

namespace App\Tests\Unit\Cart;

use App\Cart\VatCalculator;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

final class VatCalculatorTest extends TestCase
{
    #[DataProvider('vatProvider')]
    public function testVatByCountry(string $country, string $priceHt, string $expectedVat): void
    {
        $calculator = new VatCalculator();
        self::assertSame($expectedVat, $calculator->compute($country, $priceHt));
    }

    public static function vatProvider(): iterable
    {
        yield 'France standard 20%' => ['FR', '100.00', '20.00'];
        yield 'Allemagne 19%' => ['DE', '100.00', '19.00'];
        yield 'Luxembourg 17%' => ['LU', '100.00', '17.00'];
        yield 'Belgique 21%' => ['BE', '100.00', '21.00'];
        yield 'Hors UE pas de TVA' => ['CH', '100.00', '0.00'];
    }
}

Couverture : test fonctionnel HTTP de bout en bout + test d'intégration provider mocké + tests unitaires data-driven. La suite tourne sous 30 secondes pour le module checkout.

Note Foundry : $factory->_real() est l'API Foundry 2.x (retourne l'entité réelle). En Foundry 1.x c'était ->object(). De même, createOne() remplace l'ancien new()->create(). Si tu lis du vieux code, mappe les deux.


🔁 Quand utiliser / éviter

Utiliser :

  • Toujours : couvrir au moins le happy path + 1-2 cas limites.
  • TDD sur la logique métier pure (value objects, calculs).
  • Tests de régression sur les bugs corrigés (1 test = 1 bug).

Éviter :

  • Tests sur du code généré (entities getters/setters) — couverture artificielle.
  • Tests d'intégration sur tout le système au lieu de cibler — lents, fragiles.
  • Mocker des Value Objects ou des entities — ce sont des données, pas des services.

🏋️ Exercices

Progression : implémenter → isoler/durcir → casser puis réparer. Chaque exercice suppose une app Symfony 7.x + PHPUnit 11 avec symfony/clock et Foundry installés.

1. Slug + Clock déterministe (implémenter)

Objectif : écrire un TokenService::generate(): Token où le token expire 15 min après création, et le prouver sans sleep réel. Indice/Solution : injecte ClockInterface ; en test, new MockClock('2026-06-16 10:00:00'), génère le token, puis $clock->modify('+16 minutes') (ou sleep(960)) et asserte isExpired() bascule à true exactement à la frontière (10:15:00 → encore valide, 10:15:01 → expiré). Teste les deux côtés de la frontière via DataProvider.

2. Repository sous transaction rollbackée (isoler)

Objectif : tester OrderRepository::findPendingOlderThan(\DateTimeImmutable) avec 3 commandes en DB, sans polluer les autres tests. Indice/Solution : KernelTestCase + beginTransaction()/rollback() en setUp/tearDown (ou installe DAMADoctrineTestBundle et supprime le boilerplate). Persiste 3 ordres avec des dates contrôlées par MockClock, asserte que seuls les 2 attendus reviennent. Vérifie ensuite qu'un second test voit une DB vide → preuve de l'isolation.

3. Provider externe via MockHttpClient callable (production-grade)

Objectif : tester StripePaymentProvider::confirmIntent() en assertant la requête sortante (méthode, URL, Idempotency-Key) ET en simulant un 200 puis un 402 (card_declined). Indice/Solution : MockHttpClient en forme callable. Premier test : réponse succeeded, asserte $result->isSucceeded(). Deuxième test : réponse 402 card_declined (voir ci-dessous), asserte qu'une PaymentDeclinedException typée est levée et que le header Idempotency-Key était bien présent dans la requête capturée.

php
$client = new MockHttpClient(function (string $method, string $url, array $options): MockResponse {
    self::assertSame('POST', $method);
    self::assertArrayHasKey('idempotency-key', array_change_key_case($options['normalized_headers'] ?? []));
    return new MockResponse('{"error":{"code":"card_declined"}}', ['http_code' => 402]);
});

4. Flow checkout E2E + invariant de cohérence (production-grade)

Objectif : WebTestCase du parcours panier → promo → checkout, en garantissant l'invariant « jamais de débit sans commande ». Indice/Solution : Foundry + ResetDatabase. Cas nominal : asserte HT/TVA/TTC. Cas dégradé : force le provider de paiement (mock) à renvoyer succeeded mais fais échouer la création de commande (ex. contrainte unique) → asserte qu'un remboursement est déclenché (vérifie le message Messenger RefundRequested dans l'InMemoryTransport). C'est le test testPaymentSucceedsButOrderFailsTriggersRefund du scénario 2.

5. Casser puis réparer : le test flaky caché (break-then-fix)

Objectif : on te donne une suite verte. Lance-la avec --order-by=random --random-order-seed=12345 : un test passe au rouge. Trouve la cause et corrige le test, pas le seed. Indice/Solution : la cause typique est un état partagé — une propriété static, un MockClock instancié une fois en setUpBeforeClass, ou un fixture inséré en setUp qui suppose un auto-increment à 1. Fix : rendre chaque test auto-suffisant (fixtures dans le test, pas d'@depends, identifiants récupérés depuis l'entité persistée jamais codés en dur). Re-lance avec 5 seeds différents → tout vert.

6. Paralléliser sans corruption (break-then-fix, hard)

Objectif : la suite passe en séquentiel mais échoue aléatoirement sous paratest -p4. Diagnostique et isole. Indice/Solution : les 4 workers tapent la même base → deadlocks/données croisées. Solution de prod : une base par worker via TEST_TOKEN (dbname=app_test_${TEST_TOKEN}), schémas créés en amont, + transaction rollbackée par test. Vérifie aussi qu'aucun test n'écrit dans un fichier/répertoire partagé (var/, /tmp/fixe.json) sans suffixer par le token. Mesure : la suite doit être ~3,5× plus rapide sur 4 workers, zéro flakiness sur 20 runs.

🎤 En entretien

Q : Pourquoi ne faut-il PAS mocker tes propres repositories, mais mocker HttpClientInterface ? R : Un repository encapsule de la logique SQL/Doctrine que seul un vrai moteur peut valider (mapping, hydration, dialecte) — le mocker teste ta croyance sur le SQL, pas le SQL. Un client HTTP externe est une frontière non-déterministe, lente et hors de ton contrôle : tu mockes la frontière, jamais ton propre domaine. Règle : mocke ce que tu ne possèdes pas et ce qui est non-déterministe ; utilise le vrai pour ce que tu possèdes.

Q : WebTestCase vs ApiTestCase vs Panther — comment choisis-tu ? R : Le niveau minimal qui reproduit le risque. Calcul métier → TestCase. Câblage service/DB → KernelTestCase. Routing/firewall/sérialisation HTTP → WebTestCase. Conformité JSON-LD/Hydra + schéma → ApiTestCase. Du JavaScript ou un rendu navigateur réel → Panther, et seulement là, car c'est 1000× plus lent et flaky. Remonter d'un cran a un coût exponentiel en temps CI et en fragilité.

Q : Comment garantis-tu l'isolation des tests en parallèle ? R : Deux couches. Au niveau données : une base par worker (TEST_TOKEN) + transaction rollbackée par test (DAMADoctrineTestBundle), donc aucun test ne voit l'état d'un autre. Au niveau exécution : --order-by=random avec seed loggé pour faire exploser tout couplage caché, et interdiction d'état static partagé ou d'@depends. Un test doit pouvoir tourner seul, dans n'importe quel ordre, sur n'importe quel worker.

Q : Un test est flaky en CI mais vert en local. Quelle est ta démarche ? R : D'abord je ne le re-essaie pas en boucle — je le quarantine (@group flaky hors du gate) avec un ticket. Suspects par ordre de probabilité : dépendance au temps (new \DateTime() non injecté → ClockInterface), au hasard (UUID/random_int non seedé), à l'ordre/état partagé (reproductible via le seed CI), à la concurrence (base partagée entre workers), ou un cache container périmé (var/cache/test). Je reproduis avec le seed exact loggé, puis je rends la dépendance non-déterministe injectable. Re-essayer masque le bug ; le rendre déterministe le tue.

🔗 Liens

Bibliothèque tech perso — Achref