@test
assert
PHPUnit · DataProvider · Parametrisierte Tests · Clean Tests
DataProvider sinnvoll nutzen
statt duplizierter Tests

Drei Tests, die dasselbe prüfen – nur mit unterschiedlichen Eingabewerten – sind kein Zeichen guter Testabdeckung. Sie sind technische Schulden. DataProvider lösen dieses Problem elegant: Eine Test-Methode, viele Datensätze, jeder als separater, benannter Testfall. Richtig eingesetzt reduzieren DataProvider Duplizierung und erhöhen Lesbarkeit gleichzeitig.

11 Min. Lesezeit DataProvider · Benennung · Struktur · Grenzen · PHPUnit 10/11 PHPUnit 10/11 · PHP 8.x · Attribute-Syntax

1. Was DataProvider sind und wann sie helfen

Ein DataProvider ist eine statische Methode, die eine Menge von Eingabe-Erwartungs-Paaren zurückgibt. PHPUnit führt die annotierte Test-Methode für jeden dieser Datensätze einmal aus – jeder Datensatz wird als separater Testfall gezählt und erscheint im Test-Report. Das Ergebnis: Eine Methode testet viele Varianten, ohne dass der Testcode dupliziert wird.

DataProvider helfen am meisten bei Tests, die dasselbe Verhalten für verschiedene Eingabewerte prüfen. Typische Kandidaten: Validierungslogik (gültige und ungültige Eingaben), Formatierungsfunktionen (Zahlen, Datumsangaben, Währungen), Berechnungen mit mehreren Variablen, State-Machine-Übergänge. Kurz: Wenn die Test-Methode bei verschiedenen Datensätzen identisch ist und nur die Eingaben variieren, ist ein DataProvider die richtige Lösung.

DataProvider sind keine Universallösung. Wenn Tests unterschiedliche Setup-Logik, unterschiedliche Assertions oder unterschiedliche Abhängigkeiten benötigen, gehören sie als separate Test-Methoden geschrieben. Ein DataProvider, der zehn Zeilen Setup-Variation in jedem Datensatz hat, ist schwerer zu lesen als zehn separate Tests mit klaren, eigenständigen Namen. Die Faustregel: DataProvider reduzieren Code, der wirklich identisch ist. Sie ersetzen keine Tests, die inhaltlich verschieden sind.

2. Grundstruktur und PHPUnit 10 Attribute-Syntax

In PHPUnit 10 und 11 ist die bevorzugte Syntax für DataProvider das PHP 8-Attribut #[DataProvider('methodName')] statt der Annotation @dataProvider methodName. Beide funktionieren, aber Attribute sind typsicher, IDE-kompatibel und folgen dem PHP-8-Paradigma. Die DataProvider-Methode muss public und static sein. Sie gibt ein Array zurück, dessen Schlüssel der Name des Testfalls ist und dessen Werte die Parameter der Test-Methode sind.

Das Array-Format hat sich mit PHPUnit 10 leicht geändert: Früher war ein beliebig verschachteltes Array erlaubt, heute werden Iterables bevorzugt. Yield-basierte DataProvider (Generator-Funktion mit yield) sind die modernste Variante: Sie laden Daten lazy und sind für große Datensätze effizienter. Die Schlüssel des Generators sind die Testfall-Namen – genau wie bei Array-basierten DataProvidern. Beide Stile – Array und Generator – koexistieren problemlos.


<?php

declare(strict_types=1);

namespace Tests\Unit;

use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use App\Domain\PriceFormatter;

/**
 * Demonstrates correct DataProvider usage with PHPUnit 10 attribute syntax.
 */
final class PriceFormatterTest extends TestCase
{
    private PriceFormatter $formatter;

    protected function setUp(): void
    {
        $this->formatter = new PriceFormatter(locale: 'de_DE');
    }

    /**
     * @test
     * Tests price formatting for multiple currencies and amounts.
     */
    #[DataProvider('priceFormattingCases')]
    public function formats_price_correctly(
        float $amount,
        string $currency,
        string $expected
    ): void {
        $this->assertSame($expected, $this->formatter->format($amount, $currency));
    }

    /**
     * Provides test cases for price formatting.
     * Keys are descriptive test case names shown in PHPUnit output.
     *
     * @return array<string, array{float, string, string}>
     */
    public static function priceFormattingCases(): array
    {
        return [
            'euro_positive'         => [1234.56, 'EUR', '1.234,56 €'],
            'euro_zero'             => [0.0,     'EUR', '0,00 €'],
            'euro_cents_only'       => [0.99,    'EUR', '0,99 €'],
            'dollar_positive'       => [1234.56, 'USD', '1.234,56 $'],
            'large_amount'          => [999999.0,'EUR', '999.999,00 €'],
            'negative_amount'       => [-50.0,   'EUR', '-50,00 €'],
        ];
    }

    /**
     * @test
     * Generator-based provider — lazy loading for large datasets.
     */
    #[DataProvider('invalidAmountCases')]
    public function throws_on_invalid_amount(float $amount, string $message): void
    {
        $this->expectException(\InvalidArgumentException::class);
        $this->expectExceptionMessage($message);
        $this->formatter->format($amount, 'EUR');
    }

    /**
     * Uses yield for lazy dataset generation.
     *
     * @return \Generator<string, array{float, string}>
     */
    public static function invalidAmountCases(): \Generator
    {
        yield 'not_a_number_via_INF' => [INF,  'Amount must be finite'];
        yield 'negative_infinity'    => [-INF, 'Amount must be finite'];
        yield 'not_a_number_NaN'     => [NAN,  'Amount must be a valid number'];
    }
}

3. Benennung: Der wichtigste Aspekt eines DataProviders

Die Benennung der DataProvider-Datensätze ist der wichtigste Qualitätsfaktor. Ohne benannte Schlüssel zeigt PHPUnit numerische Indizes: "formats_price_correctly with data set #0". Dieser Name sagt bei einem Fehlschlag nichts darüber aus, welcher Fall betroffen ist. Mit benannten Schlüsseln zeigt PHPUnit: "formats_price_correctly with data set 'euro_zero'". Das ist im Fehlerfall sofort verständlich, ohne den DataProvider nachzulesen.

Gute Datensatz-Namen beschreiben den Geschäftsfall, nicht den technischen Wert. Statt 'case_1' oder 'test_null' lieber 'null_customer_returns_guest_price' oder 'empty_cart_throws_exception'. Der Name erscheint im Test-Report und in Fehlermeldungen – er ist die einzige Dokumentation, die bei einem CI-Fehlschlag sofort sichtbar ist. Zu lange Namen sind besser als zu kurze. Ein Name wie 'premium_user_with_expired_subscription_gets_standard_discount' mag lang sein, ist aber sofort verständlich.


<?php

declare(strict_types=1);

namespace Tests\Unit;

use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use App\Domain\DiscountCalculator;

/**
 * Shows the difference between poorly and well-named DataProvider entries.
 */
final class DiscountCalculatorTest extends TestCase
{
    // WRONG: numeric keys — meaningless output on failure
    public static function badProvider(): array
    {
        return [
            [100, 'gold', 0.05],     // what case is this?
            [600, 'premium', 0.20],  // and this?
            [200, 'premium', 0.10],  // no idea from the name
        ];
    }

    // RIGHT: descriptive keys — immediately clear on failure
    public static function discountCalculationCases(): array
    {
        return [
            'gold_tier_flat_5_percent'          => [100, 'gold',    0.05],
            'premium_tier_above_500_gets_20pct' => [600, 'premium', 0.20],
            'premium_tier_below_500_gets_10pct' => [200, 'premium', 0.10],
            'standard_tier_no_discount'         => [100, 'bronze',  0.0],
            'premium_exactly_500_gets_20pct'    => [500, 'premium', 0.20],
        ];
    }

    /**
     * @test
     */
    #[DataProvider('discountCalculationCases')]
    public function calculates_correct_discount(
        float $amount,
        string $tier,
        float $expectedDiscount
    ): void {
        $calc = new DiscountCalculator();

        $this->assertSame($expectedDiscount, $calc->calculate($amount, $tier));
    }

    // Edge cases that need different assertion logic — separate tests, not DataProvider
    /** @test */
    public function throws_on_zero_amount(): void
    {
        $this->expectException(\InvalidArgumentException::class);
        $this->expectExceptionMessage('Amount must be positive');
        (new DiscountCalculator())->calculate(0, 'gold');
    }

    /** @test */
    public function throws_on_negative_amount(): void
    {
        $this->expectException(\InvalidArgumentException::class);
        (new DiscountCalculator())->calculate(-1, 'premium');
    }
}

4. Grenzen: Wann DataProvider schaden statt helfen

DataProvider schaden, wenn die Tests, die sie parametrisieren, inhaltlich verschieden sind und nur zufällig dieselbe Test-Methode aufrufen. Ein DataProvider, der zwölf verschiedene Szenarien mit unterschiedlichem Setup kombiniert, erzeugt eine Methode, die zu viel kann. Das Symptom: Wenn ein Datensatz fehlschlägt, muss man den DataProvider und die Test-Methode gemeinsam analysieren, um zu verstehen, was schiefgelaufen ist. Separate, gut benannte Tests wären klarer gewesen.

Ein zweites Grenzproblem: DataProvider werden ausgeführt, bevor das Test-Objekt instanziiert wird. Das bedeutet, dass im DataProvider kein Zugriff auf Instanzvariablen, keinen Zugriff auf den Container und keine Nutzung von $this möglich ist. Mock-Objekte können nicht direkt im DataProvider erstellt werden, weil PHPUnit zu diesem Zeitpunkt noch kein TestCase-Objekt hat. Wenn ein Test Mock-Objekte als Parameter braucht, ist ein DataProvider die falsche Lösung – besser ist eine Hilfsmethode oder separate Tests.

Das dritte Grenzproblem: DataProvider machen Fehlersuche in CI schwieriger, wenn die Datensatznamen nicht aussagekräftig sind. Ein CI-Log wie "25 tests, 1 failure: calculates_discount with data set #7" ist fast wertlos. Der Datensatz-Name muss so spezifisch sein, dass der Entwickler sofort weiß, was er reproduzieren muss.

5. Externe DataProvider und Wiederverwendung

DataProvider-Methoden müssen sich nicht in derselben Klasse befinden wie die Test-Methode. Mit der Attribut-Syntax #[DataProvider('ClassName::methodName')] kann auf DataProvider in anderen Klassen verwiesen werden. Das ermöglicht gemeinsame Datensätze für mehrere Test-Klassen – besonders nützlich, wenn eine Klasse aus verschiedenen Perspektiven getestet wird (z.B. Service und Repository beide mit denselben Eingabewerten).

Eine sinnvolle Praxis: Gemeinsam genutzte Testdaten in einer eigenen Datenprovider-Klasse in tests/Support/DataProviders/ sammeln. Diese Klassen enthalten nur statische Methoden, die Datensätze zurückgeben, und können über alle Test-Klassen hinweg referenziert werden. Das verhindert Duplizierung von Testdaten und macht es einfacher, neue Randfälle hinzuzufügen – einmal hinzugefügt, wird der neue Datensatz automatisch in allen referenzierenden Tests ausgeführt.


<?php

declare(strict_types=1);

namespace Tests\Support\DataProviders;

/**
 * Shared data providers for email validation tests.
 * Referenced by multiple test classes covering different layers.
 */
final class EmailDataProvider
{
    /**
     * Valid email addresses that should pass validation in all contexts.
     *
     * @return array<string, array{string}>
     */
    public static function validEmails(): array
    {
        return [
            'simple_format'           => ['user@example.com'],
            'with_subdomain'          => ['user@mail.example.com'],
            'with_plus_addressing'    => ['user+tag@example.com'],
            'with_numeric_local'      => ['123@example.com'],
            'with_hyphen_in_domain'   => ['user@my-domain.com'],
        ];
    }

    /**
     * Invalid email addresses that must be rejected.
     *
     * @return array<string, array{string}>
     */
    public static function invalidEmails(): array
    {
        return [
            'missing_at_sign'         => ['userexample.com'],
            'missing_domain'          => ['user@'],
            'missing_local'           => ['@example.com'],
            'double_at_sign'          => ['user@@example.com'],
            'empty_string'            => [''],
            'whitespace_only'         => ['   '],
            'local_with_spaces'       => ['us er@example.com'],
        ];
    }
}

// Usage in multiple test classes:
use Tests\Support\DataProviders\EmailDataProvider;
use PHPUnit\Framework\Attributes\DataProvider;

final class EmailValidatorTest extends TestCase
{
    #[DataProvider('Tests\Support\DataProviders\EmailDataProvider::validEmails')]
    public function valid_emails_pass_validation(string $email): void
    {
        $validator = new EmailValidator();
        $this->assertTrue($validator->isValid($email), "Expected {$email} to be valid");
    }

    #[DataProvider('Tests\Support\DataProviders\EmailDataProvider::invalidEmails')]
    public function invalid_emails_fail_validation(string $email): void
    {
        $validator = new EmailValidator();
        $this->assertFalse($validator->isValid($email), "Expected {$email} to be invalid");
    }
}

final class UserRegistrationServiceTest extends TestCase
{
    // Same data providers used from a service-layer perspective
    #[DataProvider('Tests\Support\DataProviders\EmailDataProvider::invalidEmails')]
    public function registration_fails_for_invalid_email(string $email): void
    {
        $this->expectException(\InvalidArgumentException::class);
        $this->service->register($email, 'Password123!');
    }
}

6. DataProvider für Exception-Tests

DataProvider für Exception-Tests sind ein Spezialfall: Die Test-Methode muss sowohl die erwartete Exception als auch die Eingabewerte enthalten. Der Ansatz: Der DataProvider liefert die Eingabewerte und die erwartete Exception-Klasse oder -Nachricht als Parameter. Die Test-Methode ruft $this->expectException() mit dem Parameter auf und danach den Produktionscode. So können viele Fehlerfälle ohne Duplizierung parametrisiert werden.

Wichtig: Wenn die erwartete Exception-Klasse variiert, muss $this->expectException($exceptionClass) dynamisch aufgerufen werden. Das ist ein seltener aber valider Anwendungsfall. Häufiger ist, dass alle Fälle im DataProvider dieselbe Exception-Klasse werfen, aber unterschiedliche Nachrichten haben – dann wird nur die Nachricht parametrisiert. Wenn sowohl Exception-Klasse als auch Nachricht für alle Fälle identisch sind, ist ein DataProvider weniger sinnvoll als ein einzelner Test mit @dataProvider auf die Eingabewerte.

7. Typische Fehler bei DataProvider-Einsatz

Der häufigste Fehler: Datensätze ohne sprechende Schlüssel. Numerische Indizes im Test-Report ("data set #3") sind bei Fehlschlägen nicht aussagekräftig. Immer Strings als Schlüssel verwenden, die den Testfall beschreiben. Der zweite häufige Fehler: DataProvider für Tests verwenden, die eigentlich verschieden sind. Wenn zwei Datensätze unterschiedliche Assertions brauchen, gehören sie in separate Test-Methoden.

Ein dritter Fehler: DataProvider zu groß machen. Ein DataProvider mit zwanzig Einträgen für eine einfache Funktion ist oft ein Zeichen, dass die Funktion zu viele Varianten hat – oder dass der DataProvider zu weit ausgedehnt wurde. Zehn gut benannte Datensätze sind besser als zwanzig, bei denen die letzten zehn redundant sind. Ein vierter Fehler: Dependency auf setUp()-Daten im DataProvider. DataProvider werden statisch und vor dem Test-Object-Setup ausgeführt. Keine Instanzvariablen, kein $this in DataProviders.

Situation DataProvider? Begründung Alternative
Gleiche Logik, viele Inputs Ja Reduziert Duplizierung
Unterschiedliche Assertions Nein Tests inhaltlich verschieden Separate Test-Methoden
Validierungs-Tests Ja Typischer DataProvider-Anwendungsfall
Mock als Parameter nötig Nein Provider läuft vor TestCase-Init Hilfsmethode im Test
Gleiche Exception, viele Inputs Ja Fehlerpfade parametrisieren

8. DataProvider vs. separate Tests im Vergleich

Die Entscheidung zwischen DataProvider und separaten Tests hängt von der inhaltlichen Ähnlichkeit der Fälle ab. Wenn zwei Testfälle dieselbe Test-Methode mit anderen Werten aufrufen würden, gehören sie in einen DataProvider. Wenn sie unterschiedliches Setup, unterschiedliche Assertions oder unterschiedliche Mocks benötigen, sind separate Tests besser – auch wenn das zu scheinbarer Duplizierung führt.

Separate Tests haben einen entscheidenden Vorteil: ihren Namen. premium_user_above_threshold_receives_20_percent_discount als Methodenname ist eine vollständige Dokumentation des getesteten Verhaltens. Ein DataProvider-Eintrag mit demselben Schlüssel erreicht dasselbe im Report, aber der Test-Code ist aufgeteilt in Provider und Methode. Für einfache Fälle mit wenigen Parametern sind separate Tests oft klarer; für Validierungslogik mit zehn oder mehr Varianten sind DataProvider klar überlegen.

9. Zusammenfassung

PHPUnit DataProvider sinnvoll einsetzen bedeutet: Immer sprechende Schlüssel für Datensätze wählen. DataProvider nur für inhaltlich gleiche Tests mit variierenden Eingaben nutzen – nicht für Tests, die unterschiedliche Assertions oder unterschiedliches Setup brauchen. Mocks nicht in DataProviders erstellen – sie werden statisch vor dem TestCase-Setup ausgeführt. Generator-Syntax für große Datensätze bevorzugen. Externe DataProvider-Klassen für gemeinsam genutzte Testdaten.

Das Ziel ist immer dasselbe: Tests, die beim Fehlschlag sofort verständlich sind. Ein DataProvider mit schlechten Namen ist schlechter als zehn separate, gut benannte Tests. Ein DataProvider mit guten Namen und klarer Struktur ist besser als dreißig fast-identische Methoden.

PHPUnit DataProvider — Das Wichtigste auf einen Blick

Sprechende Schlüssel

Immer String-Schlüssel verwenden, die den Testfall beschreiben. Numerische Indizes im Report sind bei Fehlschlägen wertlos.

PHPUnit 10 Attribute

#[DataProvider('methodName')] statt @dataProvider. Typsicher, IDE-kompatibel. Externe Provider: #[DataProvider('ClassName::method')].

Wann DataProvider

Gleiche Test-Logik, viele Eingabewerte. Validierungslogik, Formatierung, Berechnungen. Nicht für inhaltlich verschiedene Tests.

Grenzen kennen

Kein $this, kein setUp() im DataProvider. Kein Mock als Parameter. Mocks in der Test-Methode oder per Hilfsmethode erstellen.

Mironsoft

PHP-Entwicklung, Test-Qualität und Clean Code

Test-Suites ohne Duplizierung und ohne Verwirrung?

Wir analysieren bestehende PHPUnit-Tests auf Duplizierung, schwache DataProvider-Nutzung und fehlende Parametrisierung – und refaktorieren zu lesbaren, wartbaren Tests mit sinnvollem DataProvider-Einsatz.

Test-Audit

Identifikation von duplizierten Tests und schwachen DataProvider-Nutzungen

Refactoring

Duplizierte Tests in DataProvider umwandeln, Benennung verbessern, Struktur klären

Schulung

DataProvider-Patterns, Benennung und Grenzen für das gesamte Entwicklerteam

10. FAQ: PHPUnit DataProvider sinnvoll nutzen

1Was ist ein PHPUnit DataProvider?
Statische Methode, die Eingabe-Erwartungs-Paare liefert. Test-Methode läuft für jeden Datensatz. Jeder erscheint als separater Testfall im Report.
2PHPUnit 10 Syntax?
#[DataProvider('methodName')] als PHP-8-Attribut. Methode muss public static sein. String-Schlüssel für benannte Datensätze.
3Warum benannte Datensätze wichtig?
Ohne Namen: "with data set #3" – wertlos. Mit Namen: "premium_above_threshold" – sofort klar welcher Fall fehlschlug.
4Wann keinen DataProvider?
Bei unterschiedlichen Assertions/Setup/Mocks. Wenn Tests inhaltlich verschieden sind. Wenn separate Tests klarer wären.
5Kann ich $this im DataProvider nutzen?
Nein. Provider läuft statisch vor TestCase-Instanziierung. Kein $this, kein setUp(), keine Mocks im Provider.
6Array vs. Generator DataProvider?
Array: sofort geladen, klarer für kleine Datensätze. Generator (yield): lazy, effizienter für große Mengen. Beide unterstützen benannte Schlüssel.
7Externe DataProvider referenzieren?
#[DataProvider('Namespace\\Class::method')]. DataProvider-Klassen in tests/Support/DataProviders/ sammeln für Wiederverwendung.
8Exception-Tests mit DataProvider?
Provider liefert Eingaben und erwartete Exception-Nachricht. Test-Methode ruft expectException() und expectExceptionMessage() mit Parametern auf.
9Maximale Größe eines DataProviders?
Keine harte Grenze. Ab ~15-20 Einträgen prüfen ob redundante Fälle dabei sind. Qualität vor Quantität.
10Erscheinen DataProvider-Tests als separate Tests in CI?
Ja. Jeder Datensatz = separater Testfall im Report. 1 Methode + 10 Datensätze = 10 Testfälle. Jeder kann einzeln fehlschlagen mit seinem Datensatznamen.