instead of duplicated tests
Three tests that check the same thing with only different input values are not a sign of good test coverage. They are technical debt. DataProvider solves this problem elegantly: one test method, many datasets, each one a separate, named test case. Used correctly, DataProvider reduces duplication and increases readability at the same time.
Table of contents
- 1. What DataProvider is and when it helps
- 2. Basic structure and PHPUnit 10 attribute syntax
- 3. Naming: the most important aspect of a DataProvider
- 4. Limits: when DataProvider hurts instead of helping
- 5. External DataProvider and reuse
- 6. DataProvider for exception tests
- 7. Common mistakes when using DataProvider
- 8. DataProvider versus separate tests compared
- 9. Summary
- 10. FAQ
1. What DataProvider is and when it helps
A DataProvider is a static method that returns a set of input-expectation pairs. PHPUnit runs the annotated test method once for each of these datasets, each dataset is counted as a separate test case and appears in the test report. The result: one method tests many variants without the test code being duplicated.
DataProvider helps most with tests that check the same behavior for different input values. Typical candidates: validation logic (valid and invalid inputs), formatting functions (numbers, dates, currencies), calculations with several variables, state machine transitions. In short: if the test method is identical across different datasets and only the inputs vary, a DataProvider is the right solution.
DataProvider is not a universal solution. If tests need different setup logic, different assertions or different dependencies, they belong as separate test methods. A DataProvider that has ten lines of setup variation baked into every dataset is harder to read than ten separate tests with clear, self-contained names. The rule of thumb: DataProvider reduces code that is genuinely identical. It does not replace tests that differ in substance.
2. Basic structure and PHPUnit 10 attribute syntax
In PHPUnit 10 and 11, the preferred syntax for DataProvider is the PHP 8 attribute #[DataProvider('methodName')] instead of the annotation @dataProvider methodName. Both work, but attributes are type-safe, IDE-compatible and follow the PHP 8 paradigm. The DataProvider method must be public and static. It returns an array whose keys are the test case name and whose values are the parameters of the test method.
The array format changed slightly with PHPUnit 10: previously an arbitrarily nested array was allowed, today iterables are preferred. Yield-based DataProvider (a generator function with yield) is the most modern variant: it loads data lazily and is more efficient for large datasets. The generator's keys are the test case names, exactly as with array-based DataProvider. Both styles, array and generator, coexist without issue.
<?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. Naming: the most important aspect of a DataProvider
Naming the DataProvider datasets is the most important quality factor. Without named keys, PHPUnit shows numeric indices: "formats_price_correctly with data set #0". This name says nothing about which case is affected when a test fails. With named keys, PHPUnit shows: "formats_price_correctly with data set 'euro_zero'". That is immediately understandable on failure, without having to look up the DataProvider.
Good dataset names describe the business case, not the technical value. Instead of 'case_1' or 'test_null', prefer 'null_customer_returns_guest_price' or 'empty_cart_throws_exception'. The name appears in the test report and in failure messages, it is the only documentation that is immediately visible on a CI failure. Names that are too long are better than names that are too short. A name like 'premium_user_with_expired_subscription_gets_standard_discount' may be long, but it is instantly understandable.
<?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. Limits: when DataProvider hurts instead of helping
DataProvider hurts when the tests it parameterizes differ in substance and only happen to call the same test method. A DataProvider that combines twelve different scenarios with different setups creates a method that is trying to do too much. The symptom: when a dataset fails, you have to analyze the DataProvider and the test method together to understand what went wrong. Separate, well-named tests would have been clearer.
A second limitation: DataProvider is executed before the test object is instantiated. That means no access to instance variables, no access to the container, and no use of $this is possible inside a DataProvider. Mock objects cannot be created directly inside a DataProvider, because PHPUnit does not yet have a TestCase object at that point. If a test needs mock objects as parameters, a DataProvider is the wrong solution, a helper method or separate tests work better.
A third limitation: DataProvider makes debugging in CI harder when the dataset names are not meaningful. A CI log like "25 tests, 1 failure: calculates_discount with data set #7" is almost worthless. The dataset name must be specific enough that the developer immediately knows what to reproduce.
5. External DataProvider and reuse
DataProvider methods do not have to live in the same class as the test method. With the attribute syntax #[DataProvider('ClassName::methodName')], you can reference a DataProvider in another class. This enables shared datasets across multiple test classes, especially useful when a class is tested from different perspectives (for example a service and a repository both tested with the same input values).
A sensible practice: collect shared test data in a dedicated DataProvider class under tests/Support/DataProviders/. These classes contain only static methods that return datasets and can be referenced across all test classes. This prevents duplication of test data and makes it easier to add new edge cases, once added, the new dataset is automatically run in every referencing test.
<?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 for exception tests
DataProvider for exception tests is a special case: the test method has to include both the expected exception and the input values. The approach: the DataProvider supplies the input values and the expected exception class or message as parameters. The test method calls $this->expectException() with the parameter and then the production code. This way many failure cases can be parameterized without duplication.
Important: if the expected exception class varies, $this->expectException($exceptionClass) must be called dynamically. This is a rare but valid use case. More commonly, all cases in the DataProvider throw the same exception class but have different messages, in which case only the message is parameterized. If both the exception class and the message are identical for all cases, a DataProvider is less useful than a single test with @dataProvider on the input values.
7. Common mistakes when using DataProvider
The most common mistake: datasets without descriptive keys. Numeric indices in the test report ("data set #3") are not meaningful on failure. Always use string keys that describe the test case. The second common mistake: using a DataProvider for tests that are actually different. If two datasets need different assertions, they belong in separate test methods.
A third mistake: making DataProvider too large. A DataProvider with twenty entries for a simple function is often a sign that the function has too many variants, or that the DataProvider has been stretched too far. Ten well-named datasets are better than twenty where the last ten are redundant. A fourth mistake: depending on setUp() data inside the DataProvider. DataProvider runs statically and before the test object setup. No instance variables, no $this in DataProviders.
| Situation | DataProvider? | Reasoning | Alternative |
|---|---|---|---|
| Same logic, many inputs | Yes | Reduces duplication | - |
| Different assertions | No | Tests differ in substance | Separate test methods |
| Validation tests | Yes | Typical DataProvider use case | - |
| Mock needed as parameter | No | Provider runs before TestCase init | Helper method in the test |
| Same exception, many inputs | Yes | Parameterize failure paths | - |
8. DataProvider versus separate tests compared
The decision between DataProvider and separate tests depends on how similar the cases are in substance. If two test cases would call the same test method with different values, they belong in a DataProvider. If they need different setup, different assertions or different mocks, separate tests are better, even if that leads to apparent duplication.
Separate tests have one decisive advantage: their name. premium_user_above_threshold_receives_20_percent_discount as a method name is complete documentation of the tested behavior. A DataProvider entry with the same key achieves the same thing in the report, but the test code is split across the provider and the method. For simple cases with few parameters, separate tests are often clearer; for validation logic with ten or more variants, DataProvider is clearly superior.
9. Summary
Using PHPUnit DataProvider well means: always choose descriptive keys for datasets. Use DataProvider only for tests that are identical in substance with varying inputs, not for tests that need different assertions or different setup. Do not create mocks inside DataProviders, they run statically before the TestCase setup. Prefer generator syntax for large datasets. Use external DataProvider classes for shared test data.
The goal is always the same: tests that are immediately understandable on failure. A DataProvider with bad names is worse than ten separate, well-named tests. A DataProvider with good names and a clear structure is better than thirty near-identical methods.
PHPUnit DataProvider: the essentials at a glance
Descriptive keys
Always use string keys that describe the test case. Numeric indices in the report are worthless on failure.
PHPUnit 10 attributes
#[DataProvider('methodName')] instead of @dataProvider. Type-safe, IDE-compatible. External providers: #[DataProvider('ClassName::method')].
When to use DataProvider
Same test logic, many input values. Validation logic, formatting, calculations. Not for tests that differ in substance.
Know the limits
No $this, no setUp() inside the DataProvider. No mock as a parameter. Create mocks in the test method or via a helper method.
Mironsoft
PHP development, test quality and clean code
Test suites without duplication and without confusion?
We analyze existing PHPUnit tests for duplication, weak DataProvider usage and missing parameterization, and refactor them into readable, maintainable tests with sensible DataProvider usage.
Test audit
Identification of duplicated tests and weak DataProvider usage
Refactoring
Turning duplicated tests into DataProvider, improving naming, clarifying structure
Training
DataProvider patterns, naming and limits for the entire development team