PHPUnit Data Provider in Symfony: Parametrisierte Tests ohne Codeverdopplung
AI generated
SF
{ }
Symfony · PHPUnit · Testing · PHP 8.4
PHPUnit Data Provider in Symfony
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.

18 Min. Lesezeit DataProvider Attribut · Named Sets · Symfony 7 PHPUnit 11 · PHP 8.4

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.

11. FAQ: PHPUnit Data Provider in Symfony

1Was ist ein PHPUnit Data Provider?
Eine Methode, die Testdatensätze liefert. PHPUnit führt die Testmethode einmal pro Datensatz aus, statt für jede Eingabe eine eigene Methode zu benötigen.
2Wie deklariere ich ihn in PHPUnit 11?
Mit dem Attribut #[DataProvider('methodenname')] über der Testmethode. Die Methode muss public und static sein.
3Warum muss die Methode statisch sein?
PHPUnit sammelt alle Datensätze vor der Testausführung. Zu diesem Zeitpunkt existiert noch keine Instanz der Testklasse.
4Zugriff auf den Symfony Container?
Nicht möglich. Provider laufen vor Kernel-Boot. Nur Skalare zurückgeben, Objekte in der Testmethode erzeugen.
5Was sind Named Data Sets?
String-Schlüssel statt numerischer Index. Fehlgeschlagene Tests zeigen einen sprechenden Namen in CI-Logs.
6Testdaten aus YAML laden?
Ja, mit dem Symfony Yaml Component in der Provider-Methode parsen und ins erwartete Format transformieren.
7Generator statt Array wann?
Bei sehr großen Datensets. Generator mit yield erzeugt Datensätze lazy und senkt den Speicherverbrauch.
8Zu viele Werte im Provider?
Überzählige Werte werden oft stillschweigend ignoriert. Parameteranzahl regelmäßig mit statischer Analyse prüfen.
9Rundungsdifferenzen bei Geldbeträgen?
assertEqualsWithDelta mit sinnvoller Toleranz verwenden statt assertEquals ohne Delta.
10Lohnt sich ein Provider fuer einen Sonderfall?
In der Regel nicht. Ein einzelner Sonderfall verdient eine eigene, klar benannte Testmethode.