Vom DoctrineFixturesBundle bis zu realistischen Faker-Daten
Wie du mit dem DoctrineFixturesBundle Fixture-Klassen mit Referenzen zwischen Entities baust, Fixture-Gruppen für dev und test trennst und mit Faker realistische Testdaten erzeugst.
Inhaltsverzeichnis
- 1. Warum strukturierte Testdaten mehr sind als ein SQL-Dump
- 2. Fixture-Klassen mit Referenzen zwischen Entities
- 3. Fixture-Gruppen für unterschiedliche Umgebungen
- 4. Faker-Integration für realistische Testdaten
- 5. Installation und Grundkonfiguration des Bundles
- 6. Fixtures in PHPUnit-Testsuiten nutzen
- 7. Abgrenzung zu Doctrine Migrations
- 8. Performance bei großen Fixture-Datenmengen
- 9. Best Practices für die Organisation von Fixture-Klassen
- 10. Zusammenfassung
- 11. FAQ
1. Warum strukturierte Testdaten mehr sind als ein SQL-Dump
In vielen Projekten wachsen Testdaten organisch: Ein Entwickler legt manuell ein paar Datensätze im Admin-Bereich an, ein anderer exportiert einen SQL-Dump von der eigenen Maschine, und irgendwann weiß niemand mehr, welcher Datensatz wofür gedacht war. Dieser Ansatz funktioniert eine Weile, wird aber spätestens dann zum Problem, wenn ein neues Teammitglied das Projekt aufsetzt oder eine CI-Pipeline reproduzierbare Ausgangsdaten für automatisierte Tests braucht.
Das DoctrineFixturesBundle löst dieses Problem, indem Testdaten als versionierter PHP-Code statt als binärer SQL-Dump im Repository liegen. Jede Fixture-Klasse beschreibt explizit, welche Entities mit welchen Werten erzeugt werden, inklusive der Beziehungen untereinander. Das macht Testdaten nachvollziehbar, review-fähig im Pull-Request und, was besonders wichtig ist, jederzeit reproduzierbar mit einem einzigen Console-Befehl.
2. Fixture-Klassen mit Referenzen zwischen Entities
Eine einzelne Fixture-Klasse implementiert das FixtureInterface mit der Methode load(ObjectManager $manager). Sobald mehrere Entities miteinander verknüpft sind, zum Beispiel eine Bestellung, die einen Kunden referenziert, reicht eine einzelne Klasse aber nicht mehr aus. Hier kommt die ReferenceRepository ins Spiel: Eine Fixture kann einen erzeugten Datensatz mit addReference('customer-1', $customer) unter einem eindeutigen Namen ablegen, damit eine andere Fixture-Klasse später darauf zugreifen kann.
Um die Reihenfolge zu steuern, in der Fixtures geladen werden, implementiert die abhängige Klasse zusätzlich DependentFixtureInterface mit der Methode getDependencies(), die ein Array der Klassennamen zurückgibt, von denen sie abhängt. Das folgende Beispiel zeigt eine OrderFixtures-Klasse, die auf eine zuvor mit CustomerFixtures angelegte Referenz zugreift, um jeder Bestellung einen gültigen Kunden zuzuweisen.
<?php
declare(strict_types=1);
namespace App\DataFixtures;
use App\Entity\Order;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Bundle\FixturesBundle\FixtureGroupInterface;
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
use Doctrine\Persistence\ObjectManager;
final class OrderFixtures extends Fixture implements DependentFixtureInterface, FixtureGroupInterface
{
public function load(ObjectManager $manager): void
{
for ($i = 1; $i <= 5; ++$i) {
$order = new Order();
$order->setCustomer($this->getReference('customer-1', \App\Entity\Customer::class));
$order->setTotalAmount(random_int(1000, 50000));
$manager->persist($order);
$this->addReference('order-' . $i, $order);
}
$manager->flush();
}
public function getDependencies(): array
{
return [CustomerFixtures::class];
}
public static function getGroups(): array
{
return ['dev', 'test'];
}
}
3. Fixture-Gruppen für unterschiedliche Umgebungen
Nicht jede Fixture ist für jede Umgebung sinnvoll. Ein großer Datensatz mit tausend zufälligen Bestellungen ist praktisch für manuelle Tests im dev-Environment, verlangsamt aber jede einzelne CI-Ausführung unnötig, wenn ein Test nur wenige, klar definierte Datensätze benötigt. Das FixtureGroupInterface löst das über die statische Methode getGroups(), die eine oder mehrere Gruppen wie dev, test oder demo zurückgibt.
Beim Laden über die Console kann anschließend gezielt gefiltert werden, etwa mit bin/console doctrine:fixtures:load --group=test, um ausschließlich die für automatisierte Tests relevanten Fixtures zu laden. So bleibt die dev-Umgebung reich an realistischen Beispieldaten für manuelles Testen, während die CI-Pipeline nur eine minimale, schnelle Teilmenge lädt, was die Ausführungszeit der Testsuite spürbar reduziert.
4. Faker-Integration für realistische Testdaten
Statisch hartcodierte Werte wie Test User 1 oder test1@example.com wirken schnell künstlich und decken selten Randfälle wie Umlaute, Sonderzeichen oder unterschiedlich lange Namen ab. Die Bibliothek fakerphp/faker generiert stattdessen realistische Zufallsdaten wie Namen, Adressen oder E-Mail-Adressen und lässt sich problemlos in eine Fixture-Klasse injizieren, meist über einen im Konstruktor erzeugten Faker\Generator.
Wichtig für reproduzierbare Tests ist, den Zufallsgenerator von Faker mit einem festen Seed zu initialisieren, etwa über Factory::create('de_DE')->seed(1234), damit bei jedem Fixture-Load exakt dieselben Werte erzeugt werden. Ohne einen festen Seed würden CI-Läufe unterschiedliche Daten produzieren, was Assertions in Tests, die auf konkrete Werte prüfen, unzuverlässig macht.
5. Installation und Grundkonfiguration des Bundles
Das DoctrineFixturesBundle wird über composer require --dev doctrine/doctrine-fixtures-bundle installiert und ist damit bewusst nur als Dev-Dependency eingebunden, da Fixtures niemals in einer echten Produktionsumgebung ausgeführt werden sollen. Symfony Flex registriert das Bundle automatisch und legt ein leeres src/DataFixtures-Verzeichnis an, in dem jede neue Fixture-Klasse als eigene Datei abgelegt wird.
Der Ladevorgang selbst erfolgt über bin/console doctrine:fixtures:load, wobei standardmäßig eine Sicherheitsabfrage erscheint, da der Befehl die Zieldatenbank vollständig leert, bevor die neuen Daten eingespielt werden. Mit dem Flag --append lässt sich dieses Verhalten umgehen, wenn Fixtures zu bestehenden Daten hinzugefügt statt sie zu ersetzen sollen, was etwa für inkrementelle Demo-Daten sinnvoll sein kann.
6. Fixtures in PHPUnit-Testsuiten nutzen
Für funktionale Tests mit dem Symfony-KernelTestCase bietet sich an, Fixtures nicht über die Console, sondern direkt im Test-Setup über den Purger und Executor aus Doctrine\Common\DataFixtures zu laden. Ein ORMExecutor kombiniert mit einem ORMPurger stellt sicher, dass die Testdatenbank vor jedem Testlauf in einen definierten Ausgangszustand zurückgesetzt wird, unabhängig davon, was ein vorheriger Test möglicherweise verändert hat.
Für größere Testsuiten lohnt sich eine eigene Basisklasse, etwa FixtureAwareTestCase, die diese Logik einmal zentral kapselt und in setUp() automatisch die benötigten Fixture-Klassen lädt. So muss jeder einzelne Testfall nur noch angeben, welche Fixtures er konkret benötigt, ohne die Purger- und Executor-Logik jedes Mal zu wiederholen.
7. Abgrenzung zu Doctrine Migrations
Ein häufiges Missverständnis ist, Fixtures und Migrations als austauschbar zu betrachten. Migrations verändern die Struktur der Datenbank, also Tabellen, Spalten und Indizes, und müssen in jeder Umgebung inklusive Produktion in exakt derselben Reihenfolge ausgeführt werden. Fixtures dagegen füllen eine bereits existierende Struktur mit konkreten Datensätzen und sind explizit nicht für die Produktionsumgebung gedacht.
Eine Ausnahme bilden reine Stammdaten wie Ländercodes oder feste Konfigurationswerte, die tatsächlich auch in Produktion vorhanden sein müssen. Für solche Fälle empfiehlt sich statt einer klassischen Fixture eine Doctrine-Migration mit eingebetteten INSERT-Statements, da Migrations versioniert, einmalig und produktionssicher ausgeführt werden, während Fixtures bei jedem Aufruf die Zieltabellen komplett leeren.
8. Performance bei großen Fixture-Datenmengen
Wird in einer Schleife für jeden einzelnen Datensatz sofort flush() aufgerufen, leidet die Ladezeit bei tausenden Datensätzen spürbar, da jeder flush einen eigenen Datenbank-Roundtrip auslöst. Deutlich schneller ist es, alle Entities zunächst nur zu persist()en und erst am Ende der Schleife einmalig zu flushen, so wie im OrderFixtures-Beispiel oben gezeigt.
Bei sehr großen Datenmengen, etwa Zehntausenden Testdatensätzen, lohnt sich zusätzlich ein periodisches clear() des EntityManagers alle paar hundert Datensätze, um den internen Identity-Map-Speicher zu entlasten und den Speicherverbrauch während des Ladevorgangs stabil zu halten, was besonders in CI-Umgebungen mit begrenztem Arbeitsspeicher relevant werden kann.
9. Best Practices für die Organisation von Fixture-Klassen
Es empfiehlt sich, pro Entity oder fachlichem Bereich eine eigene Fixture-Klasse anzulegen, statt alle Daten in einer einzigen riesigen Klasse zu bündeln. Das erleichtert es, gezielt einzelne Bereiche für bestimmte Tests zu laden, und macht Merge-Konflikte im Team seltener, weil unterschiedliche Entwickler an unterschiedlichen Fixture-Dateien arbeiten können, ohne sich gegenseitig zu blockieren.
Ebenso sinnvoll ist eine konsistente Namenskonvention für Referenzen, etwa immer entity-typ-nummer wie customer-1 oder order-3, damit abhängige Fixture-Klassen vorhersehbar auf die richtigen Referenzen zugreifen können. Wer diese Konventionen von Anfang an dokumentiert, etwa in einer README innerhalb von src/DataFixtures, erspart neuen Teammitgliedern viel Zeit beim Einarbeiten in die Testdatenstruktur.
| Baustein | Zweck | Wichtige Methode/Attribut | Typischer Einsatz |
|---|---|---|---|
| FixtureInterface | Basis für jede Fixture-Klasse | load(ObjectManager $manager) | Grunddaten für eine Entity erzeugen |
| DependentFixtureInterface | Steuert Ladereihenfolge | getDependencies() | Bestellung nach Kunde laden |
| FixtureGroupInterface | Ordnet Fixtures Umgebungen zu | getGroups() | Trennung dev/test/demo |
| ORMPurger + ORMExecutor | Setzt Testdatenbank definiert zurück | execute($fixtures) | Funktionale Tests mit sauberem Ausgangszustand |
Mironsoft
Symfony-Architektur, saubere Domänenlogik und Legacy-Modernisierung
Symfony-Anwendungen, die nach zwei Jahren noch wartbar sind?
Wir prüfen bestehende Symfony-Projekte auf aufgeblähte Controller, fehlende Serviceabstraktionen und ungetestete Kernlogik und bauen daraus eine Architektur, die neue Features aufnimmt, ohne bei jedem Release brüchiger zu werden.
Architektur-Review
Bundle-Struktur, Dependency Injection und Serviceabstraktionen auf Wartbarkeit prüfen.
Legacy-Modernisierung
Schrittweise Migration veralteter Symfony-Versionen ohne kompletten Neuschrieb.
Test- und Qualitätssicherung
PHPUnit, PHPStan und CI-Pipelines für nachhaltige Codequalität aufbauen.
10. Zusammenfassung
Doctrine Fixtures
Fixture-Klasse
load() erzeugt Datensätze, Referenzen verbinden abhängige Entities
Gruppen
getGroups() trennt dev-, test- und demo-Daten sauber voneinander
Faker
Realistische Zufallsdaten mit festem Seed für reproduzierbare Tests
Abgrenzung
Migrations ändern Struktur, Fixtures füllen sie mit Testdaten