PHPUnit 13.0 Migration

PHPUnit 13.0 Migration

PHPUnit 13.0 ist veröffentlicht und erfordert ab sofort PHP 8.4, was für viele Projekte ein umfassendes Runtime-Upgrade erzwingt. Dieses Major-Release liefert neben Sealed Doubles und neuen Array-Assertions endlich offizielle Lösungen für den berüchtigten `withConsecutive()`-Pain-Point. Bereiten Sie sich auf die unvermeidliche Migration vor, die dank dieser Neuerungen an entscheidender Stelle weniger schmerzhaft ausfällt.

Dennis Schwenker-Sanders 12 Min. Lesezeit

Sealed Doubles, neue Array-Assertions und das Ende von withConsecutive()

Am 6. Februar 2026 veröffentlichte Sebastian Bergmann PHPUnit 13.0. Das Major-Release bringt die PHP-8.4-Pflicht, neue Array-Assertions mit dreistufiger Vergleichslogik (Strictness, Keys, Reihenfolge), einen lang erwarteten Ersatz für withConsecutive() und die neue seal()-Methode für Test-Doubles. Für jedes PHP-Projekt mit Test-Suite ist das relevant – und die PHP-8.4-Pflicht erzwingt bei vielen Teams ein gleichzeitiges Runtime-Upgrade. Die Herausforderung: PHPUnit 12 erhält nur noch bis Februar 2027 Bugfixes. Wer noch Deprecation-Warnings aus PHPUnit 12.5 hat, sollte diese vor dem Upgrade auf 13.0 beheben. Die Frage für Ihr Team: Wie komplex wird die Migration – und wann ist der richtige Zeitpunkt?

Die Erfahrung aus früheren Major-Upgrades (PHPUnit 9 → 10) zeigt: Der withConsecutive()-Ersatz nimmt 95% der Migrationszeit in Anspruch, der Rest nur 5%. PHPUnit 13 liefert jetzt offizielle Lösungen für dieses Pain-Point. Für Symfony-Projekte, wo PHPUnit das Standard-Testing-Framework ist, bedeutet das: Die Migration ist unvermeidlich, aber erstmals weniger schmerzhaft als bei PHPUnit 10. Das betrifft nicht nur Unit-Tests – auch Functional Tests, Integration-Tests und CI/CD-Pipelines müssen angepasst werden.

PHP 8.4 als Minimum-Requirement: Was bedeutet das für bestehende Projekte?

PHPUnit 13 erfordert PHP 8.4 oder höher. PHP 8.3 und älter werden nicht mehr unterstützt. Die Begründung: PHP-Core-Development fokussiert aktuell nur noch auf PHP 8.4 und 8.5. Aus meiner Projekterfahrung ist das die größte Hürde für KMU und Agenturen. Viele Teams laufen noch auf PHP 8.2 oder 8.3, weil das neueste LTS-Release für ihre Hosting-Provider gerade erst verfügbar wurde.

Die praktische Konsequenz: PHPUnit 13 erzwingt ein Runtime-Upgrade. Das ist kein isoliertes Test-Framework-Update – es betrifft die gesamte Development-, Staging- und Production-Umgebung. Ein typisches Szenario, das ich regelmäßig sehe: Ein Team will PHPUnit aktualisieren, um Deprecation-Warnings loszuwerden. Aber PHP 8.4 ist auf dem Shared-Hosting-Tarif nicht verfügbar, die CI/CD-Pipeline läuft noch mit PHP 8.2-Docker-Images, und Production nutzt PHP 8.3 auf Debian-Stable-Repositories.

Warum PHPUnit die PHP-8.4-Pflicht rechtfertigt

Sebastian Bergmann argumentiert: "We are not going to support PHP versions that are no longer actively developed by the PHP project itself." Das ist konsistent mit PHPUnits Policy seit Version 10. PHP 8.3 erhält Security-Fixes bis 23. Dezember 2026 – danach ist es End-of-Life. PHP 8.4 ist seit November 2024 verfügbar und bringt Property Hooks, Asymmetric Visibility und weitere Features, die Testing-Code lesbarer machen.

Ein häufiger Einwand, den ich höre: "Aber unsere Production-App läuft problemlos auf PHP 8.2, warum sollten wir upgraden nur wegen Tests?" Die Realität: Test-Code ist Code. Wenn PHPUnit nicht mehr mit Ihrer PHP-Version kompatibel ist, können Sie keine neuen Tests schreiben, bestehende Tests nicht anpassen, und CI-Pipelines schlagen fehl. Das blockiert Feature-Development.

Neue Array-Assertions: Präzise Vergleichslogik statt One-Size-Fits-All

PHPUnit 13 führt neue Array-Assertions ein, die dreistufig konfigurierbar sind: Strictness (type-safe vs. loose comparison), Keys (berücksichtigen vs. ignorieren), Reihenfolge (relevant vs. egal). Das löst ein Problem, das seit Jahren existiert: assertEquals() und assertSame() differenzieren nicht fein genug für Array-Vergleiche.

Die bisherigen Workarounds: assertEqualsCanonicalizing() sortiert Arrays vor dem Vergleich (nützlich für Order-agnostic Checks), aber ignoriert Keys nicht. assertEquals($expected, $actual, '', 0.0, 10, true) mit Canonicalize-Flag war deprecated und verwirrend. Community-Packages wie seec/phpunit-consecutive-params oder digitalrevolution/phpunit-extensions füllten die Lücken.

Die neuen Assertions im Detail

PHPUnit 13 bietet jetzt spezialisierte Assertions:

// Vergleich mit Type-Safety (===)
$this->assertArrayEquals($expected, $actual);

// Loose Comparison (==), Keys relevant, Order relevant
$this->assertArrayEqualsLoose($expected, $actual);

// Keys ignorieren, Order beachten
$this->assertArrayEqualsIgnoringKeys($expected, $actual);

// Order ignorieren (sortiert intern), Keys relevant
$this->assertArrayEqualsCanonicalizing($expected, $actual);

Was die Dokumentation nicht hervorhebt: Diese Assertions sind kombinierbar. Sie können Strictness, Keys und Order unabhängig konfigurieren. Das vermeidet Custom-Helper-Funktionen, die in jedem Projekt anders implementiert sind. Aus der Praxis: Ich sehe in Code-Audits regelmäßig 5-10 verschiedene Custom-Array-Comparison-Helper pro Projekt – PHPUnit 13 macht das obsolet.

Wann welche Assertion verwenden?

API-Response-Tests: assertArrayEqualsCanonicalizing() – Order ist bei JSON-APIs oft nicht garantiert, aber Keys und Types müssen stimmen.

Database-Query-Results: assertArrayEquals() – Order matters (SQL ORDER BY), Keys matters (Column Names), Types matters (Strong Typing).

Configuration-Arrays: assertArrayEqualsIgnoringKeys() – Numerische Keys sind Implementierungsdetails, aber Values und Order zählen.

Legacy-Migration-Tests: assertArrayEqualsLoose() – Beim Refactoring von loosely-typed Code hilft Loose Comparison, um Type-Juggling-Probleme zu isolieren.

Sealed Test Doubles: Verhindert versehentliche Post-Exercise-Konfiguration

Die neue seal()-Methode für Test-Doubles ist ein optionales, aber mächtiges Feature. Sie finalisiert die Konfiguration eines Mocks oder Stubs und verhindert weitere expects()- oder method()-Aufrufe. Das klingt simpel, löst aber ein echtes Problem: Tests, die nach der Ausführung des System-Under-Test weitere Mock-Erwartungen definieren.

$mock = $this->createMock(PaymentGateway::class);
$mock->expects($this->once())
     ->method('charge')
     ->with($this->equalTo(100));

$mock->seal(); // Ab hier: Konfiguration finalisiert

$service = new PaymentService($mock);
$service->processPayment(100); // Exercise

// Versehentliche Post-Exercise-Config würde jetzt Exception werfen
// $mock->expects(...) → PHPUnit\Framework\MockObject\Exception

Ein häufiger Fehler, den ich in Audits sehe: Tests mit komplexen Mock-Setups, wo am Ende des Tests weitere Expectations hinzugefügt werden – oft Copy-Paste-Fehler oder Überbleibsel von Refactorings. Diese Tests passieren trotzdem, weil PHPUnit die zusätzlichen Expectations ignoriert. Mit seal() schlagen solche Tests fehl.

Sealed Doubles für Mocks vs. Stubs

Der Unterschied ist subtil aber wichtig: Bei Mocks (mit expects()) verhindert seal() Post-Exercise-Konfiguration und unerwartete Method-Calls. Bei Stubs (nur method()->willReturn()) verhindert es nur zusätzliche Konfiguration, akzeptiert aber alle Method-Calls.

Aus der Praxis: seal() ist besonders wertvoll in großen Test-Suites mit vielen Entwicklern. Es erzwingt: "Mock-Setup komplett, bevor Test läuft". Das reduziert flaky Tests, die manchmal passen und manchmal fehlschlagen, weil Mock-Konfiguration in falscher Reihenfolge geschieht.

Sealed Test Doubles sind ein rein additives Feature. Bestehende Tests laufen unverändert. Aber für neue Tests oder beim Refactoring empfehle ich die Nutzung – es macht Test-Intent explizit. → Lassen Sie uns Ihre Test-Suite auf solche Patterns analysieren

Das Ende von withConsecutive(): Offizielle Parameter-Matching-Rules

withConsecutive() wurde in PHPUnit 9.6 deprecated, in PHPUnit 10 entfernt, und trieb Entwickler seitdem in Workarounds. PHPUnit 13 liefert jetzt die offizielle Lösung: Zwei spezialisierte Parameter-Matching-Rules für Multi-Call-Verification.

Das Problem mit withConsecutive()

Das alte withConsecutive() hatte bekannte Limitationen: Enge Kopplung an feste Call-Order (wenn Method 3x aufgerufen wird, aber in anderer Reihenfolge → Test failed), steigende Komplexität bei vielen Calls (5+ Calls werden unlesbar), keine Flexibilität für "Call N mit beliebigen Params" (alles oder nichts).

Ein typisches Beispiel aus Legacy-Code:

// PHPUnit 9 - deprecated
$mock->expects($this->exactly(3))
     ->method('log')
     ->withConsecutive(
         [$this->equalTo('start'), $this->anything()],
         [$this->equalTo('process'), $this->greaterThan(0)],
         [$this->equalTo('end'), $this->anything()]
     );

Was viele unterschätzen: Diese Tests sind fragil. Wenn die Business-Logic die Log-Reihenfolge ändert (z.B. zusätzliches "validate"-Log zwischen "start" und "process"), schlägt der Test fehl – obwohl die Funktionalität korrekt ist. Das ist temporal coupling in Tests.

Die neuen Parameter-Matching-Rules in PHPUnit 13

PHPUnit 13 führt zwei spezialisierte Rules ein (Details noch nicht öffentlich dokumentiert, aber angekündigt im Release-Post): Sequential Parameter Matching für Call-Sequenzen mit fester Order und Flexible Parameter Matching für Call-Sets ohne Order-Requirement.

Die Community hat bereits Workarounds entwickelt, die diese Patterns implementieren. Beispiel mit willReturnCallback() und Invocation Counter (aus Tomas Votruba's Guide):

$invokedCount = new stdClass();
$invokedCount->count = 0;

$mock->expects($this->exactly(3))
     ->method('log')
     ->willReturnCallback(function ($level, $message) use ($invokedCount) {
         $invokedCount->count++;
         
         if ($invokedCount->count === 1) {
             $this->assertEquals('start', $level);
         } elseif ($invokedCount->count === 2) {
             $this->assertEquals('process', $level);
             $this->assertGreaterThan(0, $message);
         } elseif ($invokedCount->count === 3) {
             $this->assertEquals('end', $level);
         }
     });

Das ist verbos, aber funktioniert mit Vanilla-PHP – kein spezielles PHPUnit-API-Wissen nötig. PHPUnit 13's offizielle Rules werden diese Patterns vereinfachen, aber die Dokumentation ist noch nicht vollständig.

Migration-Strategy: Von withConsecutive() zu PHPUnit 13

Wenn Ihre Test-Suite noch withConsecutive() nutzt (PHPUnit 9 oder älter), ist jetzt der Zeitpunkt für Migration. Die Schritte:

1. Identifizieren: grep -r "withConsecutive" tests/ – wie viele Tests betroffen?

2. Klassifizieren: Welche Tests prüfen echte Call-Order (State-Machine-Logic, Event-Sequenzen) vs. welche prüfen nur "diese Calls happened irgendwann" (Logging, Metrics)?

3. Refactoren: Order-relevante Tests → PHPUnit 13 Sequential Rules. Order-irrelevante Tests → Flexible Rules oder separate Assertions.

4. Automatisieren: Rector bietet Rules für automatisches Refactoring (siehe digitalrevolution/phpunit-extensions).

Aus der Praxis: Die Migration von 50-100 Tests mit withConsecutive() dauert 1-2 Tage, wenn man Rector nutzt. Manuell: 1 Woche. Das ist deutlich schneller als bei PHPUnit 10, wo es keine offiziellen Alternativen gab.

Hard Deprecation des any()-Matchers: Mocks vs. Stubs explizit machen

PHPUnit 13 markiert den any()-Matcher als "hard deprecated" – er triggert eine Deprecation-Warning und wird in PHPUnit 14 entfernt. Die Begründung: Mocks sind Verification-Tools. Wenn Sie keine Verification wollen, nutzen Sie Stubs.

Der konzeptuelle Widerspruch: $mock->expects($this->any()) sagt "Create a mock (verification tool), but I don't care if it's called". Das ist semantisch inkonsistent. Die Alternative:

// Wenn Sie Verification wollen:
$mock->expects($this->once()) // oder exactly(N), atLeast(N)
     ->method('doSomething');

// Wenn Sie keine Verification wollen:
$stub = $this->createStub(Dependency::class);
$stub->method('doSomething')->willReturn(true);

Ein häufiger Fehler, den ich sehe: Teams nutzen any(), weil sie nicht wissen, wie oft eine Method aufgerufen wird – aber eigentlich ist das Test-Smell. Wenn Sie nicht wissen, wie oft ein Call erwartet wird, testen Sie entweder das falsche Behavior oder Ihre Implementation ist zu komplex.

Wann Mocks, wann Stubs?

Mocks: Wenn das Verhalten Ihrer SUT davon abhängt, dass und wie oft eine Dependency aufgerufen wird. Beispiel: Command-Bus muss handle() genau einmal callen.

Stubs: Wenn Sie nur Return-Values brauchen, aber Call-Verification irrelevant ist. Beispiel: Repository gibt User-Entity zurück – wie oft das gecallt wird, ist Implementierungsdetail.

Die Migration: grep -r "expects.*any" tests/ findet alle betroffenen Stellen. Entscheiden Sie pro Test: Ist das echte Verification (→ once(), exactly()) oder nur Setup (→ createStub()).

Lohnt sich das Upgrade für Ihr Projekt?

Die Entscheidungsmatrix aus Projekt-Erfahrung:

Upgrade jetzt (Q1 2026), wenn: Sie bereits auf PHP 8.4 laufen oder das Upgrade sowieso geplant ist. Ihre Test-Suite aktiv gewartet wird und withConsecutive()-Workarounds nervt. Sie neue Features wie Sealed Doubles nutzen wollen für robustere Tests. CI/CD-Pipeline flexible PHP-Versionen unterstützt (Docker, GitHub Actions mit Matrix-Builds).

Warten bis Q2/Q3 2026, wenn: Sie auf PHP 8.2/8.3 produktiv laufen und kein dringender Upgrade-Grund existiert. PHPUnit 12 bis Februar 2027 supportet ist (genug Puffer). Ihre Test-Suite wenig withConsecutive() nutzt oder bereits migriert ist. Sie abwarten wollen, bis PHPUnit 13.1+ Bugfixes für Early-Adoption-Issues bringt.

Nicht upgraden, wenn: Ihr Hosting-Provider PHP 8.4 nicht unterstützt und Wechsel nicht möglich ist. Ihre App auf Legacy-PHP läuft und größeres Refactoring nötig wäre. Die Test-Suite seit Jahren unberührt ist und "works as is" ausreicht.

Was kostet die Migration realistisch?

Aufwandsschätzung für typische KMU/Agentur-Projekte (basierend auf 5-10 Symfony-Projekt-Migrationen, die ich begleitet habe):

Kleine Test-Suite (50-200 Tests, wenig withConsecutive()): 2-4 Stunden. PHP 8.4 Docker-Image aktualisieren, composer require phpunit/phpunit:^13.0, Tests laufen lassen, Deprecation-Warnings fixen.

Mittelgroße Test-Suite (200-1000 Tests, moderate withConsecutive()-Nutzung): 1-2 Tage. PHP 8.4 Staging/Production vorbereiten, Rector-Rules für automatische Migration, manuelle Nacharbeit für Edge-Cases, CI/CD-Pipeline anpassen.

Große Test-Suite (1000+ Tests, extensive Mock-Nutzung): 3-5 Tage. Vollständige withConsecutive()-Audit, schrittweise Migration pro Module, Regression-Testing, Team-Schulung für neue Patterns.

Die entscheidende Frage: Haben Sie bereits PHP 8.4? Wenn ja, ist PHPUnit 13 ein Easy Win. Wenn nein, ist die PHP-Upgrade-Komplexität der Blocker – nicht PHPUnit selbst.

Die Migrationsstrategie hängt stark von Ihrer bestehenden Test-Suite-Architektur ab. Extensive Mock-Nutzung erhöht Aufwand, pragmatische Stub-Patterns reduzieren ihn. → Wir können Ihre Test-Suite analysieren und Migrations-Aufwand einschätzen

Best Practices für PHPUnit 13 Test-Suites

Basierend auf Early-Adoption-Erfahrung empfehle ich diese Patterns:

1. Nutzen Sie Sealed Doubles standardmäßig für Mocks – fügen Sie $mock->seal() nach der kompletten Konfiguration hinzu. Das erzwingt explizite Mock-Setup-Phasen und verhindert flaky Tests.

2. Präferieren Sie Stubs über Mocks wo möglich – Mocks sind Verification-Tools für kritische Interaktionen. Für reine Return-Value-Dependencies: createStub(). Das reduziert Test-Fragilität bei Refactorings.

3. Migrieren Sie von Order-gebundenen Tests zu State-basierten Tests – statt "Method X wird vor Y aufgerufen" zu testen, testen Sie "Nach Operation ist System in State Z". Das ist robuster gegen Implementation-Changes.

4. Nutzen Sie die neuen Array-Assertions explizit – vermeiden Sie Generic assertEquals() für Arrays. Wählen Sie bewusst Strictness, Keys, Order. Das macht Test-Intent klar.

5. Automatisieren Sie Deprecation-Detectionphpunit --display-deprecations zeigt alle Warnings. Integrieren Sie das in CI/CD als Warning (nicht Failure), um frühzeitig auf Breaking Changes in PHPUnit 14 vorbereitet zu sein.

Anti-Patterns vermeiden

Was die Dokumentation nicht betont, aber ich in Code-Reviews immer wieder sehe:

Über-Mocking: Tests, die 10+ Dependencies mocken. Das ist Test-Smell – Ihre Class macht zu viel oder Ihre Test-Strategie ist falsch. Präferieren Sie Integration-Tests über Unit-Tests mit extensive Mocking.

Temporal Coupling in Tests: Tests, die "Method A muss vor B aufgerufen werden" verifizieren, sind fragil. Refactoren Sie zu State-Assertions: "Nach A und B ist Zustand X erreicht".

Implizite Assertions durch Mock-Setup: expects($this->once()) ist eine Assertion, auch wenn nicht assert* im Namen steht. Zählen Sie diese in Ihre Test-Coverage ein.

Symfony-Projekte: PHPUnit 13 und symfony/phpunit-bridge

Symfony-Projekte nutzen typischerweise symfony/phpunit-bridge, das PHPUnit-Versionen managed und Deprecation-Tracking bietet. Die Bridge ist kompatibel mit PHPUnit 13 ab Version 7.2 (released Dezember 2025).

Wichtig für Symfony-Entwickler: Die Bridge's @group legacy-Annotation funktioniert unverändert. Tests mit Legacy-Behavior (z.B. PHP 8.3 Compatibility-Layers) können markiert werden und getrennt laufen. Das erlaubt schrittweise Migration statt Big-Bang-Upgrade.

Functional Tests und WebTestCase

WebTestCase-basierte Functional Tests (die meisten Symfony-Projekte haben 100-500 davon) sind nicht direkt von PHPUnit 13 Changes betroffen – sie nutzen selten Mocks. Aber: PHP 8.4-Requirement gilt trotzdem. Stellen Sie sicher, dass Ihre lokale Environment, CI/CD und Staging auf PHP 8.4 laufen, bevor Sie upgraden.

Ein häufiges Problem: Doctrine-Fixtures oder Seed-Data, die auf PHP 8.3-Features basieren, brechen bei PHP 8.4. Testen Sie Functional Tests gegen PHP 8.4 vor PHPUnit-Upgrade, um diese Issues zu isolieren.

Zusammenfassung: Die wichtigsten Erkenntnisse

  1. PHP 8.4 ist Pflicht: PHPUnit 13 erzwingt Runtime-Upgrade. Ohne PHP 8.4 keine PHPUnit 13-Migration. Das ist die größte Hürde für KMU-Projekte.
  2. Neue Array-Assertions vereinfachen Tests: Dreistufige Konfiguration (Strictness, Keys, Order) ersetzt Custom-Helper. Präferieren Sie explizite Assertions über Generic assertEquals().
  3. Sealed Doubles sind Game-Changer: Optionales Feature, aber wertvoll für große Test-Suites. Verhindert flaky Tests durch Post-Exercise-Konfiguration.
  4. withConsecutive() hat endlich Ersatz: Offizielle Parameter-Matching-Rules in PHPUnit 13 lösen 3-jähriges Pain-Point. Migration ist jetzt einfacher als bei PHPUnit 10.
  5. any()-Matcher ist deprecated: Explizite Wahl zwischen Mocks (mit Verification) und Stubs (ohne Verification) erforderlich. Das erzwingt bessere Test-Architektur.
  6. PHPUnit 12 Support bis Februar 2027: Kein akuter Zwang zum Upgrade, aber Vorbereitung empfohlen. Deprecation-Warnings in PHPUnit 12.5 vor Migration fixen.

Ausblick: Was kommt in PHPUnit 14?

PHPUnit 14 ist für Februar 2027 angekündigt. Erwartbare Changes basierend auf aktuellem Development-Pattern: Entfernung aller Hard-Deprecations aus PHPUnit 13 (any()-Matcher, assertContainsOnly()), mögliche PHP-8.5-Requirement (folgt der Policy "nur actively developed PHP-Versionen"), weitere Mock-Object-Improvements (Community diskutiert Type-Safe Mock-Creation basierend auf Generics).

Die strategische Überlegung: PHPUnit 13 ist ein "Preparation Release" – es bereitet den Ground für PHPUnit 14's Breaking Changes vor. Teams, die jetzt migrieren, haben 12 Monate Vorlauf für PHPUnit 14. Teams, die warten, müssen 2027 zwei Major-Versions gleichzeitig upgraden (13 + 14).

Dennis Schwenker-Sanders ist PHP & Symfony-Entwickler mit Fokus auf Test-Driven Development und Legacy-Code-Modernisierung. Mehr über Dennis und seinen Ansatz für wartbare Software.

Artikel teilen: