@test
assert
PHPUnit · Fixtures · Builder · DataProvider · Magento 2
PHPUnit Fixtures, Builders und Testdaten
sauber organisieren

Duplikate Testdaten in jedem setUp, magische Arrays in DataProvidern und hartcodierte IDs verstreut über Hunderte von Testklassen – das sind die typischen Zeichen einer Testsuite ohne klare Datenorganisation. Builder-Pattern, Fixture-Klassen und Factory-Methoden bringen Ordnung ohne Overengineering.

16 Min. Lesezeit Builder · Fixtures · DataProvider · Factory · Object Mother PHPUnit 10/11 · PHP 8.4 · Magento 2

1. Das Problem mit rohen Testdaten direkt im Test

Ein häufiges Muster in gewachsenen Testsuiten: Jede Testmethode oder jeder setUp-Block baut seine eigenen Testdaten aus Primitiven zusammen. Ein Produkt-Array mit zwanzig Feldern, dreimal leicht unterschiedlich zusammengebaut, an drei verschiedenen Stellen im Testverzeichnis. Wenn sich das Produktmodell ändert und ein Pflichtfeld hinzukommt, müssen dreißig Stellen angepasst werden – und wer eine übersieht, hat einen silently broken Test.

Das zweite Problem: Testmethoden lesen sich wie Datenkonstruktionsübungen statt wie fachliche Aussagen. Wenn die ersten zwanzig Zeilen einer Testmethode damit verbracht werden, Objekte zusammenzubauen, ist die eigentliche Assertion – das Herzstück des Tests – im Rauschen versteckt. Gute Tests sind kurze, verständliche Szenarien: Given, When, Then – jeder Teil maximal eine Handvoll Zeilen.

Die Lösung liegt nicht in einem einzigen Pattern, sondern in der bewussten Wahl des richtigen Werkzeugs für den jeweiligen Kontext. Object Mother für vorgefertigte Standardobjekte, Builder für variante Testdaten, DataProvider für parameterisierte Aussagen, Fixture-Klassen für Integrationstests mit Datenbankzustand. Diese Muster schließen sich nicht aus – sie ergänzen sich.

2. Object Mother: vorgefertigte Test-Objekte per statischer Fabrik

Das Object-Mother-Pattern stammt aus dem Java-Umfeld, ist aber in PHP genauso wertvoll. Eine Object-Mother-Klasse ist eine reine Factory für Testobjekte – sie erzeugt vollständig initialisierte Objekte mit sinnvollen Standardwerten und bietet benannte Varianten für häufige Testszenarien an. Statt new Product(1, 'Test', 99.99, true, 'DE', 19.0, ...) im Test steht nur noch ProductMother::standard() oder ProductMother::withDiscount(10).

Object Mothers sind besonders wertvoll, wenn viele Tests dasselbe Objekt in leicht unterschiedlichen Zuständen brauchen. Die Methoden sind sprechend benannt und kommunizieren fachliche Absicht: CustomerMother::guestWithOpenCart() erzählt eine Geschichte über den Testzustand, bevor die erste Assertion gelesen wird. Änderungen am Produktmodell werden nur in der Object-Mother-Klasse durchgeführt, alle Tests profitieren automatisch.


<?php
declare(strict_types=1);

namespace Mironsoft\Tests\Fixture;

use Mironsoft\Catalog\Model\Product;
use Mironsoft\Customer\Model\Customer;

/**
 * Object Mother for Product test fixtures.
 * Provides named, semantically meaningful test objects.
 */
final class ProductMother
{
    /** Returns a standard in-stock product with 19% VAT. */
    public static function standard(): Product
    {
        return new Product(
            id: 1,
            sku: 'TEST-001',
            name: 'Test Product',
            price: 100.00,
            vatRate: 19.0,
            inStock: true,
            categoryId: 10,
        );
    }

    /** Returns a product that is out of stock. */
    public static function outOfStock(): Product
    {
        return new Product(
            id: 2,
            sku: 'TEST-002',
            name: 'Out Of Stock Product',
            price: 50.00,
            vatRate: 19.0,
            inStock: false,
            categoryId: 10,
        );
    }

    /** Returns a product with reduced VAT rate (books, food). */
    public static function reducedVat(): Product
    {
        return new Product(
            id: 3,
            sku: 'TEST-003',
            name: 'Reduced VAT Product',
            price: 20.00,
            vatRate: 7.0,
            inStock: true,
            categoryId: 20,
        );
    }

    /** Returns a product with a percentage discount applied. */
    public static function withDiscount(float $percent): Product
    {
        $base = self::standard();
        return $base->withPrice($base->getPrice() * (1 - $percent / 100));
    }
}

3. Builder-Pattern: flexible Testdaten ohne Boilerplate

Wo Object Mother mit festen, benannten Varianten auskommt, braucht man für komplexe Objekte mit vielen optionalen Attributen das Builder-Pattern. Ein Test-Builder ist eine fluent API, die Schritt für Schritt ein Objekt aufbaut und dabei alle nicht explizit gesetzten Felder mit sinnvollen Defaults belegt. Das Ergebnis: Tests die nur die für das Szenario relevanten Felder setzen und den Rest ignorieren.

Der kritische Unterschied zum ProductionBuilder: Ein Test-Builder verwendet immer vollständige Defaults, sodass ein build()-Aufruf ohne weitere Konfiguration ein gültiges Objekt liefert. Im Produktionscode wären viele dieser Defaults sinnlos oder sogar falsch – aber im Testkontext kommunizieren sie klare Absicht: "Dieses Feld ist für diesen Test irrelevant, ich setze es auf einen Standardwert."


<?php
declare(strict_types=1);

namespace Mironsoft\Tests\Builder;

use Mironsoft\Sales\Model\Order;
use Mironsoft\Sales\Model\OrderItem;

/**
 * Fluent test builder for Order objects.
 * All fields default to valid test values; only relevant fields need to be set.
 */
final class OrderBuilder
{
    private int $id = 1;
    private string $status = 'pending';
    private string $customerEmail = 'test@example.com';
    private float $grandTotal = 119.00;
    private string $currencyCode = 'EUR';
    /** @var OrderItem[] */
    private array $items = [];

    public static function anOrder(): self
    {
        return new self();
    }

    public function withStatus(string $status): self
    {
        $clone = clone $this;
        $clone->status = $status;
        return $clone;
    }

    public function withGrandTotal(float $total): self
    {
        $clone = clone $this;
        $clone->grandTotal = $total;
        return $clone;
    }

    public function withCustomerEmail(string $email): self
    {
        $clone = clone $this;
        $clone->customerEmail = $email;
        return $clone;
    }

    public function withItem(OrderItem $item): self
    {
        $clone = clone $this;
        $clone->items[] = $item;
        return $clone;
    }

    public function build(): Order
    {
        if (empty($this->items)) {
            $this->items = [OrderItemMother::standardItem()];
        }
        return new Order($this->id, $this->status, $this->customerEmail, $this->grandTotal, $this->currencyCode, $this->items);
    }
}

// Usage in tests — only relevant fields set, rest defaults:
// $order = OrderBuilder::anOrder()->withStatus('complete')->withGrandTotal(200.0)->build();

4. DataProvider: Testfälle systematisch skalieren

Der PHPUnit-DataProvider ist das richtige Werkzeug, wenn dieselbe fachliche Aussage mit verschiedenen Eingaben überprüft werden soll. Statt fünf separate Testmethoden für verschiedene VAT-Sätze zu schreiben, gibt es eine Testmethode mit einem DataProvider, der alle Varianten auflistet. Das Ergebnis: weniger Code, vollständigere Abdeckung und – wenn ein Fall fehlschlägt – eine klare Benennung des fehlschlagenden Szenarios.

DataProvider-Methoden sind public static und geben array oder einen iterable zurück. Die Schlüssel des Arrays werden als Testfall-Name in der PHPUnit-Ausgabe angezeigt. Descriptive Keys wie 'standard rate DE 19%' statt [0] machen Fehlermeldungen sofort verständlich. In PHPUnit 11 werden DataProvider-Methoden mit #[DataProvider('methodName')] verknüpft – die alte @dataProvider-Annotation wird ignoriert.


<?php
declare(strict_types=1);

namespace Mironsoft\Tests\Unit\Tax;

use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use Mironsoft\Tax\Service\TaxCalculator;

#[CoversClass(TaxCalculator::class)]
final class TaxCalculatorTest extends TestCase
{
    private TaxCalculator $calculator;

    protected function setUp(): void
    {
        $this->calculator = new TaxCalculator();
    }

    #[Test]
    #[DataProvider('vatRateProvider')]
    public function calculatesGrossPriceCorrectly(float $net, float $rate, float $expected): void
    {
        $this->assertEqualsWithDelta(
            $expected,
            $this->calculator->gross($net, $rate),
            0.001,
            "Gross price calculation failed for rate {$rate}%"
        );
    }

    /**
     * @return array<string, array{float, float, float}>
     */
    public static function vatRateProvider(): array
    {
        return [
            'standard rate DE 19%'  => [100.00, 19.0, 119.00],
            'reduced rate DE 7%'    => [100.00,  7.0, 107.00],
            'zero rate'             => [100.00,  0.0, 100.00],
            'non-EU rate 25%'       => [100.00, 25.0, 125.00],
            'fractional net price'  => [  9.99, 19.0,  11.89],
        ];
    }

    #[Test]
    #[DataProvider('invalidRateProvider')]
    public function rejectsInvalidVatRate(float $invalidRate): void
    {
        $this->expectException(\InvalidArgumentException::class);
        $this->calculator->gross(100.0, $invalidRate);
    }

    /**
     * @return array<string, array{float}>
     */
    public static function invalidRateProvider(): array
    {
        return [
            'negative rate'    => [-1.0],
            'rate over 100%'   => [101.0],
        ];
    }
}

5. Fixture-Klassen für Integrationstests

Unit-Tests können Testdaten vollständig im Speicher halten – Integrationstests hingegen brauchen oft echte Datenbankzustände. Fixture-Klassen sind dafür zuständig, einen definierten Datenbankzustand herzustellen, den Test laufen zu lassen und danach alles aufzuräumen. Das Gegenstück zu setUp ist tearDown – und in Magento-Integrationstests ist das Rollback-Verhalten der Fixtures durch den Testrahmen vorgegeben.

Für plain-PHP-Projekte ohne Magento-Framework bietet sich eine eigene Fixture-Abstraktion an, die Transaktionen für Datenbankoperationen verwendet und nach jedem Test ein Rollback durchführt. Das ist deutlich schneller als das vollständige Neuaufsetzen der Datenbank und erlaubt echte Integrationstests in vertretbarer Laufzeit. Trait-basierte Fixture-Komposition erlaubt es, Fixture-Sets modular zusammenzustellen: use HasProductFixtures, HasCustomerFixtures statt alles in einer monolithischen Basisklasse.

6. Testdaten in Magento 2: Fixtures und Rollback

Magento 2 hat ein eigenes Fixture-System für Integrationstests. Fixtures sind PHP-Dateien, die mit der Annotation #[DataFixture] (PHPUnit 11) oder @magentoDataFixture (PHPUnit 9) in Tests eingebunden werden. Sie legen Produktkategorien, Kunden, Bestellungen oder Konfigurationswerte an und werden nach dem Test automatisch zurückgerollt. Der Mechanismus basiert auf Datenbank-Transaktionen, die am Testende abgebrochen werden.

Die neue Magento-2.4.8-Fixture-API mit PHP-Attributen ist typsicherer und IDE-freundlicher als die alte Annotations-basierte Variante. Fixtures können Argumente entgegennehmen, und komplexe Testszenarien lassen sich durch Fixture-Kombinationen ohne duplizierte SQL-Dateien beschreiben. Wichtig: Fixtures, die externe Systeme aufrufen (APIs, Dateisystem, Cache), müssen ihren Zustand selbst aufräumen – das Datenbank-Rollback greift dort nicht.


<?php
declare(strict_types=1);

namespace Mironsoft\Catalog\Test\Integration\Model;

use Magento\TestFramework\Fixture\DataFixture;
use Magento\TestFramework\Fixture\DataFixtureStorageManager;
use Magento\Catalog\Test\Fixture\Product as ProductFixture;
use Magento\Catalog\Test\Fixture\Category as CategoryFixture;
use PHPUnit\Framework\Attributes\Test;
use Magento\TestFramework\Helper\Bootstrap;

/**
 * Integration test using Magento 2 fixture attributes.
 * Fixtures are rolled back automatically after each test.
 */
#[DataFixture(CategoryFixture::class, ['name' => 'Test Category'], 'cat')]
#[DataFixture(ProductFixture::class, ['name' => 'Test Product', 'category_ids' => ['$cat.id$'], 'price' => 49.99], 'prod')]
final class ProductRepositoryTest extends \Magento\TestFramework\TestCase\AbstractController
{
    #[Test]
    public function productIsFoundByCategory(): void
    {
        $fixtures = DataFixtureStorageManager::getStorage();
        $category = $fixtures->get('cat');
        $product  = $fixtures->get('prod');

        $repository = Bootstrap::getObjectManager()->get(\Magento\Catalog\Api\ProductRepositoryInterface::class);
        $loaded     = $repository->getById($product->getId());

        $this->assertSame('Test Product', $loaded->getName());
        $this->assertContains($category->getId(), $loaded->getCategoryIds());
    }
}

7. Verzeichnisstruktur und Namenskonventionen

Eine klare Verzeichnisstruktur für Testdaten-Klassen ist Voraussetzung dafür, dass Teams diese Patterns konsistent nutzen. Wenn Builder und Object Mothers in unterschiedlichen Ordnern und ohne erkennbare Namenskonvention verstreut sind, werden sie nicht gefunden und stattdessen neue Boilerplate geschrieben. Eine bewährte Struktur trennt nach Testtyp und Hilfsklasse: tests/Fixture/ für Object Mothers und Fixture-Klassen, tests/Builder/ für Builder-Klassen, tests/Integration/Fixture/ für Magento-Fixture-Dateien.

Namenskonventionen signalisieren den Zweck einer Klasse sofort: ProductMother ist eine Object-Mother-Klasse, OrderBuilder ist ein Builder, CreateProductFixture ist eine Magento-Fixture-Klasse. Diese Konventionen müssen im Team dokumentiert und in Code-Reviews durchgesetzt werden. Ein Linting-Regel, die sicherstellt, dass Klassen im tests/Builder/-Verzeichnis auf Builder enden, automatisiert die Durchsetzung.

8. Ansätze im Vergleich

Die vier Hauptpatterns für Testdaten decken unterschiedliche Anwendungsfälle ab. Die Wahl des richtigen Patterns für den jeweiligen Kontext ist wichtiger als die strikte Einheitlichkeit. Viele Projekte nutzen alle vier Patterns gleichzeitig – für unterschiedliche Abstraktionsebenen und Testszenarien.

Pattern Stärke Schwäche Einsatz
Object Mother Sprechende Varianten, zentrale Wartung Wenig flexibel für Variationen Standardobjekte, häufig benutzte Szenarien
Builder Maximale Flexibilität, nur relevante Felder Mehr Boilerplate als Object Mother Komplexe Objekte mit vielen Varianten
DataProvider Skalierbare Parameterisierung Nur für gleichförmige Testfälle Gleichartige Fälle, Randwerte, Mengen
Fixture-Klasse Echter DB-Zustand, Rollback Langsamer, abhängig von DB Integrationstests, Magento-Tests

In der Praxis ist die häufigste Kombination: Object Mother für Unit-Tests, DataProvider für parameterisierte Assertions und Fixture-Klassen für Integrationstests. Builder werden eingesetzt, wenn ein Objekt so viele optionale Felder hat, dass Object-Mother-Varianten nicht mehr ausreichen. Die Faustregel: Wenn man mehr als drei benannte Object-Mother-Varianten für dasselbe Objekt braucht, ist ein Builder wahrscheinlich die bessere Wahl.

9. Zusammenfassung

Sauber organisierte Testdaten in PHPUnit-Tests sind keine Kür, sondern eine Investition in die Wartbarkeit der Testsuite. Object Mother liefert sprechende, benannte Standardobjekte aus einer einzigen Quelle der Wahrheit. Builder erlaubt maximale Flexibilität für komplexe Objekte ohne Boilerplate in den Testmethoden selbst. DataProvider skaliert parameterisierte Aussagen ohne Codeduplizierung. Fixture-Klassen stellen echte Datenbankzustände für Integrationstests sicher.

Der entscheidende Faktor ist Konsistenz: Wenn alle Tests im Projekt dieselben Patterns verwenden, werden neue Testklassen automatisch nach denselben Konventionen geschrieben. Code-Reviews können sich auf fachliche Korrektheit konzentrieren statt auf die Frage, wie Testdaten zusammengebaut werden sollen. Eine gemeinsam gepflegte Bibliothek aus Object Mothers und Buildern ist ein Team-Asset – sie dokumentiert gleichzeitig die fachlichen Szenarien, die für das Projekt relevant sind.

PHPUnit Fixtures & Testdaten — Das Wichtigste auf einen Blick

Object Mother

Statische Factory für benannte Test-Objekte. Zentrale Wartung, sprechende Varianten. Ideal für häufig verwendete Standardobjekte.

Builder-Pattern

Fluent API mit Defaults für alle Felder. Nur relevante Felder setzen, Rest bleibt Standardwert. Ideal für komplexe Objekte mit vielen Varianten.

DataProvider

Public-static-Methode liefert Testfall-Arrays. Descriptive Keys für lesbare Fehlermeldungen. In PHPUnit 11 über #[DataProvider] verknüpft.

Magento Fixtures

#[DataFixture]-Attribut lädt PHP-Fixture-Dateien mit automatischem Rollback nach Testende. Kein manuelles Aufräumen nötig.

10. FAQ: PHPUnit Fixtures, Builders und Testdaten

1Object Mother vs. Builder – Unterschied?
Object Mother: feste benannte Varianten. Builder: fluent API für beliebige Kombinationen. Object Mother für Standardszenarien, Builder für komplexe variable Objekte.
2Wo sollen Builder und Object Mother liegen?
tests/Fixture/ für Object Mothers, tests/Builder/ für Builder. Klare Namenskonventionen: ProductMother, OrderBuilder.
3DataProvider muss ein Array zurückgeben?
Nein – auch Generator-Funktionen (yield) sind möglich. Bei großen Datensätzen speichereffizienter als Arrays.
4Descriptive Keys in DataProvidern?
Als assoziatives Array: ['standard rate DE 19%' => [100.0, 19.0, 119.0]]. PHPUnit zeigt den Key im Fehlerreport – sofort klar, welcher Fall scheiterte.
5Rollback von Magento Fixtures?
Datenbank-Transaktion wird am Testende abgebrochen. Externe Systeme (Cache, Dateisystem) müssen selbst aufräumen – Rollback greift dort nicht.
6Soll ein Builder immutable sein?
Ja – with*-Methoden klonen sich selbst. Verhindert gegenseitige Beeinflussung wenn ein Builder-Objekt in mehreren Tests geteilt wird.
7DataProvider und Builder kombinieren?
Ja: DataProvider kann Builder-Aufrufe enthalten: ['paid order' => [OrderBuilder::anOrder()->withStatus('complete')->build()]].
8Fixture vs. Builder – was ist der Unterschied?
Fixture: richtet Datenbankzustand ein (Integrationstests). Builder: baut In-Memory-Objekt auf (Unit-Tests). Verschiedene Ebenen, beide lösen Testdaten-Organisation.
9Duplikate in Object Mother vermeiden?
Varianten auf Basismethode aufbauen: outOfStock() ruft standard() auf und ändert nur inStock. Änderungen propagieren automatisch.
10Fixture-Klassen in Produktion oder nur Tests?
Ausschließlich im Testverzeichnis. Fixture-Klassen sind kein Produktionscode und dürfen keine Produktionslogik enthalten.