Aller au contenu principal
Retour au blog

PHPUnit 11 : migrer des annotations aux attributs PHP 8

Flavien Métivier8 octobre 20248 min

Sebastian Bergmann a publié PHPUnit 11 le 2 février 2024 avec un message sans ambiguïté : les annotations docblock ont fait leur temps. Les @dataProvider, @covers, @before et @group nichés dans tes commentaires sont désormais officiellement dépréciés — PHPUnit 12 les supprimera purement et simplement. La bonne nouvelle : PHP 8 embarque exactement l'outil pour les remplacer, les attributs natifs. Ce guide te montre comment migrer un projet Symfony existant sans casser un seul test, en automatisant l'essentiel avec Rector et en gardant les yeux ouverts sur les pièges qui font trébucher même les développeurs aguerris.

PHPUnit 11 : pourquoi les annotations docblock doivent disparaître

Depuis PHPUnit 9, les attributs PHP 8 coexistaient avec les annotations. PHPUnit 10 les a stabilisés. PHPUnit 11 franchit le pas : toute annotation docblock déclenche désormais un warning de dépréciation à l'exécution, et PHPUnit 12 les supprimera définitivement (voir phpunit.de/announcements/phpunit-11.html). La logique est imparable : les attributs PHP 8 sont typés, vérifiés par le moteur PHP, découvrables par les IDE et résistants aux fautes de frappe. Les annotations docblock, elles, vivaient dans des chaînes de caractères opaques ignorées par PHP lui-même — un anachronisme à l'heure où PHP 8.3 pousse vers plus de rigueur statique. Voici la table de correspondance complète :

  • @dataProvider provideData → #[DataProvider('provideData')]
  • @covers \App\Service\Foo → #[CoversClass(Foo::class)]
  • @covers \App\Service\Foo::method → #[CoversMethod(Foo::class, 'method')]
  • @coversNothing → #[CoversNothing]
  • @uses \App\Service\Bar → #[UsesClass(Bar::class)]
  • @before / @after → #[Before] / #[After]
  • @beforeClass / @afterClass → #[BeforeClass] / #[AfterClass]
  • @group integration → #[Group('integration')]
  • @depends testFoo → #[Depends('testFoo')]
  • @runInSeparateProcess → #[RunInSeparateProcess]
  • @runTestsInSeparateProcesses (classe) → #[RunTestsInSeparateProcesses]
  • @backupGlobals enabled → #[BackupGlobals(true)]

Avant / Après : le changement en conditions réelles

Voici un test typique dans un projet Symfony 7.1 qui vérifie un service de calcul de factures. C'est probablement la forme que tu as en production aujourd'hui — annotations docblock dans les commentaires, data provider en méthode d'instance.

<?php

namespace App\Tests\Service;

use App\Service\InvoiceCalculator;
use PHPUnit\Framework\TestCase;

/**
 * @covers \App\Service\InvoiceCalculator
 * @group invoice
 */
class InvoiceCalculatorTest extends TestCase
{
    private InvoiceCalculator $calculator;

    /**
     * @before
     */
    public function setUp(): void
    {
        $this->calculator = new InvoiceCalculator(taxRate: 0.20);
    }

    /**
     * @dataProvider provideAmountsAndExpected
     */
    public function testCalculateTotal(int $amountCents, int $expectedCents): void
    {
        self::assertSame($expectedCents, $this->calculator->calculateTotal($amountCents));
    }

    public function provideAmountsAndExpected(): array
    {
        return [
            'zero'            => [0, 0],
            'cent euros'      => [10000, 12000],
            'valeur négative' => [-500, -600],
        ];
    }
}

Même classe, version migrée vers les attributs PHP 8 — compatible PHPUnit 11 :

<?php

namespace App\Tests\Service;

use App\Service\InvoiceCalculator;
use PHPUnit\Framework\Attributes\Before;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\TestCase;

#[CoversClass(InvoiceCalculator::class)]
#[Group('invoice')]
class InvoiceCalculatorTest extends TestCase
{
    private InvoiceCalculator $calculator;

    #[Before]
    public function setUp(): void
    {
        $this->calculator = new InvoiceCalculator(taxRate: 0.20);
    }

    #[DataProvider('provideAmountsAndExpected')]
    public function testCalculateTotal(int $amountCents, int $expectedCents): void
    {
        self::assertSame($expectedCents, $this->calculator->calculateTotal($amountCents));
    }

    public static function provideAmountsAndExpected(): array
    {
        return [
            'zero'            => [0, 0],
            'cent euros'      => [10000, 12000],
            'valeur négative' => [-500, -600],
        ];
    }
}

Trois différences sautent aux yeux : les imports explicites des attributs en tête de fichier, les annotations supprimées des docblocks, et — point capital — provideAmountsAndExpected est désormais déclarée static. Ce dernier point est le piège numéro un de la migration, et nous y revenons en détail.

Migrer avec Rector : automatiser 80 % du travail

Convertir des dizaines de fichiers de test à la main, c'est une perte de temps et une source d'erreurs assurée. Rector — l'outil de refactoring PHP automatisé — dispose d'un set dédié à la migration PHPUnit. Installe-le en dépendance de développement, configure-le sur ton répertoire tests/, valide les changements en dry-run, puis applique-les.

composer require --dev rector/rector
<?php

// rector.php à la racine du projet
declare(strict_types=1);

use Rector\Config\RectorConfig;
use Rector\PHPUnit\Set\PHPUnitSetList;

return RectorConfig::configure()
    ->withPaths([
        __DIR__ . '/tests',
    ])
    ->withSets([
        PHPUnitSetList::ANNOTATIONS_TO_ATTRIBUTES,
    ]);
# Dry-run : voir les changements sans modifier les fichiers
vendor/bin/rector process --dry-run

# Application réelle de la migration
vendor/bin/rector process

# Relancer la suite pour valider
php bin/phpunit

En quelques secondes sur un projet moyen, Rector convertit les annotations, ajoute les imports use PHPUnit\Framework\Attributes\... nécessaires et rend les data providers static dans la grande majorité des cas. Il reste néanmoins des angles morts importants à connaître avant de pousser en production.

Le piège numéro un : les data providers doivent être statiques

C'est le changement qui génère le plus de casse lors d'une migration partielle. Avec les annotations docblock, PHPUnit acceptait les data providers comme méthodes d'instance ordinaires. Avec l'attribut #[DataProvider], c'est terminé : la méthode doit impérativement être déclarée static. Un oubli et PHPUnit 11 lève une erreur explicite à l'exécution.

// ❌ PHPUnit 11 refuse — la méthode doit être static
#[DataProvider('provideData')]
public function testSomething(string $input): void
{
    // ...
}

public function provideData(): array // Oubli du static → erreur
{
    return [['foo'], ['bar']];
}

// ✅ Correct
#[DataProvider('provideData')]
public function testSomething(string $input): void
{
    // ...
}

public static function provideData(): array
{
    return [['foo'], ['bar']];
}

Votre dette technique s'accumule ?

Demander un audit

Rector gère ce cas dans la grande majorité des situations. Il peut néanmoins rater les data providers définis dans des trait utilisés par plusieurs classes de test, ou dans une classe abstraite héritée. Après avoir lancé Rector, effectue cette recherche ciblée :

# Lister les méthodes commençant par 'provide' qui ne sont pas static
grep -rn 'public function provide' tests/ | grep -v 'static'

Chaque résultat est un data provider potentiellement non-statique à corriger à la main. Ajoute static devant la déclaration — la logique interne ne change pas, un data provider ne doit de toute façon pas dépendre de l'état de l'objet.

Impossible de mixer annotations et attributs dans le même fichier

PHPUnit 11 impose une règle de coexistence stricte : dans un fichier de test donné, tu dois utiliser soit les annotations docblock, soit les attributs PHP 8. Dès qu'un attribut est détecté sur la classe ou l'une de ses méthodes, PHPUnit ignore silencieusement les annotations restantes du même fichier — sans toujours émettre un warning explicite. Le résultat peut être des tests qui passent mais ne couvrent pas ce qu'ils sont censés couvrir, ou des data providers ignorés en silence.

// ❌ Mélange interdit dans PHPUnit 11 — comportement silencieusement cassé
#[CoversClass(UserService::class)]  // Attribut sur la classe
class UserServiceTest extends TestCase
{
    /**
     * @dataProvider provideUsers  // Annotation IGNORÉE silencieusement
     */
    public function testCreate(array $userData): void
    {
        // Ce test sera lancé sans dataset ou lèvera une erreur
    }

    public function provideUsers(): array
    {
        return [[['name' => 'Alice']]];
    }
}

Rector migre chaque fichier entièrement en une seule passe, évitant ce problème par construction. Si tu migres manuellement ou que tu traites des fichiers partiellement modifiés, applique cette règle sans exception : un fichier touché doit être migré intégralement en une seule fois. Ne laisse jamais un fichier en état hybride entre deux commits.

Ce que Rector ne couvre pas : la checklist manuelle

Rector est un excellent filet de sécurité, mais certains cas lui échappent structurellement. Passe ces points en revue après chaque migration :

  • Data providers dans des trait non inclus dans le chemin Rector : ajoute le dossier contenant les traits dans withPaths() si nécessaire
  • @covers avec méthode cible (ex. @covers ::calculate) : correspond à #[CoversMethod(Foo::class, 'calculate')], syntaxe différente à vérifier manuellement
  • @depends chaînés sur plusieurs niveaux : valide le comportement après migration, le nom passé à #[Depends()] est sensible à la casse exacte de la méthode
  • @runTestsInSeparateProcesses au niveau classe (différent de @runInSeparateProcess sur une méthode) : correspond à #[RunTestsInSeparateProcesses] sur la classe
  • Classes de test abstraites héritées par plusieurs suites : migre la classe parent en premier, puis les classes enfants

Configurer PHPUnit 11 pour détecter les annotations restantes

PHPUnit 11 émet des warnings de dépréciation pour chaque annotation encore présente dans tes fichiers de test. Passe failOnDeprecation à true dans ta configuration pour transformer ces warnings en échecs de tests — c'est le garde-fou le plus efficace pour ne rien laisser passer après la migration.

<?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"
         displayDetailsOnTestsThatTriggerDeprecations="true">
    <testsuites>
        <testsuite name="Project Test Suite">
            <directory>tests</directory>
        </testsuite>
    </testsuites>
    <source>
        <include>
            <directory>src</directory>
        </include>
    </source>
</phpunit>

Lance ensuite ta suite complète avec l'affichage explicite des dépréciations. Chaque annotation résiduelle apparaîtra clairement dans la sortie, avec le fichier et le numéro de ligne.

# Affichage explicite des dépréciations pendant l'exécution
php vendor/bin/phpunit --display-deprecations

# Dans un projet Symfony avec le bridge de test :
php bin/phpunit --display-deprecations

# Pour ne lancer que les tests impactés par un groupe donné
php bin/phpunit --group invoice --display-deprecations

Une fois la suite au vert sans aucune dépréciation, tu peux retirer failOnDeprecation — ou la laisser en place, ce qui est une bonne pratique : elle te prémunira aussi contre les dépréciations futures introduites par PHPUnit lui-même entre deux versions mineures.

Conclusion : une migration qui en vaut la peine

La migration vers les attributs PHP 8 dans PHPUnit 11 est l'une des rares évolutions qui améliore simultanément la lisibilité du code, la découvrabilité par les IDE et la robustesse statique de la suite de tests. Avec Rector, l'essentiel se règle en une commande. Les vrais pièges — data providers non-statiques, mélange annotations/attributs dans le même fichier, cas dans les traits et classes abstraites — sont tous identifiables et corrigeables méthodiquement. Si tu gères un projet Symfony legacy avec des centaines de fichiers de test et que tu veux une migration propre, planifiée et sans régression, le service Bear Upgrade est fait pour ça : migration PHPUnit 11 cadencée, revue de la couverture de code et rapport de dette technique inclus.

Cet article vous a plu ? Partagez-le !

Votre dette technique s'accumule ?

Audit complet en 10 jours. Recommandations priorisées et actionnables.