Aller au contenu principal
Retour au blog

PHPUnit 13 sur Symfony : l’ordre des étapes, les vraies ruptures

PHPUnit 13 exige PHP 8.4. Le chemin 11.5, 12.5 puis 13, les ruptures mesurées (stubs, any(), seal), les remplaçants de withConsecutive et Rector.

Flavien Métivier · 8 min de lecture
Sommaire · 9 parties

Sauter directement à PHPUnit 13 est la meilleure façon de transformer une migration de deux jours en chantier de deux semaines. PHPUnit 13 est sorti le 6 février 2026 et exige PHP 8.4. Pour migrer une suite Symfony, on passe par PHPUnit 11.5, puis 12.5 sans aucune dépréciation, et seulement ensuite à la 13. Voici l’ordre des étapes et les ruptures qui comptent vraiment — toutes mesurées sur des suites de test exécutées sous PHPUnit 11.5.57, 12.5.39 et 13.4.2.

Pourquoi migrer maintenant, et ce que PHPUnit 13 exige

  • PHPUnit 11 (PHP 8.2) : support de correctifs terminé le 6 février 2026.
  • PHPUnit 12 (7 février 2025, PHP 8.3) : correctifs jusqu’au 5 février 2027.
  • PHPUnit 13 (6 février 2026, PHP 8.4) : correctifs jusqu’au 4 février 2028. Dernier patch au 12 octobre 2026 : 13.4.2.
  • PHPUnit 14 : annoncé pour le 5 février 2027.

Une suite encore en PHPUnit 11 n’est donc plus corrigée, et la fenêtre confortable pour atteindre la 13 court jusqu’en février 2027. Le prérequis est PHP 8.4 là où tournent les tests : si votre production reste en 8.3, PHPUnit 12.5 est un bon palier d’attente. Monter PHP passe avant monter PHPUnit ; notre checklist de migration PHP et fin de support de PHP 8.2 donne le calendrier côté langage.

L’annonce officielle de PHPUnit 13 pose une règle sans ambiguïté : ne migrez pas tant que votre suite ne tourne pas sur 12.5 sans aucun avertissement de dépréciation. Ce qui était déprécié en douceur en 12 devient fermement déprécié en 13, et ce qui l’était fermement est supprimé.

Le chemin de migration : 11.5, puis 12.5, puis 13

  • Étape 0 — PHPUnit 11.5 : c’est la version qui vous annonce ce que la 12 va casser. Lancez-la avec l’affichage des dépréciations.
  • Étape 1 — PHPUnit 12.5, au minimum 12.5.11 (en dessous, certains avertissements n’existent pas encore). Corrigez les ruptures jusqu’à zéro dépréciation.
  • Étape 2 — verrouiller : failOnDeprecation="true" en CI sur 12.5. C’est le critère de sortie, pas un objectif vague.
  • Étape 3 — PHPUnit 13 : composer require --dev "phpunit/phpunit:^13.4", puis adoption des nouveautés à votre rythme, dans des commits séparés.

Un point que beaucoup d’équipes ratent : les remplaçants apportés par la 13 — withParameterSetsInOrder(), les assertions assertArrays*, seal() — n’existent pas en 12.5. Testé : « Call to undefined method » sur 12.5.39. On ne peut donc pas les adopter en avance ; seul le nettoyage des dépréciations se fait en 12.5.

Les vraies ruptures de PHPUnit 12

Nous avons écrit une suite volontairement « legacy ». En 11.5.57, tout passe avec quatre dépréciations. En 12.5.39, quatre erreurs. Ce qui casse :

  • Les annotations en doc-comment. Un @dataProvider n’est plus lu : le test reçoit zéro argument et lève une ArgumentCountError. Plus traître : @covers et @group sont ignorés sans aucune erreur. La suite reste verte, mais votre couverture ciblée et vos filtres de groupe en CI ne fonctionnent plus. La conversion vers les attributs est détaillée dans notre article sur la migration des annotations PHPUnit vers les attributs PHP 8.
  • getMockForAbstractClass() disparaît, sans remplaçant direct. $this->getMockBuilder(Base::class)->getMock() fonctionne en 12 et 13 ; une classe anonyme qui étend la classe abstraite est souvent plus lisible.
  • expects() sur un stub : createStub()->expects() lève une erreur. En 11, ces tests étaient déjà marqués « risky » : l’attente n’était plus vérifiée depuis PHPUnit 10. C’étaient des faux positifs. Utilisez createMock() si vous vérifiez l’appel, sinon supprimez l’attente.

Et withConsecutive() ? Contrairement à ce qu’on lit souvent, ce n’est pas une rupture de la 13 : la méthode a été supprimée dès PHPUnit 10. Nous avons vérifié qu’elle échoue en 11.5, 12.5 et 13.4. Une suite qui l’utilise encore ne tourne déjà plus aujourd’hui ; la 13 apporte enfin ses remplaçants.

Les vraies ruptures de PHPUnit 13

  • with() sur un stub devient une erreur fatale. La 12.5 prévient : « Using with*() on a test stub has no effect and is deprecated ». C’est la rupture la plus fréquente sur les suites Symfony, où l’on stubbe repositories et clients HTTP avec un ->with() « par habitude ».
  • any() est déprécié et disparaîtra en 14 : « Use a test stub instead or configure a real invocation count expectation ». Piège : la 12.5 n’affiche pas cette dépréciation, seulement un notice sur l’absence d’attente. Remplacez par createStub() si vous ne vérifiez rien, par once(), exactly(n), atLeast(1) ou never() sinon.
  • with*() sans expects() et atLeast() avec un argument non positif sont fermement dépréciés depuis 13.0.2.
  • Les mineures ajoutent des dépréciations de configuration : en 13.2, executionOrder="duration" devient duration-ascending ; en 13.3, cacheResult devient recordTestRunHistory ; en 13.4, depends dans executionOrder passe par resolveDependencies. Visez ^13.4 et relisez votre phpunit.xml.

Remplacer withConsecutive(), sceller les mocks

PHPUnit 13 remplace l’ancien withConsecutive() par withParameterSetsInOrder() et withParameterSetsInAnyOrder() ; une variante withParameterSetsInPartialOrder() existe dans le code, sans être citée par l’annonce. Chaque argument est le tableau des paramètres d’un appel. Ce n’est pas un simple renommage : la migration oblige à décider si l’ordre des appels compte vraiment. Tout le code ci-dessous a été exécuté sur PHPUnit 13.4.2 :

php
<?php
declare(strict_types=1);

use PHPUnit\Framework\TestCase;

interface Mailer
{
    public function send(string $to, string $subject): void;
    public function flush(): void;
}

final class MailerTest extends TestCase
{
    public function testSendsInOrder(): void
    {
        $mailer = $this->createMock(Mailer::class);
        // PHPUnit 13 uniquement : remplace withConsecutive()
        $mailer->expects($this->exactly(2))
            ->method('send')
            ->withParameterSetsInOrder(['a@x.fr', 'S1'], ['b@x.fr', 'S2']);

        $mailer->send('a@x.fr', 'S1');
        $mailer->send('b@x.fr', 'S2');
    }

    public function testSealedMock(): void
    {
        $mailer = $this->createMock(Mailer::class);
        // seal() se place en fin de chaîne : flush(), non configurée, sera rejetée
        $mailer->expects($this->once())->method('send')->with('a@x.fr', 'S1')->seal();

        $mailer->send('a@x.fr', 'S1');
    }

    public function testStubInsteadOfAny(): void
    {
        // Avant : createMock() + expects($this->any())
        $mailer = $this->createStub(Mailer::class);
        $mailer->method('send'); // pas de with() sur un stub

        $mailer->send('a@x.fr', 'S1');
        $this->assertArraysAreIdenticalIgnoringOrder(['a' => 1, 'b' => 2], ['b' => 2, 'a' => 1]);
    }
}

Migration Symfony en vue ?

Bear Upgrade migre votre codebase 2,5× à 4,5× moins cher qu'une ESN, selon sa maturité.

Estimer ma migration →

Avec withParameterSetsInOrder(), des appels inversés échouent avec un message clair (« Parameter $to for invocation Mailer::send('b@x.fr', 'S2') does not match expected value ») ; withParameterSetsInAnyOrder() les accepte. Les deux exigent un expects() préalable.

seal() mérite deux précisions que l’annonce simplifie. D’abord, il ne s’appelle pas sur le mock mais en fin de chaîne de configuration : $mock->seal() n’existe pas. Ensuite, son effet est réel : toute méthode non configurée reçoit une attente « zéro appel », et appeler flush() fait échouer le test (« was not expected to be called, actually called 1 time »). Le réglage global requireSealedMockObjects="true" est plus doux qu’annoncé : un mock non scellé produit un test risky, pas un échec. Sans failOnRisky="true", la CI reste verte.

bash
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
         bootstrap="vendor/autoload.php"
         failOnDeprecation="true"
         failOnRisky="true"
         requireSealedMockObjects="true">
    <!-- sans failOnRisky, un mock non scellé ne fait que marquer le test risky -->
    <testsuites>
        <testsuite name="app"><directory>tests</directory></testsuite>
    </testsuites>
</phpunit>

Enfin, huit assertions de tableaux arrivent, dont le nom encode la sémantique : assertArraysAreIdentical, assertArraysAreEqual, assertArraysHaveIdenticalValues, assertArraysHaveEqualValues, chacune avec sa variante IgnoringOrder. Les variantes « Equal » comparent de façon lâche : [1, 2, 3] et ['3', 1, 2] passent avec assertArraysHaveEqualValuesIgnoringOrder.

Automatiser avec Rector, sans lui déléguer le jugement

Depuis Rector 2.6.0, withComposerBased(phpunit: true) active les règles selon la version de PHPUnit installée. La commande vendor/bin/rector composer-based liste les règles actives : 56 sur 63 avec PHPUnit 11.5.57, 60 sur 63 avec 13.4.2, sur Rector 2.7.0.

php
<?php
// rector.php (Rector 2.7.0)
declare(strict_types=1);

use Rector\Config\RectorConfig;

return RectorConfig::configure()
    ->withPaths([__DIR__ . '/tests'])
    ->withComposerBased(phpunit: true);

Sur notre suite, Rector a converti @covers en #[CoversClass] et @dataProvider en #[DataProvider]. En 13.4, il réécrit expects($this->any())->method('send') en method('send') — mais le mock résultant, sans attente, déclenche un notice qui suggère de passer à un stub : finissez le travail à la main. Rector n’a touché ni à getMockForAbstractClass() ni aux expects() sur stubs : ces cas demandent un jugement humain. La mécanique générale est détaillée dans notre article sur l’automatisation du refactoring PHP avec Rector.

L’écosystème autour : bridge Symfony, Infection, Pest

  • symfony/phpunit-bridge 8.1.9 s’installe avec PHPUnit 13.4.2. Nous n’avons vérifié que la résolution Composer, pas le comportement du listener : testez sur votre suite. Il capte les dépréciations PHP et Symfony, pas celles de PHPUnit, qui ont leur propre affichage.
  • Infection : la 0.35.7 adapte la validation des configurations de PHPUnit 13.2 et plus. Mettez Infection à jour avant de passer en 13.2.
  • Pest 5 exige PHPUnit 13 et PHP 8.4 : si vous utilisez Pest, cette migration est incluse dans la sienne.

Les pièges de production

  • Le notice « No expectations were configured for the mock object », apparu en 12.5, explose en volume sur les vieilles suites qui utilisent createMock() pour tout. Le traitement est de convertir en createStub() ; l’attribut #[AllowMockObjectsWithoutExpectations] n’est qu’une échappatoire à dater.
  • Affichez explicitement les dépréciations de PHPUnit lui-même : --display-deprecations --display-phpunit-deprecations --display-notices. Attention, --display-phpunit-notices n’existe qu’à partir de la 12.
  • Ne combinez pas migration et adoption de seal() : activer requireSealedMockObjects d’emblée transforme chaque mock en test risky.
  • Tant que la production n’est pas en PHP 8.4, gardez deux jobs : PHP 8.3 avec PHPUnit 12.5, et PHP 8.4 ou 8.5 avec PHPUnit 13.

Le plan en trois sprints

  • Sprint 1 : PHPUnit 11.5, résorption des dépréciations, Rector en mode composer-based pour les attributs.
  • Sprint 2 : PHPUnit 12.5.11 ou plus, correction des expects() et with() sur stubs, remplacement de getMockForAbstractClass() et de any(), failOnDeprecation en CI.
  • Sprint 3 : PHP 8.4 en CI, PHPUnit ^13.4, Infection à jour, phpunit.xml nettoyé. Puis adoption de withParameterSetsInOrder(), des assertArrays* et de seal() par lots, et éventuellement requireSealedMockObjects avec failOnRisky.

Le coût de PHPUnit 13 ne vient pas de ses nouveautés : il vient de la dette de mocks que la suite accumulait depuis PHPUnit 9. Traitez any() et les stubs bavards comme des décisions de test, pas comme des renommages. Et visez la 13 avant février 2027 : la 14 sera là, et la 12 sortira du support. Une fois la suite migrée, vérifiez qu’elle teste encore quelque chose : le mutation testing est le bon juge.

Migrer la suite de tests est rarement perçu comme le vrai sujet. C’est pourtant le préalable discret à la montée de PHP et de Symfony, et c’est lui qui s’éternise quand il n’est pas planifié. Si votre suite est restée bloquée en PHPUnit 9 ou 10 faute de temps, c’est le type de chantier que nous prenons en charge, du passage Rector jusqu’aux Merge Requests testées, dans le cadre de Bear Upgrade.

Cet article vous a été utile ? Partagez-le.

Portrait de Flavien Métivier, fondateur de The Bearded Bear : barbe longue, lunettes, sur fond sombre

Écrit par

Flavien Métivier

Expert PHP/Symfony indépendant depuis 2014, ancien CTO de Kartable et d'Extrabat. Plus de 80 projets menés, de l'audit à la migration.

Mon parcours

À lire ensuite

Migration Symfony en vue ?

Bear Upgrade migre votre codebase 2,5× à 4,5× moins cher qu'une ESN, selon sa maturité.

Newsletter Bear Bytes

Veille Claude Code, Symfony et PHP, retours d'expérience terrain et conseils techniques, directement dans votre boîte mail.