von DataProvider bis zur vollständigen Mock-Strategie
Wer Tests ohne klare Muster schreibt, bekommt eine Testsuite, die langsam, brüchig und schwer zu verstehen ist. DataProvider, aussagekräftige Assertions, isolierte Mocks und eine sinnvolle Coverage-Strategie trennen Tests, die bei Refactoring sofort anschlagen, von grünen Tests, die trotz Bugs grün bleiben.
Inhaltsverzeichnis
- 1. Was PHPUnit-Patterns wirklich lösen
- 2. Testaufbau: Arrange, Act, Assert und setUp richtig einsetzen
- 3. DataProvider: Testfälle systematisch variieren
- 4. Mocks und Stubs: Abhängigkeiten kontrolliert ersetzen
- 5. Assertions: präzise und aussagekräftige Fehlermeldungen
- 6. Exceptions und Fehlerszenarien testen
- 7. PHPUnit in Magento: Integration und Unit-Tests trennen
- 8. Testperformance: schnelle Suiten durch klare Isolation
- 9. PHPUnit-Patterns im direkten Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Was PHPUnit-Patterns wirklich lösen
Ein PHPUnit-Pattern ist keine syntaktische Regel, sondern eine erprobte Lösungsstruktur für ein wiederkehrendes Testproblem. Der Unterschied zu einem schnell hingeschriebenen Test liegt darin, dass das Muster gezielt auf Wartbarkeit, Verständlichkeit und Robustheit ausgelegt ist. Tests, die keinen klaren Patterns folgen, neigen dazu, bei der ersten Refaktorisierung zu brechen – nicht weil die Logik falsch ist, sondern weil der Test zu eng mit Implementierungsdetails verknüpft ist.
In der Praxis lassen sich vier typische Probleme beobachten: Tests, die zu viel in einem Testfall prüfen und bei einem Fehler nicht erkennen lassen, was genau schiefgelaufen ist. Tests, die Abhängigkeiten nicht isolieren und dadurch langsam werden oder von externem Zustand abhängen. Tests, die keine aussagekräftigen Fehlermeldungen liefern, sodass man den Code lesen muss, um den Fehler zu verstehen. Und Tests, die nur den Happy Path prüfen und Fehlerzustände vollständig ignorieren. Die folgenden Abschnitte adressieren diese Probleme mit konkreten PHPUnit-Patterns.
2. Testaufbau: Arrange, Act, Assert und setUp richtig einsetzen
Das AAA-Pattern (Arrange, Act, Assert) ist die Grundlage für lesbare Tests. Jeder Test besteht aus drei klar getrennten Phasen: In der Arrange-Phase wird der Zustand vorbereitet – Objekte instanziiert, Mocks konfiguriert, Daten aufgebaut. In der Act-Phase wird genau eine Aktion ausgeführt – die Methode aufgerufen, der Command dispatched. In der Assert-Phase wird das Ergebnis geprüft – idealerweise mit einem einzigen assert-Aufruf oder mehreren Assertions zum selben Sachverhalt. Tests mit vielen Assertions zu verschiedenen Aspekten signalisieren, dass der Test mehrere Verantwortlichkeiten trägt.
Die Methoden setUp() und tearDown() sind für Initialisierungslogik gedacht, die in jedem Test der Klasse gleich ist. Häufig werden sie überladen mit Logik, die nur für einen Teil der Tests relevant ist – was dazu führt, dass neue Tests unklar erben. Das PHPUnit-Pattern ist, setUp() so minimal wie möglich zu halten und testspezifische Vorbereitungen in private Hilfsmethoden wie createProductWithPrice(9.99) auszulagern. So ist jeder Test selbsterklärend, ohne in setUp() nachzuschauen.
<?php
declare(strict_types=1);
namespace Mironsoft\Catalog\Test\Unit\Model;
use Mironsoft\Catalog\Model\PriceCalculator;
use Mironsoft\Catalog\Model\TaxProvider;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
/**
* Unit tests for PriceCalculator using AAA pattern and minimal setUp.
*/
final class PriceCalculatorTest extends TestCase
{
private PriceCalculator $calculator;
private MockObject&TaxProvider $taxProvider;
protected function setUp(): void
{
// setUp: only shared, truly universal initialization
$this->taxProvider = $this->createMock(TaxProvider::class);
$this->calculator = new PriceCalculator($this->taxProvider);
}
public function testNetPriceIsCalculatedCorrectly(): void
{
// Arrange
$this->taxProvider->method('getRate')->willReturn(0.19);
$grossPrice = 119.00;
// Act
$netPrice = $this->calculator->calculateNet($grossPrice);
// Assert
self::assertEqualsWithDelta(100.00, $netPrice, 0.001, 'Net price must equal gross / (1 + tax rate)');
}
public function testZeroTaxRateReturnsGrossUnchanged(): void
{
// Arrange
$this->taxProvider->method('getRate')->willReturn(0.0);
// Act
$result = $this->calculator->calculateNet(99.00);
// Assert
self::assertSame(99.00, $result, 'Zero tax rate must return gross price as-is');
}
}
3. DataProvider: Testfälle systematisch variieren
Der #[DataProvider]-Attribut (PHPUnit 10+) ist eines der wirkungsvollsten PHPUnit-Patterns für systematische Testabdeckung. Statt dieselbe Testlogik mit verschiedenen Eingaben in separate Methoden zu kopieren, definiert man eine statische Methode, die ein Array von Testfällen zurückgibt. PHPUnit führt den Test für jeden Datensatz separat aus und zeigt im Fehlerfall genau an, welcher Datensatz fehlgeschlagen ist. Das verhindert, dass bei einem Fehler der erste schlechte Datensatz alle folgenden verdeckt.
Ein guter DataProvider benennt jeden Datensatz mit einem beschreibenden String-Schlüssel: 'negative price returns zero', 'price with maximum precision'. PHPUnit verwendet diesen Schlüssel in der Fehlerausgabe, was die Diagnose erheblich beschleunigt. Das PHPUnit-Pattern für DataProvider: die Methode ist public static, gibt ein assoziatives Array zurück und enthält sowohl valide als auch Grenzfall-Eingaben. Boundary-Values – der kleinste, der größte und der Grenzwert – gehören in jeden guten DataProvider.
4. Mocks und Stubs: Abhängigkeiten kontrolliert ersetzen
Der Unterschied zwischen Mock und Stub ist konzeptionell wichtig: Ein Stub ersetzt eine Abhängigkeit durch eine einfache Rückgabe ohne Verhaltensverifikation. Ein Mock erwartet zusätzlich, dass bestimmte Methoden in bestimmter Weise aufgerufen werden – und lässt den Test fehlschlagen, wenn das nicht passiert. Das häufigste Fehlermuster: Mocks einsetzen, wo Stubs genügen. Das führt zu Tests, die brechen, weil sich die interne Aufruf-Reihenfolge geändert hat, obwohl das Verhalten aus Anwendersicht korrekt bleibt.
PHPUnit 10+ bietet mit createMock(), createStub() und dem Intersection-Type-Syntax MockObject&InterfaceType klare Trennung. Das PHPUnit-Pattern: Stubs für Lesezugriffe auf Abhängigkeiten (willReturn, willReturnMap), Mocks nur dann, wenn der Test explizit verifizieren soll, dass eine Aktion ausgeführt wurde (expects($this->once())). willReturnCallback() ermöglicht komplexe Stub-Logik ohne eine vollständige Fake-Implementierung schreiben zu müssen.
<?php
declare(strict_types=1);
namespace Mironsoft\Order\Test\Unit\Model;
use Mironsoft\Order\Api\EmailSenderInterface;
use Mironsoft\Order\Model\OrderConfirmationService;
use Mironsoft\Order\Model\OrderRepository;
use PHPUnit\Framework\TestCase;
/**
* Demonstrates the stub vs. mock distinction in PHPUnit.
*/
final class OrderConfirmationServiceTest extends TestCase
{
/**
* Stub: repository returns order, no call-count verification needed.
* Mock: email sender must be called exactly once — we verify the side effect.
*/
public function testConfirmationEmailIsSentOnSuccessfulOrder(): void
{
// Arrange — stub for read dependency
$repository = $this->createStub(OrderRepository::class);
$repository->method('getById')->willReturn($this->buildOrder(42, 'pending'));
// Mock for write/action dependency — verify it is actually called
$emailSender = $this->createMock(EmailSenderInterface::class);
$emailSender->expects($this->once())
->method('sendOrderConfirmation')
->with($this->equalTo(42));
$service = new OrderConfirmationService($repository, $emailSender);
// Act
$service->confirm(42);
// Assert: verified via mock expectation above
}
#[\PHPUnit\Framework\Attributes\DataProvider('orderStatusProvider')]
public function testOnlyPendingOrdersAreSentConfirmation(string $status, bool $expectEmail): void
{
$repository = $this->createStub(OrderRepository::class);
$repository->method('getById')->willReturn($this->buildOrder(1, $status));
$emailSender = $this->createMock(EmailSenderInterface::class);
$emailSender->expects($expectEmail ? $this->once() : $this->never())
->method('sendOrderConfirmation');
(new OrderConfirmationService($repository, $emailSender))->confirm(1);
}
public static function orderStatusProvider(): array
{
return [
'pending order receives email' => ['pending', true],
'processing order receives email' => ['processing', false],
'complete order skips email' => ['complete', false],
'canceled order skips email' => ['canceled', false],
];
}
private function buildOrder(int $id, string $status): object
{
return new readonly class($id, $status) {
public function __construct(public int $id, public string $status) {}
};
}
}
5. Assertions: präzise und aussagekräftige Fehlermeldungen
Schlechte Assertions sind der Hauptgrund, warum Entwickler beim Fehlschlagen eines Tests in den Code schauen müssen, statt die Testausgabe zu lesen. assertTrue($result) sagt nur, dass etwas falsch war. assertSame('expected', $result, 'Discount calculation returned wrong value for 10% tier') sagt, was falsch war und warum es wichtig ist. Das PHPUnit-Pattern: immer die spezifischste Assertion verwenden, immer eine beschreibende Message als drittes Argument hinzufügen, und niemals assertTrue für Vergleiche einsetzen, wenn assertSame, assertEquals oder assertInstanceOf direkt kommunizieren, was geprüft wird.
PHPUnit bietet über 60 Assertion-Methoden. assertEqualsWithDelta() für Floating-Point-Vergleiche, assertMatchesRegularExpression() für Muster, assertJsonStringEqualsJsonString() für API-Responses, assertSameSize() für Collections. Das PHPUnit-Pattern für Custom Assertions: eine abstrakte Basisklasse für Tests im selben Modul, die domänenspezifische assertValidProduct()-Methoden als wiederverwendbare Assertions kapselt. Das verhindert duplizierten Assertions-Code über viele Testklassen.
6. Exceptions und Fehlerszenarien testen
Fehlerszenarien sind der am häufigsten vernachlässigte Bereich in PHP-Testsuiten. Das PHPUnit-Pattern für Exception-Tests in PHPUnit 10+ sind die Attribute #[ExpectedException], #[ExpectedExceptionMessage] oder die Methode $this->expectException(InvalidArgumentException::class) vor dem Act-Aufruf. Wichtig: nach dem expectException()-Aufruf kommt direkt der Code, der die Exception auslöst. Kein weiterer Assert danach ist nötig – PHPUnit schlägt fehl, wenn keine Exception geworfen wird.
Ein häufiges Fehlermuster: die Exception wird mit einem try/catch-Block gefangen und manuell mit assertTrue(true) bestätigt. Das ist nicht nur umständlich, sondern maskiert auch Fehler, wenn der falsche Code die Exception wirft. Das korrekte PHPUnit-Pattern: expectException und expectExceptionMessage kombinieren, um sowohl Typ als auch Nachricht zu verifizieren. Für Fehler-Codes: expectExceptionCode(). Für komplexe Exception-Daten: Exception fangen, auf Eigenschaften prüfen und danach mit $this->fail() sicherstellen, dass ohne Exception keine weitere Assertion ausgeführt wird.
7. PHPUnit in Magento: Integration und Unit-Tests trennen
Magento liefert zwei phpunit.xml-Konfigurationen: dev/tests/unit/phpunit.xml für Unit-Tests ohne Bootstrap und dev/tests/integration/phpunit.xml für Integration-Tests mit vollständigem Magento-Bootstrap. Das PHPUnit-Pattern für Magento: Unit-Tests für alle reinen PHP-Klassen – ViewModels, Helper, Model-Logik –, die keine Magento-Infrastruktur benötigen. Integration-Tests nur für Code, der die Datenbank, den ObjectManager oder Layout-Rendering verwendet. Diese Trennung hält die Unit-Test-Suite in unter einer Sekunde pro Klasse.
Im Magento-Kontext ist das direkte Verwenden von ObjectManager::getInstance() in Tests ein Anti-Pattern. Statt dessen nutzt man ObjectManagerHelper aus dem Magento-Testing-Framework oder vermeidet die ObjectManager-Abhängigkeit im Produktionscode durch konsequente Constructor Injection. Tests für ViewModels sind besonders unkompliziert: ViewModel bekommt alle Abhängigkeiten als Mocks injiziert, und die Business-Logik ist vollständig ohne Magento-Bootstrap testbar. Das ist einer der Hauptvorteile des ViewModel-Patterns gegenüber Block-Klassen.
<?php
declare(strict_types=1);
namespace Mironsoft\Catalog\Test\Unit\ViewModel;
use Magento\Catalog\Api\Data\ProductInterface;
use Magento\Catalog\Api\ProductRepositoryInterface;
use Mironsoft\Catalog\ViewModel\ProductBadgeViewModel;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
/**
* Unit test for ViewModel — no Magento bootstrap required.
*/
final class ProductBadgeViewModelTest extends TestCase
{
private MockObject&ProductRepositoryInterface $productRepository;
private ProductBadgeViewModel $viewModel;
protected function setUp(): void
{
$this->productRepository = $this->createMock(ProductRepositoryInterface::class);
$this->viewModel = new ProductBadgeViewModel($this->productRepository);
}
#[\PHPUnit\Framework\Attributes\DataProvider('badgeDataProvider')]
public function testBadgeLabelIsCorrectForPrice(float $price, string $expectedBadge): void
{
$product = $this->createStub(ProductInterface::class);
$product->method('getFinalPrice')->willReturn($price);
$this->productRepository->method('getById')->willReturn($product);
$badge = $this->viewModel->getBadgeLabel(1);
self::assertSame($expectedBadge, $badge, sprintf(
'Expected badge "%s" for price %.2f',
$expectedBadge,
$price
));
}
public static function badgeDataProvider(): array
{
return [
'free product gets FREE badge' => [0.0, 'FREE'],
'budget product gets DEAL badge' => [4.99, 'DEAL'],
'standard product has empty badge' => [9.99, ''],
'premium product has empty badge' => [99.0, ''],
];
}
}
8. Testperformance: schnelle Suiten durch klare Isolation
Eine langsame Testsuite wird nicht ausgeführt. Das ist das Grundprinzip hinter dem PHPUnit-Pattern für Testperformance. Jeder Unit-Test, der mehr als 100 ms braucht, ist ein Zeichen dafür, dass eine echte Abhängigkeit nicht gemockt ist – sei es eine Datenbankabfrage, ein HTTP-Call oder ein Dateisystem-Zugriff. --testdox und --log-junit liefern Daten für die Identifikation langsamer Tests. PHPUnit 10 bietet mit dem stopOnDefect-Attribut die Möglichkeit, die Suite nach dem ersten Fehler zu stoppen – das spart Zeit in CI-Pipelines, die ohnehin geblockt sind.
Das PHPUnit-Pattern für Testgruppen: Tests mit #[Group('slow')] annotieren und in der phpunit.xml oder per Kommandozeilenargument ausschließen (--exclude-group slow). Integration-Tests laufen in einem eigenen Job in der CI-Pipeline, Unit-Tests als Pre-Commit-Hook in unter fünf Sekunden. Shared Fixtures via #[BeforeClass] reduzieren den Setup-Overhead, wenn viele Tests dieselbe teure Initialisierung benötigen – zum Beispiel das Einlesen einer großen Testdatei oder das Aufbauen eines Objektgraphs.
9. PHPUnit-Patterns im direkten Vergleich
Viele alltägliche Testaufgaben lassen sich auf verschiedene Arten lösen – mit erheblichen Unterschieden bei Lesbarkeit, Diagnosefähigkeit und Wartungsaufwand. Die Wahl des richtigen PHPUnit-Patterns ist keine Stilfrage, sondern hat direkte Auswirkung darauf, wie schnell ein fehlgeschlagener Test diagnostiziert werden kann.
| Aufgabe | Anti-Pattern | Empfohlenes PHPUnit-Pattern | Vorteil |
|---|---|---|---|
| Mehrere Eingaben testen | Duplizierte Testmethoden | #[DataProvider] |
Alle Fälle sichtbar, klare Fehlerbenennung |
| Vergleich prüfen | assertTrue($a === $b) |
assertSame($a, $b, 'msg') |
Zeigt Ist- und Soll-Wert im Fehlerfall |
| Exception prüfen | try/catch + assertTrue(true) |
expectException() |
Klar, PHPUnit verwaltet die Assertion |
| Lesezugriff isolieren | Mock + expects($this->once()) |
createStub() |
Test bricht nicht bei Refactoring |
| Float vergleichen | assertEquals(1.1+2.2, 3.3) |
assertEqualsWithDelta() |
Berücksichtigt Floating-Point-Ungenauigkeit |
Die häufigste Ursache für brüchige Testsuiten ist das Überbestimmen von Mocks. Wenn jede Methode auf jedem Mock mit expects($this->once()) belegt wird, schlägt der Test bei jeder internen Refaktorisierung fehl – auch wenn das nach außen sichtbare Verhalten unverändert bleibt. Das richtige PHPUnit-Pattern: nur das verifizieren, was der Test aus seiner Perspektive wissen muss. Alles andere als Stub.
Mironsoft
PHPUnit-Beratung, Test-Strategie und CI-Integration für PHP und Magento
Testsuite, die bei Refactoring sofort anschlägt?
Wir analysieren bestehende PHPUnit-Suiten, identifizieren brüchige Patterns und ersetzen sie durch robuste PHPUnit-Patterns – mit vollständiger Mock-Strategie, DataProvider-Systematik und CI-Integration für Magento und PHP-Projekte.
Test-Audit
Analyse bestehender Tests auf Anti-Patterns, überbestimmte Mocks und fehlende Edge Cases
Refactoring
DataProvider-Umstellung, Mock-Strategie und aussagekräftige Assertions einführen
CI-Integration
PHPUnit in Pipelines, Coverage-Reports und automatische Qualitätsschwellen einrichten
10. Zusammenfassung
Die wichtigsten PHPUnit-Patterns für PHP und Magento lösen immer dasselbe Grundproblem: Tests, die ohne klare Muster geschrieben sind, werden zur Wartungslast, die niemand anfassen will. Das AAA-Pattern sorgt für Lesbarkeit. DataProvider für systematische Testabdeckung ohne Codeduplizierung. Stubs statt Mocks für Lesezugriffe verhindert brüchige Tests bei Refactoring. Aussagekräftige Assertions mit beschreibenden Messages beschleunigen die Diagnose. Und die klare Trennung von Unit- und Integration-Tests hält die Suite schnell genug, um bei jedem Commit ausgeführt zu werden.
Der größte Hebel liegt in der konsistenten Anwendung über alle Tests eines Projekts. Ein Test mit vollständigen DataProvidnern neben zehn Tests mit kopierten Methoden schafft ungleiche Wartbarkeit. Eine gemeinsame abstrakte Testbasisklasse im Modul, phpunit.xml mit klaren Gruppen und ein CI-Job, der Unit-Tests in unter zehn Sekunden ausführt – das sind die strukturellen Maßnahmen, die aus einer chaotischen Testsuite eine produktive machen.
50 PHPUnit-Patterns — Das Wichtigste auf einen Blick
Teststruktur
AAA-Pattern (Arrange, Act, Assert) in jedem Test. setUp() minimal halten. Testspezifische Vorbereitung in private Hilfsmethoden auslagern.
DataProvider
#[DataProvider] für alle Varianten einer Testlogik. Datensätze mit beschreibenden String-Schlüsseln benennen. Boundary-Values immer einschließen.
Mocks vs. Stubs
Stubs für Lesezugriffe, Mocks nur zur Verifikation von Aktionen. Überbestimmte Mocks vermeiden – sie brechen bei jeder Refaktorisierung.
Magento-Spezifika
Unit-Tests ohne Bootstrap für ViewModels und reine PHP-Klassen. Integration-Tests nur für Datenbankzugriffe und ObjectManager-abhängigen Code.