parametrisierte Tests ohne Codeverdopplung
Wer für jede Eingabevariante eine eigene Testmethode schreibt, vervielfacht Wartungsaufwand statt Testabdeckung. Der Data Provider von PHPUnit trennt Testlogik von Testdaten und macht aus zehn fast identischen Methoden eine einzige, klar lesbare Testmethode mit einem Dutzend Datensätzen.
Inhaltsverzeichnis
- 1. Was ein Data Provider wirklich löst
- 2. Das DataProvider Attribut in PHPUnit 11
- 3. Named Data Sets für lesbare Testausgaben
- 4. Data Provider mit KernelTestCase kombinieren
- 5. Data Provider aus Fixtures und externen Quellen
- 6. Statische vs. nicht statische Data Provider
- 7. Performance bei großen Datensets
- 8. Typische Fehler beim Einsatz von Data Providern
- 9. Data Provider im Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Was ein Data Provider wirklich löst
Ein PHPUnit Data Provider ist eine Methode, die ein Array oder einen Generator mit Testdatensätzen liefert, das PHPUnit anschließend gegen eine einzige Testmethode ausführt. Statt zehn Testmethoden zu schreiben, die sich nur in zwei Zeilen unterscheiden, schreibt man eine Testmethode und einen Data Provider mit zehn Zeilen. Der Vorteil zeigt sich nicht sofort, sondern erst beim zweiten oder dritten neuen Randfall, der ohne Data Provider eine weitere Kopie der Testmethode erzwingen würde.
In Symfony Projekten trifft man Validierungslogik, Preisberechnungen und Formatumwandlungen an, die für dutzende Eingabekombinationen dasselbe Verhalten zeigen müssen. Genau hier zahlt sich ein Data Provider aus, denn die Testmethode bleibt stabil, während neue Fälle nur als zusätzliche Zeile im Provider ergänzt werden. Ein Reviewer sieht auf einen Blick, welche Eingaben getestet werden, ohne zehn fast identische Methodenrümpfe zu vergleichen.
Dieser Artikel zeigt, wie das DataProvider Attribut in PHPUnit 11 syntaktisch funktioniert, wie man Named Data Sets für lesbare Testausgaben nutzt, wie sich Data Provider mit Symfonys KernelTestCase kombinieren lassen und welche Performance-Fallstricke bei großen Datensets auftreten.
2. Das DataProvider Attribut in PHPUnit 11
Seit PHPUnit 10 ist die Annotation @dataProvider im Docblock veraltet, ab PHPUnit 12 wird sie vollständig entfernt. Der moderne Weg ist das PHP-8-Attribut #[DataProvider('methodenname')], das direkt über der Testmethode steht und vom Static-Analyzer sowie der IDE erkannt wird. Der Data Provider selbst ist eine öffentliche, meist statische Methode, die ein Array von Arrays zurückgibt, wobei jedes innere Array die Parameter für einen Aufruf der Testmethode enthält.
Wichtig ist die Reihenfolge: Die Werte im inneren Array werden positionsbasiert auf die Parameter der Testmethode gemappt, nicht namensbasiert. Wer die Reihenfolge im Provider ändert, ohne die Testmethode anzupassen, bekommt einen stillen Fehler, keinen Compile-Fehler. Aus diesem Grund lohnt es sich, Named Data Sets zu verwenden, die im nächsten Abschnitt erklärt werden und genau dieses Risiko reduzieren.
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Pricing;
use App\Pricing\DiscountCalculator;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class DiscountCalculatorTest extends TestCase
{
private DiscountCalculator $calculator;
protected function setUp(): void
{
$this->calculator = new DiscountCalculator();
}
/**
* Data provider: net price, discount percentage, expected result.
* Order matters — positional mapping onto testDiscount() parameters.
*/
public static function discountCases(): array
{
return [
[100.0, 10, 90.0],
[200.0, 25, 150.0],
[50.0, 0, 50.0],
[99.99, 50, 49.995],
];
}
#[Test]
#[DataProvider('discountCases')]
public function testDiscount(float $price, int $percentage, float $expected): void
{
$result = $this->calculator->applyDiscount($price, $percentage);
self::assertEqualsWithDelta($expected, $result, 0.001);
}
}
Ein Data Provider kann auch einen Generator statt eines Arrays zurückgeben. Das ist besonders bei großen oder berechneten Datensets sinnvoll, weil PHPUnit die Datensätze dann nacheinander erzeugt, statt das komplette Array vorab im Speicher zu halten. Bei wenigen Dutzend Datensätzen macht das keinen messbaren Unterschied, bei mehreren tausend Kombinationen kann der Generator-Ansatz den Speicherverbrauch spürbar senken.
3. Named Data Sets für lesbare Testausgaben
Ohne benannte Schlüssel identifiziert PHPUnit einen fehlgeschlagenen Datensatz nur über seinen numerischen Index, etwa testDiscount with data set #2. Das ist bei zwei oder drei Datensätzen noch verständlich, wird aber bei zwanzig Einträgen schnell unübersichtlich, weil man den Index erst im Provider nachzählen muss. Die Lösung ist ein Named Data Set: Man vergibt dem äußeren Array einen sprechenden String-Schlüssel statt eines automatischen numerischen Index.
Named Data Sets zahlen sich besonders in CI-Pipelines aus, wenn die Testausgabe in einem Slack-Kanal oder Merge-Request-Kommentar landet. Statt data set #7 liest ein Reviewer sofort negative discount is rejected und weiß ohne Blick in den Code, welcher fachliche Fall gescheitert ist. Der Mehraufwand für die Benennung ist minimal, der Gewinn an Nachvollziehbarkeit im Team ist erheblich.
<?php
declare(strict_types=1);
/**
* Named data sets — string keys instead of numeric indexes.
* Failed assertions show the key, e.g. "testDiscount with data set 'edge case zero'".
*/
public static function discountCases(): array
{
return [
'standard ten percent' => [100.0, 10, 90.0],
'quarter discount' => [200.0, 25, 150.0],
'edge case zero' => [50.0, 0, 50.0],
'odd cent rounding' => [99.99, 50, 49.995],
'full discount' => [80.0, 100, 0.0],
];
}
4. Data Provider mit KernelTestCase kombinieren
Eine wichtige Einschränkung: Data Provider-Methoden werden von PHPUnit ausgeführt, bevor der Symfony-Kernel gebootet wird. Das bedeutet, dass innerhalb eines Providers kein Zugriff auf den Container, auf Doctrine-Entitäten oder auf gebootete Services möglich ist. Wer versucht, im Provider einen Service aus self::getContainer() zu holen, erhält eine Exception, weil der Kernel zu diesem Zeitpunkt schlicht noch nicht existiert.
Die praxistaugliche Lösung ist, den Data Provider auf primitive Werte zu beschränken, etwa Strings, Zahlen oder Enum-Fälle, und die eigentliche Objekterzeugung erst in der Testmethode selbst vorzunehmen, nachdem self::bootKernel() beziehungsweise das automatische Setup von KernelTestCase gelaufen ist. So bleibt der Data Provider unabhängig vom Container, während die Testmethode die parametrisierten Werte nutzt, um dynamisch Services oder Entitäten zu erzeugen.
<?php
declare(strict_types=1);
namespace App\Tests\Integration\Repository;
use App\Entity\Product;
use App\Repository\ProductRepository;
use PHPUnit\Framework\Attributes\DataProvider;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
final class ProductRepositoryFilterTest extends KernelTestCase
{
/**
* Provider stays container-free — only primitive scalars here.
*/
public static function stockThresholds(): array
{
return [
'no stock' => [0, 0],
'low stock' => [5, 3],
'plenty' => [100, 12],
];
}
#[DataProvider('stockThresholds')]
public function testFindByMinimumStock(int $threshold, int $expectedCount): void
{
self::bootKernel();
$container = static::getContainer();
/** @var ProductRepository $repository */
$repository = $container->get(ProductRepository::class);
$result = $repository->findByMinimumStock($threshold);
self::assertCount($expectedCount, $result);
}
}
5. Data Provider aus Fixtures und externen Quellen
Größere Testsuiten laden Testdaten oft aus YAML- oder JSON-Dateien statt sie hartcodiert im PHP-Array zu pflegen. Ein Data Provider kann problemlos eine Datei einlesen und deren Inhalt in das erwartete Array-Format transformieren. Das ist sinnvoll, wenn Fachexperten die Testfälle pflegen sollen, ohne PHP-Syntax zu kennen, etwa bei komplexen Steuer- oder Rabattregeln mit vielen Randfällen.
Ein Muster, das sich in Symfony Projekten bewährt hat: Eine YAML-Datei im Verzeichnis tests/Fixtures/ enthält eine Liste von Testfällen, der Data Provider parst sie mit dem Symfony Yaml Component und liefert das Ergebnis an PHPUnit weiter. So bleibt die Testmethode unverändert, während neue Testfälle als reine Datenänderung ohne Code-Review-Aufwand für Logikänderungen hinzugefügt werden können.
<?php
declare(strict_types=1);
/**
* Data provider reading test cases from a YAML fixture file.
* Non-developers can add cases without touching PHP code.
*/
public static function taxRulesFromFixture(): iterable
{
$path = __DIR__ . '/../Fixtures/tax_rules.yaml';
$cases = \Symfony\Component\Yaml\Yaml::parseFile($path);
foreach ($cases['cases'] as $name => $case) {
yield $name => [$case['country'], $case['net'], $case['expectedGross']];
}
}
6. Statische vs. nicht statische Data Provider
PHPUnit verlangt seit Version 10, dass Data Provider-Methoden statisch deklariert sind. Der Grund liegt in der internen Ausführungsreihenfolge: PHPUnit sammelt alle Datensätze aller Testmethoden vor der eigentlichen Testausführung, um die Gesamtzahl der Testfälle zu ermitteln und Fortschrittsbalken korrekt anzuzeigen. Eine nicht statische Methode würde eine Instanz der Testklasse voraussetzen, die zu diesem Zeitpunkt noch nicht in dem Zustand existiert, den man erwarten würde.
Das hat eine praktische Konsequenz: Ein Data Provider darf nicht auf Instanzeigenschaften der Testklasse zugreifen, die in setUp() gesetzt werden, denn setUp() läuft für jeden Testfall neu, aber der Provider läuft davor, einmalig und statisch. Wer versehentlich $this->irgendwas in einem als statisch deklarierten Provider verwendet, bekommt einen klaren Fehler vom PHP-Interpreter, weil $this in statischem Kontext schlicht nicht existiert.
7. Performance bei großen Datensets
Ein Data Provider mit tausenden Einträgen kann die Testsuite spürbar verlangsamen, weil PHPUnit für jeden Datensatz eine eigene Testinstanz mit vollem Setup- und Teardown-Zyklus ausführt. Bei einem KernelTestCase-Test mit gebootetem Container und Datenbankzugriff pro Datensatz addiert sich der Overhead schnell zu mehreren Minuten zusätzlicher Laufzeit. Die erste Gegenmaßnahme ist, Datensätze auf tatsächlich unterscheidbare Äquivalenzklassen zu reduzieren, statt jede denkbare Zahl einzeln zu testen.
Für sehr große Data Provider lohnt sich außerdem ein Generator statt eines Arrays, weil PHPUnit die Datensätze dann lazy erzeugt und nicht das komplette Set vor Testbeginn im Speicher hält. In CI-Pipelines kann zusätzlich paratest eingesetzt werden, um Testklassen parallel auf mehrere CPU-Kerne zu verteilen, was bei datengetriebenen Tests mit vielen Kernel-Boots einen deutlichen Zeitgewinn bringt.
<?php
declare(strict_types=1);
/**
* Generator-based provider — lazy evaluation, lower peak memory
* for large combinatorial test data.
*/
public static function largeVatMatrix(): \Generator
{
$countries = ['DE', 'AT', 'CH', 'FR', 'NL'];
$rates = [0, 7, 19, 20, 21];
foreach ($countries as $country) {
foreach ($rates as $rate) {
yield "{$country}-{$rate}%" => [$country, $rate];
}
}
}
8. Typische Fehler beim Einsatz von Data Providern
Der häufigste Fehler ist eine falsche Parameteranzahl: Der Data Provider liefert vier Werte pro Zeile, die Testmethode erwartet aber nur drei Parameter. PHPUnit meldet das nicht immer sofort verständlich, insbesondere wenn zusätzliche Werte einfach ignoriert werden, statt einen Fehler auszulösen. Ein zweiter verbreiteter Fehler ist die Verwechslung von Positions- und Schlüsselreihenfolge, wenn ein Datensatz nachträglich umsortiert wird, ohne die Testmethode entsprechend anzupassen.
<?php
// WRONG: provider returns 4 values, test method only accepts 3 parameters
public static function brokenCases(): array
{
return [
[100.0, 10, 90.0, 'unused label'], // extra value silently ignored
];
}
public function testDiscount(float $price, int $percent, float $expected): void
{
// ...
}
// RIGHT: named data sets keep parameter count and order in sync
public static function fixedCases(): array
{
return [
'ten percent off a hundred' => [100.0, 10, 90.0],
];
}
Ein dritter Fehler betrifft Floating-Point-Vergleiche in Data Provider-Tests: Wer erwartete Werte wie 49.995 mit assertEquals ohne Delta vergleicht, riskiert Fehlschläge durch Rundungsdifferenzen, die nichts mit der eigentlichen Geschäftslogik zu tun haben. assertEqualsWithDelta mit einer sinnvollen Toleranz ist bei Geldbeträgen und Prozentrechnungen fast immer die richtige Wahl.
9. Data Provider im Vergleich
Nicht jede Situation verlangt zwingend nach einem Data Provider. Die folgende Tabelle stellt die gängigen Alternativen gegenüber und zeigt, wann welcher Ansatz die bessere Wahl ist.
| Situation | Unpassend | Empfohlener Ansatz | Begründung |
|---|---|---|---|
| Zehn ähnliche Eingaben | Zehn Testmethoden | Data Provider mit zehn Zeilen | Ein Assertion-Codepfad statt zehn Kopien |
| Container im Test nötig | Objekte direkt im Provider bauen | Nur Skalare im Provider, Objekte in der Testmethode | Provider läuft vor Kernel-Boot |
| Fachexperten pflegen Fälle | Hartcodiertes PHP-Array | YAML-Fixture, per Provider geparst | Kein PHP-Wissen für neue Testfälle nötig |
| Tausende Kombinationen | Array mit allen Fällen vorab | Generator mit yield | Lazy Erzeugung, niedrigerer Speicherverbrauch |
| Ein einziger Sonderfall | Data Provider mit einem Eintrag | Eigene, klar benannte Testmethode | Overhead des Attributs lohnt sich nicht |
Die Tabelle macht deutlich: Ein Data Provider lohnt sich erst ab einer echten Wiederholung derselben Prüflogik. Ein Einzelfall mit eigener fachlicher Bedeutung verdient eine eigene, sprechend benannte Testmethode, keinen Provider mit nur einer Zeile.
Mironsoft
Symfony Testing, PHPUnit Architektur und CI-Pipelines
Testsuite mit wartbaren, parametrisierten Tests aufbauen?
Wir strukturieren bestehende Symfony Testsuiten neu, führen Data Provider dort ein, wo sie Codeverdopplung reduzieren, und beschleunigen langsame CI-Pipelines mit gezielter Parallelisierung.
Test-Audit
Analyse bestehender PHPUnit Testsuiten auf Duplikate und fehlende Data Provider
Refactoring
Testmethoden konsolidieren, Named Data Sets und Fixtures einführen
CI-Beschleunigung
paratest Integration und Laufzeitoptimierung für datengetriebene Tests
10. Zusammenfassung
Der PHPUnit Data Provider löst ein wiederkehrendes Problem in Symfony Testsuiten: dieselbe Prüflogik gegen viele Eingabevarianten, ohne dass Testklassen unkontrolliert wachsen. Das #[DataProvider('methode')] Attribut ersetzt die veraltete Docblock-Annotation und wird von IDEs und statischer Analyse zuverlässig erkannt. Named Data Sets machen fehlgeschlagene Testfälle in CI-Logs sofort verständlich, ohne dass jemand einen numerischen Index nachzählen muss.
Bei der Kombination mit KernelTestCase gilt die wichtigste Regel: Der Data Provider läuft vor dem Kernel-Boot und darf deshalb nur primitive Werte liefern, die eigentliche Objekterzeugung passiert in der Testmethode. Für große Datensets reduzieren Generatoren den Speicherverbrauch, für fachlich gepflegte Testfälle bieten YAML-Fixtures eine saubere Trennung zwischen Testdaten und Testcode. Wer diese Muster konsequent anwendet, bekommt eine Testsuite, die mit der Anzahl der Randfälle wächst, ohne dass die Anzahl der Codezeilen proportional mitwächst.
PHPUnit Data Provider in Symfony: Das Wichtigste auf einen Blick
Attribut statt Annotation
#[DataProvider('methode')] ersetzt @dataProvider, wird in PHPUnit 12 endgültig gefordert.
Named Data Sets
String-Schlüssel im äußeren Array machen fehlgeschlagene Fälle in CI-Logs sofort lesbar.
Container-Verbot im Provider
Provider läuft vor Kernel-Boot, nur Skalare zurückgeben, Objekte erst in der Testmethode erzeugen.
Große Datensets
Generator mit yield statt Array senkt den Speicherverbrauch bei tausenden Kombinationen.