@test
assert
PHPUnit · Migration · PHP 8.4 · Magento 2
PHPUnit Deprecations und Upgrades
in Altprojekten sauber fahren

Wer ein gewachsenes PHP-Projekt auf PHPUnit 10 oder 11 hebt, begegnet einer Flut von Deprecation-Notices, geänderten Assertion-Signaturen und entfernten Interfaces. Dieser Artikel zeigt einen strukturierten Weg durch die Migration – ohne die laufende Testsuite zu gefährden und ohne jede Testklasse auf einmal anfassen zu müssen.

15 Min. Lesezeit PHPUnit 9 → 10 → 11 · Assertions · Hooks · Magento PHP 8.2+ · Composer · CI/CD

1. Warum PHPUnit-Upgrades in Altprojekten so oft eskalieren

PHPUnit-Upgrades gelten in vielen Teams als Routineaufgabe, bis das erste Projekt tatsächlich migriert wird. Dann zeigt sich die Realität: Hunderte von Testklassen verwenden die seit Jahren als deprecated markierten Methoden assertFileNotExists, assertRegExp oder withConsecutive. Andere Klassen erben von PHPUnit\Framework\TestCase-Subklassen, die Magento oder andere Frameworks selbst mitbringen – und die ihrerseits auf veraltete interne APIs setzen. Dazu kommen Bootstrap-Skripte, die mit der neuen Konfigurationsstruktur von PHPUnit 10 nicht mehr kompatibel sind.

Das eigentliche Problem ist nicht der Umfang der Änderungen, sondern ihre Unsichtbarkeit. PHPUnit 9 zeigt Deprecation-Notices nur, wenn der entsprechende Code tatsächlich ausgeführt wird – und nur dann, wenn --display-deprecations gesetzt ist oder der eigene Code die Warnungen nicht unterdrückt. Ohne eine systematische Bestandsaufnahme tappt man blind in die Migration und stellt erst beim Versuch, die neue Version zu installieren, fest, dass man 300 Stellen anpassen muss.

Der strukturierte Weg durch eine solche Migration besteht aus vier Phasen: Inventarisierung, schrittweise Anpassung auf einer separaten Branch, Absicherung der CI-Pipeline während der Migrationsphase und abschließende Bereinigung. Wer versucht, PHPUnit 9 und 11 gleichzeitig zu betreiben, wird scheitern – Composer erlaubt es nicht, und Polyfill-Pakete lösen nur einen Teil der Probleme.

2. Inventar erstellen: Deprecations sichtbar machen

Bevor die erste Zeile Code angefasst wird, verschafft man sich einen vollständigen Überblick über alle betroffenen Stellen. Der einfachste Weg: PHPUnit 9 mit aktivierter Deprecation-Ausgabe gegen die vollständige Testsuite laufen lassen und die Ausgabe in eine Datei leiten. Das Ergebnis zeigt, welche Methoden betroffen sind und wie oft sie verwendet werden.

Ergänzend hilft ein einfaches grep über das gesamte Testverzeichnis, um veraltete Methodennamen zu finden, die eventuell in abstrakten Basisklassen stecken und von Dutzenden Testklassen geerbt werden. Solche Stellen haben den größten Hebel, weil eine Änderung viele Tests gleichzeitig bereinigt. Tools wie rector/rector mit dem PHPUnit-Set können einen Großteil der mechanischen Umbenennung automatisieren.


<?php
// Inventory step 1: run with full deprecation output
// vendor/bin/phpunit --display-deprecations 2>&1 | tee /tmp/phpunit-deprecations.txt

// Inventory step 2: grep for known deprecated methods
// grep -rn "assertRegExp\|assertNotRegExp\|assertFileNotExists\|withConsecutive\|getMockBuilder" tests/

// Inventory step 3: rector dry-run to see what would change automatically
// vendor/bin/rector process tests/ --dry-run --config rector.php

// rector.php — PHPUnit migration config
use Rector\Config\RectorConfig;
use Rector\PHPUnit\Set\PHPUnitSetList;

return RectorConfig::configure()
    ->withPaths([__DIR__ . '/tests'])
    ->withSets([
        PHPUnitSetList::PHPUNIT_90,
        PHPUnitSetList::PHPUNIT_100,
        PHPUnitSetList::ANNOTATIONS_TO_ATTRIBUTES,
    ])
    ->withPhpSets(php84: true);

Rector erledigt die rein mechanischen Umbenennungen zuverlässig: assertRegExp zu assertMatchesRegularExpression, assertFileNotExists zu assertFileDoesNotExist, PHPDoc-Annotationen zu PHP-8-Attributen. Was Rector nicht kann: die Logik hinter withConsecutive-Chains umschreiben, denn das erfordert inhaltliches Verständnis des Tests. Diese Stellen müssen manuell überarbeitet werden – aber das Inventar zeigt genau, wo sie sind.

3. Von PHPUnit 9 auf 10: die größten Bruchstellen

PHPUnit 10 ist der erste wirklich breaking Release seit langer Zeit. Die Konfigurationsdatei phpunit.xml hat eine neue Struktur – die alten Attribute cacheResultFile, executionOrder und die Syntax für Testsuiten haben sich geändert. PHPUnit 10 verweigert den Start, wenn die Konfiguration nicht der neuen XSD entspricht. Das Schema-Upgrade erledigt PHPUnit selbst: vendor/bin/phpunit --migrate-configuration passt die Datei automatisch an.

Die wichtigste inhaltliche Änderung betrifft withConsecutive: die Methode wurde vollständig entfernt. Sie erlaubte es, einem Mock für aufeinanderfolgende Aufrufe jeweils andere Argumente und Rückgabewerte zu definieren. Der Ersatz ist eine explizite Implementierung mit einem Zähler oder einer Queue. Das ist mehr Code, aber deutlich verständlicher und weniger fehleranfällig bei Refactorings der Produktionsklasse.


<?php
declare(strict_types=1);

namespace Mironsoft\Tests\Unit;

use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\DataProvider;

// BEFORE (PHPUnit 9): withConsecutive — removed in PHPUnit 10
// $mock->expects($this->exactly(3))
//      ->method('process')
//      ->withConsecutive(['a'], ['b'], ['c'])
//      ->willReturnOnConsecutiveCalls(1, 2, 3);

// AFTER (PHPUnit 10+): explicit queue approach
final class OrderProcessorTest extends TestCase
{
    #[Test]
    public function processesItemsInSequence(): void
    {
        $calls = [['a'], ['b'], ['c']];
        $returns = [1, 2, 3];
        $callIndex = 0;

        $mock = $this->createMock(ItemProcessor::class);
        $mock->expects($this->exactly(3))
             ->method('process')
             ->willReturnCallback(function (string $item) use (&$callIndex, $calls, $returns): int {
                 $this->assertSame($calls[$callIndex][0], $item);
                 return $returns[$callIndex++];
             });

        $processor = new OrderProcessor($mock);
        $result = $processor->run(['a', 'b', 'c']);

        $this->assertSame([1, 2, 3], $result);
    }
}

4. Von PHPUnit 10 auf 11: Hooks, Attributes und weitere Bereinigungen

PHPUnit 11 konsolidiert den Schritt hin zu PHP-8-Attributen und entfernt endgültig die Unterstützung für PHPDoc-Annotationen als Steuerungsformat. Tests, die noch /** @test */ oder /** @dataProvider */ als PHPDoc-Kommentar verwenden, werden ignoriert – sie laufen nicht mehr, ohne eine Fehlermeldung zu erzeugen. Das ist besonders heimtückisch: ein Test, der still ignoriert wird, erscheint als grünes Kreuz in der CI, obwohl er nie ausgeführt wurde.

Die Migration der Annotationen erledigt Rector mit dem Set PHPUnitSetList::ANNOTATIONS_TO_ATTRIBUTES vollautomatisch. Alle @test-Annotationen werden zu #[Test]-Attributen, @dataProvider zu #[DataProvider('methodName')], @depends zu #[Depends('testMethodName')]. Nach dem Rector-Lauf sollte die Testsuite vollständig grün sein – mit derselben Anzahl ausgeführter Tests wie vorher.


<?php
declare(strict_types=1);

namespace Mironsoft\Tests\Unit\Catalog;

use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Depends;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use Mironsoft\Catalog\Service\PriceCalculator;

// BEFORE PHPUnit 11 (annotations — silently ignored in PHPUnit 11):
// /**
//  * @test
//  * @dataProvider priceProvider
//  */
// public function calculatesCorrectPrice(float $net, float $vat, float $expected): void

// AFTER PHPUnit 11 (PHP attributes — correct):
#[CoversClass(PriceCalculator::class)]
final class PriceCalculatorTest extends TestCase
{
    #[Test]
    #[DataProvider('priceProvider')]
    public function calculatesCorrectPrice(float $net, float $vat, float $expected): void
    {
        $calculator = new PriceCalculator();
        $this->assertEqualsWithDelta($expected, $calculator->gross($net, $vat), 0.001);
    }

    /**
     * @return array<string, array{float, float, float}>
     */
    public static function priceProvider(): array
    {
        return [
            'standard rate DE' => [100.0, 19.0, 119.0],
            'reduced rate DE'  => [100.0,  7.0, 107.0],
            'zero rate'        => [100.0,  0.0, 100.0],
        ];
    }
}

5. Veraltete Assertions modernisieren

PHPUnit hat über die Jahre viele Assertion-Methoden umbenannt, die veralteten Varianten aber lange mitgeführt. In PHPUnit 10 und 11 werden diese Aliase endgültig entfernt. Die Liste der häufigsten Umbenennungen ist überschaubar, aber die Anzahl der betroffenen Stellen in einem gewachsenen Projekt kann in die Hunderte gehen. Rector erledigt die mechanischen Umbenennungen, aber es lohnt sich, die Änderungen manuell zu verstehen – denn einige Umbenennungen ändern auch die Semantik leicht.

Besondere Vorsicht gilt bei assertEquals gegenüber assertSame. Ersteres vergleicht mit == (typ-loose), letzteres mit === (typ-strikt). In vielen Altprojekten wird assertEquals für Integer-Vergleiche verwendet, wo eigentlich assertSame gemeint ist. Das ist kein Verstoß gegen die neue PHPUnit-API, aber eine latente Schwachstelle im Test: assertEquals(0, false) ist wahr, assertSame(0, false) ist falsch. Die Migration ist ein guter Moment, diese Stellen zu bereinigen.

6. Magento 2: Besonderheiten beim PHPUnit-Upgrade

Magento 2 koppelt PHPUnit über eigene Testrahmen-Klassen wie Magento\TestFramework\TestCase\AbstractController und Magento\Framework\TestFramework\Unit\BaseTestCase. Diese Klassen erben von PHPUnit und enthalten eigene Lifecycle-Hooks und Assertions. Bei einem PHPUnit-Upgrade muss deshalb auch das Magento-Framework selbst kompatibel sein – was bedeutet, dass man nicht einfach PHPUnit auf 11 updaten kann, wenn Magento 2.4.7 noch PHPUnit 9 voraussetzt.

Der praktische Weg in Magento-Projekten: Erst auf die Magento-Version updaten, die das neue PHPUnit offiziell unterstützt. Magento 2.4.8 unterstützt PHPUnit 10. Danach können eigene Module und Testklassen schrittweise auf die neue API migriert werden. Eigene Bootstrap-Dateien in dev/tests/unit/framework/bootstrap.php müssen auf die neue Konfigurationsstruktur angepasst werden. Besonders anfällig: das objectManager-Pattern in Unit-Tests, das in Magento-Projekten häufig vorkommt, aber in reinen PHPUnit-Tests als Anti-Pattern gilt.


<?php
declare(strict_types=1);

namespace Mironsoft\Catalog\Test\Unit\Model;

use Magento\Framework\TestFramework\Unit\Helper\ObjectManager;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use Mironsoft\Catalog\Model\ProductEnricher;
use Mironsoft\Catalog\Api\Data\ProductInterface;

/**
 * PHPUnit 10-compatible unit test for Magento 2 module.
 * Avoids deprecated ObjectManager helper in favour of direct constructor injection.
 */
#[CoversClass(ProductEnricher::class)]
final class ProductEnricherTest extends TestCase
{
    private ProductEnricher $enricher;

    protected function setUp(): void
    {
        // Prefer direct DI over ObjectManager in unit tests
        $priceService = $this->createMock(\Mironsoft\Catalog\Api\PriceServiceInterface::class);
        $logger       = $this->createMock(\Psr\Log\LoggerInterface::class);

        $this->enricher = new ProductEnricher($priceService, $logger);
    }

    #[Test]
    public function enrichAddsGrossPriceToProduct(): void
    {
        $product = $this->createMock(ProductInterface::class);
        $product->method('getNetPrice')->willReturn(100.0);
        $product->expects($this->once())->method('setGrossPrice')->with(119.0);

        $this->enricher->enrich($product, 19.0);
    }
}

7. CI-Pipeline für die Migrationsphase absichern

Während der Migrationsphase laufen alte und neue Tests parallel. Die CI-Pipeline muss so konfiguriert sein, dass Deprecation-Notices als Warnungen sichtbar sind, aber die Pipeline nicht sofort brechen. Das erreicht man mit --display-deprecations und einem separaten Qualitätsgate, das die Anzahl der Deprecations über die Zeit messen und bei Regressions reagieren kann. Ein einfacher Ansatz: die Deprecation-Ausgabe in eine Datei leiten und deren Zeilenzahl als Metrik speichern.

Composer-Constraints während der Migration: "phpunit/phpunit": "^9.6 || ^10.5" erlaubt das parallele Testen auf beiden Versionen. Sobald alle Tests auf 10 lauffähig sind, wird der Constraint auf "^10.5" eingeengt. Für Magento-Projekte empfiehlt sich ein separater CI-Job, der ausschließlich die eigenen Modul-Tests unter der neuen PHPUnit-Version laufen lässt – die Magento-Core-Tests laufen weiter unter der vom Framework vorgeschriebenen Version.

8. Vorher-Nachher-Vergleich: kritische Änderungen

Die folgende Tabelle zeigt die wichtigsten API-Änderungen beim Upgrade von PHPUnit 9 auf 11 und die entsprechenden Ersetzungen. Alle Einträge in der linken Spalte führen in PHPUnit 10 zu einer Deprecation-Notice und in PHPUnit 11 zu einem Fehler.

PHPUnit 9 (veraltet) PHPUnit 10/11 (korrekt) Automatisierbar? Hinweis
assertRegExp() assertMatchesRegularExpression() Rector Reines Umbenennen
withConsecutive() willReturnCallback() mit Queue Manuell Logik-Änderung nötig
/** @test */ #[Test] Rector In PHPUnit 11 pflicht
assertFileNotExists() assertFileDoesNotExist() Rector Reines Umbenennen
phpunit.xml alte Struktur --migrate-configuration PHPUnit selbst Einmalig ausführen

Die Tabelle zeigt, dass der Großteil der Änderungen automatisierbar ist. Der Aufwand konzentriert sich auf die Stellen mit withConsecutive, die manuell umgeschrieben werden müssen, und auf eigene Bootstrap-Dateien, die die neue phpunit.xml-Struktur berücksichtigen müssen. In Projekten mit Tausenden von Tests ist es sinnvoll, zuerst die automatisierbaren Stellen per Rector zu bereinigen und danach die verbleibenden manuellen Anpassungen mit grep-Listen zu priorisieren.

9. Zusammenfassung

PHPUnit-Upgrades in Altprojekten sind planbar und risikoarm durchführbar, wenn man vier Schritte einhält: Inventar mit --display-deprecations und grep erstellen, automatisierbare Änderungen per Rector durchführen, manuelle Stellen (insbesondere withConsecutive) priorisieren und die CI-Pipeline für die Übergangsphase mit parallelen Composer-Constraints absichern. Magento-Projekte erfordern zusätzlich, dass die Magento-Version das neue PHPUnit unterstützt, bevor eigene Tests migriert werden.

Die Migration ist auch eine Chance: PHPUnit 11 mit PHP-8-Attributen ist deutlich lesbarer als annotationsbasierte Tests. #[Test], #[DataProvider] und #[CoversClass] sind typsicher, IDE-unterstützt und verlangen kein Parsen von Docblöcken mehr. Wer die Migration nicht aufschiebt, profitiert außerdem von der verbesserten Fehlerberichterstattung und den neuen Assertion-Methoden in PHPUnit 11.

PHPUnit-Upgrade — Das Wichtigste auf einen Blick

Inventar zuerst

--display-deprecations und grep vor der ersten Code-Änderung. Rector zeigt, was automatisierbar ist.

withConsecutive manuell

Kein Rector-Ersatz möglich. willReturnCallback mit internem Zähler ist der sauberste Ersatz.

Annotationen zu Attributen

In PHPUnit 11 werden @test-Annotations still ignoriert. Rector migriert vollautomatisch auf #[Test].

Magento-Timing

Erst Magento auf eine Version updaten, die PHPUnit 10 unterstützt (Magento 2.4.8). Dann eigene Tests migrieren.

10. FAQ: PHPUnit Deprecations und Upgrades in Altprojekten

1PHPUnit 9 und 11 gleichzeitig nutzen?
Nein. Composer erlaubt nur eine installierte Version. Übergangsphase: ^9.6 || ^10.5 als Constraint mit zwei CI-Jobs.
2Schnellster Weg alle Deprecations zu finden?
--display-deprecations kombiniert mit grep über das Testverzeichnis und Rector --dry-run.
3Was ersetzt withConsecutive in PHPUnit 10?
willReturnCallback mit internem Zähler oder Queue. Kein Rector-Ersatz – manuelle Anpassung nötig.
4@test-Annotationen in PHPUnit 11 als Fehler?
Nein – sie werden still ignoriert. Der Test läuft nicht, die CI bleibt grün. Gefährlich. Rector migriert auf #[Test].
5phpunit.xml auf neue Struktur migrieren?
vendor/bin/phpunit --migrate-configuration aktualisiert die Datei einmalig auf die neue XSD-Struktur.
6Rector löst alle Deprecations?
Den Großteil: Umbenennungen, Annotation-zu-Attribut-Migrationen. Nicht automatisierbar: withConsecutive und Bootstrap-Dateien.
7Welche Magento-Version unterstützt PHPUnit 10?
Magento 2.4.8. Für ältere Versionen bleibt man bei PHPUnit 9, ohne PHP-8.4-Unterstützung.
8assertEquals vs. assertSame für Integer?
assertSame ist korrekt – typ-strict mit ===. assertEquals ist typ-loose und würde assertEquals(0, false) als wahr werten.
9ObjectManager in Magento-Unit-Tests vermeiden?
Direkte Konstruktor-Injektion mit createMock() verwenden. ObjectManager verschleiert den Abhängigkeitsgraphen und ist ein Anti-Pattern in Unit-Tests.
10Wie lange dauert ein PHPUnit-9-auf-10-Upgrade?
Mit Rector und ~500 Testmethoden: 1-2 Tage automatisierbar. Manuelle withConsecutive-Migrationen und Bootstrap: 1-3 weitere Tage.