@test
assert
PHPUnit · Magento 2 · Test Fixtures · DataBuilder · Integrationstests
PHPUnit Testdaten in Magento sauber aufbauen
statt Fixture-Hölle mit XML-Dateien

Magento-Integrationstests mit XML-Fixtures sind fragil, schwer wartbar und kaum lesbar. PHP-basierte Fixtures, DataBuilder-Pattern und konsequente Nutzung des ObjectManagers erzeugen Testdaten, die lesbar, zuverlässig und einfach anpassbar sind – ohne Fixture-Dateien, die nach jedem Schema-Update brechen.

20 Min. Lesezeit DataBuilder · PHP-Fixtures · ObjectManager · Factory-Pattern · Rollback Magento 2.4.8 · PHPUnit 10/11 · PHP 8.4

1. Das Problem mit XML-Fixtures in Magento

Magento 2 hat ein eigenes Fixture-System, das über PHP-Annotationen wie @magentoDataFixture auf PHP-Dateien oder Fixture-Dateien verweist. Dieses System ist historisch gewachsen und hat erhebliche Schwächen: XML-Fixtures für Produkte, Kategorien oder Kunden sind verbose, schwer zu lesen und brechen bei Schema-Änderungen. Ein Produkt-Fixture mit fünf Attributen braucht schnell 50 Zeilen XML, die keiner auf einen Blick versteht.

Der tiefere Grund für die sogenannte "Fixture-Hölle" ist der Ansatz, Testdaten deklarativ in Dateien zu beschreiben statt programmatisch zu erstellen. Programmatische Testdaten können auf andere Testdaten aufbauen, Variationen durch einfache Parametrierung erzeugen und die Test-Absicht durch sprechende Builder-Methoden kommunizieren. XML-Fixtures können das nicht: Sie sind statisch, haben keine bedingten Felder und lassen sich nicht ohne weiteres kombinieren. Das führt dazu, dass Projekte nach einigen Monaten dutzende leicht unterschiedliche Fixture-Dateien haben, die niemand mehr überblickt.

Die Alternative ist nicht der vollständige Verzicht auf das Magento-Fixture-System, sondern dessen gezielte Nutzung für einfache, stabile Stammdaten – kombiniert mit PHP-basierten DataBuilders für komplexe, variationsreiche Testdaten. Dieser Beitrag zeigt den praktischen Weg dorthin.

2. PHP-Fixtures: Testdaten programmatisch aufbauen

PHP-Fixtures in Magento sind einfache PHP-Dateien, die ohne Klassen- oder Funktionsdeklaration direkt ausgeführt werden. Sie haben Zugriff auf den Magento-Bootstrap und den ObjectManager und können alle Magento-Services nutzen. Das Magento-Fixture-System führt diese Dateien in einer Transaktion aus und rollt sie nach dem Test automatisch zurück. Der Vorteil gegenüber XML: Volle PHP-Mächtigkeit, Wiederverwendung von Magento-Repositories und Services, und Lesbarkeit durch sprechenden Code.


<?php
// Test/Integration/_files/product_with_custom_options.php
// PHP fixture: programmatic product creation with custom options

declare(strict_types=1);

use Magento\Catalog\Api\Data\ProductInterfaceFactory;
use Magento\Catalog\Api\ProductRepositoryInterface;
use Magento\TestFramework\Helper\Bootstrap;

$objectManager = Bootstrap::getObjectManager();

/** @var ProductInterfaceFactory $productFactory */
$productFactory = $objectManager->get(ProductInterfaceFactory::class);

/** @var ProductRepositoryInterface $productRepository */
$productRepository = $objectManager->get(ProductRepositoryInterface::class);

$product = $productFactory->create();
$product->setSku('test-product-options-001')
    ->setName('Test Product With Options')
    ->setTypeId(\Magento\Catalog\Model\Product\Type::TYPE_SIMPLE)
    ->setAttributeSetId(4)
    ->setWebsiteIds([1])
    ->setVisibility(\Magento\Catalog\Model\Product\Visibility::VISIBILITY_BOTH)
    ->setStatus(\Magento\Catalog\Model\Product\Attribute\Source\Status::STATUS_ENABLED)
    ->setPrice(29.99)
    ->setStockData(['qty' => 100, 'is_in_stock' => 1]);

$productRepository->save($product);

PHP-Fixtures können auch Rollback-Dateien haben: Eine Datei product_with_custom_options_rollback.php im selben Verzeichnis wird nach dem Test ausgeführt, wenn die Transaktion nicht ausreicht – etwa für Daten, die außerhalb der Transaktion erzeugt wurden. Der Rollback-Mechanismus ist in PHPUnit-Integrationstests von Magento explizit dokumentiert und sollte für alle Fixtures genutzt werden, die Dateisystemoperationen oder externe Zustandsänderungen durchführen.

3. DataBuilder-Pattern: lesbare, flexible Testdaten

Das DataBuilder-Pattern ist ein aus der Java-Welt stammendes Testmuster, das in PHP-Projekten besonders gut funktioniert, wenn Testdaten variationsreich sein müssen. Ein DataBuilder ist eine PHP-Klasse mit einer Fluent-API, die ein Domänenobjekt mit sinnvollen Standardwerten erzeugt. Jede Builder-Methode überschreibt einen Standardwert. Am Ende erzeugt build() das fertige Objekt und persistiert es optional über ein Repository.


<?php
// Test/Integration/DataBuilder/ProductBuilder.php
// Fluent builder for creating test products with sensible defaults

declare(strict_types=1);

namespace Mironsoft\Catalog\Test\Integration\DataBuilder;

use Magento\Catalog\Api\Data\ProductInterface;
use Magento\Catalog\Api\Data\ProductInterfaceFactory;
use Magento\Catalog\Api\ProductRepositoryInterface;
use Magento\TestFramework\Helper\Bootstrap;

final class ProductBuilder
{
    private string $sku = 'test-product-001';
    private string $name = 'Test Product';
    private float $price = 19.99;
    private int $qty = 100;
    private bool $inStock = true;
    private int $status = 1;

    /**
     * Creates a new ProductBuilder with default values.
     */
    public static function aProduct(): self
    {
        return new self();
    }

    /**
     * Overrides the SKU for the product being built.
     */
    public function withSku(string $sku): self
    {
        $clone = clone $this;
        $clone->sku = $sku;
        return $clone;
    }

    /**
     * Overrides the price for the product being built.
     */
    public function withPrice(float $price): self
    {
        $clone = clone $this;
        $clone->price = $price;
        return $clone;
    }

    /**
     * Marks the product as out of stock.
     */
    public function outOfStock(): self
    {
        $clone = clone $this;
        $clone->qty = 0;
        $clone->inStock = false;
        return $clone;
    }

    /**
     * Builds and persists the product, returning it with its assigned ID.
     */
    public function build(): ProductInterface
    {
        $om = Bootstrap::getObjectManager();
        $factory = $om->get(ProductInterfaceFactory::class);
        $repo = $om->get(ProductRepositoryInterface::class);

        $product = $factory->create();
        $product->setSku($this->sku)
            ->setName($this->name)
            ->setPrice($this->price)
            ->setTypeId('simple')
            ->setAttributeSetId(4)
            ->setWebsiteIds([1])
            ->setVisibility(4)
            ->setStatus($this->status)
            ->setStockData(['qty' => $this->qty, 'is_in_stock' => (int)$this->inStock]);

        return $repo->save($product);
    }
}

Im Test selbst wird der Builder so genutzt: $product = ProductBuilder::aProduct()->withSku('special-001')->withPrice(9.99)->outOfStock()->build();. Diese Zeile kommuniziert die Test-Absicht direkt im Code, ohne dass der Entwickler eine Fixture-Datei öffnen muss. Mehrere Produkte mit leichten Variationen lassen sich durch Klonen des Builders in wenigen Zeilen erstellen – das ist mit XML-Fixtures nicht möglich.

4. ObjectManager im Integrationstest nutzen

In Magento-Integrationstests ist der ObjectManager das zentrale Werkzeug zum Erzeugen von Services. Anders als im Produktionscode ist die direkte Nutzung des ObjectManagers in Tests ausdrücklich erlaubt und empfohlen – Dependency Injection via Konstruktor funktioniert in PHPUnit-Testklassen mit Magento nicht ohne spezielle Mechanismen. Der Zugriff erfolgt über Bootstrap::getObjectManager(), das eine vollständig initialisierte Magento-Instanz zurückgibt.

Wichtig: Nicht alle Magento-Objekte sollten über den ObjectManager direkt erzeugt werden. Repositories und Services sind der richtige Weg für die Datenpersistierung. Models direkt über $om->create() zu erzeugen und zu speichern, umgeht die Business-Logik, die in Repositories implementiert ist, und erzeugt inkonsistente Testdaten. Die Faustregel: Immer die öffentliche API (Repositories, Service-Contracts) nutzen, niemals direkt auf Models oder Ressource-Models zugreifen.

5. Rollback und Isolation: Daten nach Tests aufräumen

Magento-Integrationstests laufen standardmäßig in Datenbank-Transaktionen, die nach jedem Test zurückgerollt werden. Das garantiert, dass Tests sich gegenseitig nicht beeinflussen, und ist der Hauptvorteil gegenüber Tests, die echte Commits an die Datenbank senden. Die Transaktion wird automatisch von der Magento-Testinfrastruktur verwaltet – PHP-Fixtures, die in diesem Modus laufen, werden vollständig zurückgerollt, ohne dass eine Rollback-Datei benötigt wird.


<?php
// Test/Integration/Service/PriceCalculatorTest.php
// Integration test using DataBuilder for clean, isolated test data

declare(strict_types=1);

namespace Mironsoft\Catalog\Test\Integration\Service;

use Magento\TestFramework\TestCase\AbstractController;
use Mironsoft\Catalog\Test\Integration\DataBuilder\ProductBuilder;
use Mironsoft\Catalog\Test\Integration\DataBuilder\CustomerBuilder;
use Mironsoft\Catalog\Api\PriceCalculatorInterface;
use Magento\TestFramework\Helper\Bootstrap;

class PriceCalculatorTest extends AbstractController
{
    private PriceCalculatorInterface $priceCalculator;

    protected function setUp(): void
    {
        parent::setUp();
        $this->priceCalculator = Bootstrap::getObjectManager()
            ->get(PriceCalculatorInterface::class);
    }

    /**
     * Tests that VIP customers receive a 20% discount.
     */
    public function testVipCustomerReceivesDiscount(): void
    {
        // Arrange: create test data inline, no fixture files needed
        $product = ProductBuilder::aProduct()
            ->withSku('price-test-001')
            ->withPrice(100.00)
            ->build();

        $customer = CustomerBuilder::aCustomer()
            ->withGroup('VIP')
            ->build();

        // Act
        $calculatedPrice = $this->priceCalculator->calculateFor(
            $product->getId(),
            $customer->getId()
        );

        // Assert
        self::assertEqualsWithDelta(80.00, $calculatedPrice, 0.01,
            'VIP customer should receive 20% discount on full price');
    }

    /**
     * Tests that out-of-stock products are not eligible for pricing.
     */
    public function testOutOfStockProductThrowsException(): void
    {
        $product = ProductBuilder::aProduct()
            ->withSku('oos-test-001')
            ->outOfStock()
            ->build();

        $this->expectException(\Mironsoft\Catalog\Exception\ProductNotAvailableException::class);

        $this->priceCalculator->calculateFor($product->getId(), 1);
    }
}

Ein häufiges Problem mit der Transaktions-Isolation: Magento-Events, die bei Datenbankoperationen ausgelöst werden, können Seiteneffekte außerhalb der Transaktion erzeugen – etwa Cache-Einträge, Suchindex-Dokumente oder Mediendateien. Diese Seiteneffekte werden nicht zurückgerollt. Tests, die sich auf den Cache oder den Suchindex verlassen, müssen diese Zustände explizit zurücksetzen oder verwalten.

6. Geteilte Fixtures: setUp vs. setUpBeforeClass

Die Wahl zwischen setUp() und setUpBeforeClass() hat erheblichen Einfluss auf Testlaufzeiten und Isolation. setUp() läuft vor jedem einzelnen Test und garantiert vollständige Isolation – jeder Test bekommt frische Testdaten. setUpBeforeClass() läuft einmal vor allen Tests der Klasse und teilt die Testdaten zwischen allen Tests. Das ist erheblich schneller, wenn der Testdaten-Aufbau teuer ist (mehrere Sekunden Datenbankoperationen), erfordert aber sorgfältiges Testdesign, weil Tests bei geteilten Daten implizite Abhängigkeiten entwickeln können.

Die Empfehlung für Magento-Integrationstests: setUp() für Tests, die Daten verändern; setUpBeforeClass() nur für Tests, die Daten nur lesen. Datenbankoperationen in setUpBeforeClass() laufen außerhalb der Magento-Transaktionsverwaltung und erfordern explizite Rollback-Logik in tearDownAfterClass(). Das ist ein häufig übersehener Fallstrick, der zu Testdaten-Verschmutzung zwischen Testklassen führt.

7. Magento-eigene Factories als Testhelfer

Magento generiert für jedes Model und jedes Data-Interface automatisch eine Factory-Klasse. Diese Factories sind im Integrationstest über den ObjectManager verfügbar und sollten der direkten Nutzung von new vorgezogen werden, weil sie den DI-Mechanismus von Magento nutzen und alle konfigurierten Plugins, Interceptors und Preferences berücksichtigen. Ein Produkt, das über die Factory erzeugt wird, ist ein "echtes" Magento-Produkt – eines, das über new Product() erzeugt wird, ist ein nacktes Objekt ohne Magento-Kontext.

Ansatz Lesbarkeit Wartbarkeit Flexibilität Empfehlung
XML-Fixtures Schlecht Schlecht (bricht bei Schema-Änderungen) Keine Variationen Nur für einfache Stammdaten
PHP-Fixtures (statisch) Mittel Mittel Wenig Variationen Für einfache, stabile Testdaten
DataBuilder-Pattern Sehr gut Sehr gut Beliebige Variationen Empfohlen für komplexe Tests
Factory-Klassen direkt Mittel Mittel (Boilerplate) Gut Als Basis für DataBuilder

8. Häufige Fehler beim Testdaten-Aufbau

Der häufigste Fehler: Testdaten werden im Test selbst über direkte SQL-Statements oder über den ResourceModel-Layer aufgebaut, ohne die Business-Logik-Schicht (Repository) zu nutzen. Das führt zu Testdaten, die nicht dem entsprechen, was die Applikation in der Produktion erzeugen würde – fehlende Indexeinträge, fehlende Cache-Einträge, inkonsistente Relationen. Tests, die auf solchen Daten aufbauen, können grün sein, obwohl die getestete Logik mit echten Daten fehlschlagen würde.

Ein zweiter häufiger Fehler: Zu viele Testdaten in einem Fixture aufgebaut. Wenn ein Fixture 20 Produkte, 5 Kategorien, 3 Kunden und 10 Bestellungen erstellt, um einen einzelnen Test zu ermöglichen, ist das ein Zeichen für mangelnde Test-Isolation. Jeder Test sollte nur die Testdaten aufbauen, die er tatsächlich benötigt. DataBuilder mit minimalen Standardwerten helfen dabei, den Testdaten-Aufbau auf das Nötigste zu beschränken.


<?php
// WRONG: direct SQL bypass — skips business logic, creates inconsistent data
$connection = Bootstrap::getObjectManager()
    ->get(\Magento\Framework\App\ResourceConnection::class)
    ->getConnection();
$connection->insert('catalog_product_entity', [
    'sku' => 'bad-fixture',
    'type_id' => 'simple',
    // Missing: website_ids, stock data, attribute values...
]);

// WRONG: building far too much data for a focused test
$this->createFullCatalogWithCategories();
$this->createCustomersWithAddresses();
$this->createOrderHistory();
// ... only to test a single price calculation

// RIGHT: DataBuilder with minimal, focused test data
$product = ProductBuilder::aProduct()
    ->withPrice(100.00)
    ->build();
// Test only what you need — nothing more

9. XML-Fixtures vs. PHP-Fixtures im Vergleich

Die Entscheidung zwischen XML-Fixtures, PHP-Fixtures und DataBuilders hängt vom Kontext ab. XML-Fixtures sind sinnvoll für Stammdaten, die sich selten ändern und in vielen Tests gleich benötigt werden – zum Beispiel eine Standard-Website-Konfiguration oder eine feste Kategoriehierarchie. PHP-Fixtures sind besser für Testdaten, die Magento-Services nutzen und komplexere Objekte erzeugen. DataBuilder sind die beste Wahl für Tests, die viele leicht unterschiedliche Variationen derselben Daten benötigen.

10. Zusammenfassung

Testdaten in Magento-Integrationstests sauber aufzubauen erfordert einen bewussten Ansatz, der die Stärken des Magento-Fixture-Systems nutzt, ohne in die Fixture-Hölle zu geraten. PHP-Fixtures, die Magento-Repositories und Service-Contracts nutzen, sind lesbar, wartbar und konsistent mit der Produktions-Logik. Das DataBuilder-Pattern macht Testdaten-Variationen zu einem trivialen Problem und kommuniziert die Test-Absicht direkt im Code.

Die wichtigsten Prinzipien: Immer die öffentliche API (Repositories, Service-Contracts) für Testdaten nutzen, niemals direkte SQL-Statements oder ResourceModel-Layer. Testdaten auf das für jeden Test nötige Minimum beschränken. Für komplexe, variationsreiche Testdaten DataBuilder einsetzen. Rollback-Verhalten explizit verstehen und bei Bedarf Rollback-Dateien bereitstellen.

Testdaten in Magento sauber aufbauen — Das Wichtigste auf einen Blick

Immer Repositories nutzen

Testdaten über Repositories und Service-Contracts aufbauen, niemals über SQL-Statements oder ResourceModel direkt. Nur so sind Testdaten konsistent mit der Produktions-Logik.

DataBuilder-Pattern

Fluent Builder-Klassen mit sinnvollen Standardwerten ermöglichen beliebige Variationen mit minimalem Code. Test-Absicht wird direkt im Test-Code kommuniziert.

Minimale Testdaten

Jeder Test baut nur die Daten auf, die er tatsächlich benötigt. Zu viele Fixture-Daten sind ein Zeichen für mangelnde Test-Isolation.

Rollback verstehen

Transaktions-Rollback deckt die meisten Fälle ab, aber nicht Seiteneffekte wie Cache, Index oder Dateisystem. Diese erfordern explizite Rollback-Logik.

11. FAQ: PHPUnit Testdaten in Magento sauber aufbauen

1Was ist der Unterschied zwischen @magentoDataFixture und PHP-Fixtures?
@magentoDataFixture verweist auf eine PHP-Datei, die Magento vor dem Test ausführt. PHP-Fixtures nutzen die volle Magento-API und sind wesentlich flexibler als XML-Fixtures.
2Warum keine SQL-Statements direkt im Test ausführen?
SQL umgeht Magento-Business-Logik: kein Index, kein Event, kein Plugin. Tests auf solchen Daten können grün sein, obwohl die Logik mit echten Daten fehlschlägt.
3Was ist das DataBuilder-Pattern?
PHP-Klasse mit Fluent-API, die Testdaten mit sinnvollen Standardwerten erzeugt. Einzelne Werte werden durch Builder-Methoden überschrieben – beliebige Variationen mit minimalem Code.
4setUp() vs. setUpBeforeClass() für Fixtures?
setUp() für Tests, die Daten verändern – vollständige Isolation. setUpBeforeClass() nur für lesende Tests wenn der Aufbau teuer ist. Explizite tearDownAfterClass()-Logik nicht vergessen.
5Wie funktioniert der automatische Rollback?
Jeder Integrationstest läuft in einer Datenbank-Transaktion, die danach zurückgerollt wird. Seiteneffekte außerhalb der DB (Cache, Index, Dateisystem) werden nicht zurückgerollt.
6Warum ist zu viel Fixture-Daten ein Problem?
Verlangsamt Tests und schafft implizite Abhängigkeiten. Jeder Test sollte nur die Daten aufbauen, die er tatsächlich benötigt – nicht mehr.
7DataBuilder und @magentoDataFixture kombinieren?
Ja. @magentoDataFixture für stabile Stammdaten, DataBuilder für testspezifische, variationsreiche Daten. Die Kombination reduziert Redundanz und verbessert Lesbarkeit.
8Wie implementiert man Rollback für Fixture-Seiteneffekte?
Rollback-Datei mit Suffix _rollback.php im selben Verzeichnis. Magento führt sie nach dem Test aus. Alle Seiteneffekte außerhalb der DB-Transaktion dort beseitigen.
9new Product() oder Factory-Klasse?
Immer Factory. new Product() erzeugt ein nacktes Objekt ohne DI-Konfiguration und Plugin-Verarbeitung. Die Factory erzeugt ein vollständig initialisiertes Magento-Objekt.
10Häufigster Grund für langsame Magento-Integrationstests?
Zu viele DB-Operationen im Fixture-Aufbau, und setUp() statt setUpBeforeClass() für lesende Tests. DataBuilder mit minimalen Defaults und Fixture-Wiederverwendung reduzieren die Laufzeit.