|
| 1 | +--- |
| 2 | +title: "Data Providers" |
| 3 | +date: 2026-02-01 |
| 4 | +description: "Flexible data providers in Testo: from simple DataSet to powerful combinations with DataZip, DataCross, and DataUnion." |
| 5 | +image: /blog/data-providers/preview.jpg |
| 6 | +author: Aleksei Gagarin |
| 7 | +outline: deep |
| 8 | +--- |
| 9 | + |
| 10 | +# Data Providers |
| 11 | + |
| 12 | +In unit tests, we're used to data providers — their job is to supply argument sets (datasets) for test functions. |
| 13 | + |
| 14 | +## PHPUnit Example |
| 15 | + |
| 16 | +In PHPUnit, data providers are declared with attributes. For example, the `#[DataProvider]` attribute takes the name of a public static method in the current class, from which datasets will be extracted. |
| 17 | + |
| 18 | +```php |
| 19 | +#[DataProvider('dataSum')] |
| 20 | +public function testSum(int $a, int $b, int $c): void |
| 21 | +{ |
| 22 | + $result = Helper::sum($a, $b); |
| 23 | + |
| 24 | + self::assertSame($c, $result); |
| 25 | +} |
| 26 | + |
| 27 | +public static function dataSum(): iterable |
| 28 | +{ |
| 29 | + yield [1, 1, 2]; |
| 30 | + # datasets can be named: |
| 31 | + yield 'second dataset' => [1, 2, 3]; |
| 32 | +} |
| 33 | +``` |
| 34 | + |
| 35 | +The data provider function can be in another class — PHPUnit simply uses a different attribute: `#[DataProviderExternal(External::class, 'dataMethod')]`. |
| 36 | + |
| 37 | +If full data provider functionality is overkill, you can send datasets one at a time via the `#[TestWith]` attribute: |
| 38 | + |
| 39 | +```php |
| 40 | +#[TestWith([1, 1, 2])] |
| 41 | +#[TestWith([1, 2, 3], 'second dataset')] |
| 42 | +public function testSum(int $a, int $b, int $c): void { ... } |
| 43 | +``` |
| 44 | + |
| 45 | +That's about all there is to say about data providers in PHPUnit ¯\\\_(ツ)\_/¯ |
| 46 | + |
| 47 | +## What's in Testo? |
| 48 | + |
| 49 | +Well, this article wouldn't exist if there was nothing to say. |
| 50 | + |
| 51 | +**First**, I didn't like the `#[TestWith]` attribute name in PHPUnit. It conveys the intent well (*test with "this"*), but what about consistency? I wouldn't have known about this attribute if not by chance (do you know about it?). |
| 52 | + |
| 53 | +::: tip ☝️ It would be better if this attribute appeared in IDE suggestions when typing "Data": next to `DataProvider`. |
| 54 | +::: |
| 55 | + |
| 56 | +That's why in Testo this attribute is named: `#[DataSet]`. |
| 57 | + |
| 58 | +**Second**, Testo has no separate `#[DataProviderExternal]` attribute: the need for it simply disappears, since you can pass any `callable` to `#[DataProvider]`. |
| 59 | + |
| 60 | +**And third**, datasets in Testo can merge not only vertically, but also horizontally and diagonally. |
| 61 | + |
| 62 | +Let's go through everything in order. |
| 63 | + |
| 64 | +### DataSet |
| 65 | + |
| 66 | +Simply a standalone dataset for a test: |
| 67 | + |
| 68 | +```php |
| 69 | +#[DataSet([1, 1, 2])] |
| 70 | +#[DataSet([1, 2, 3], 'second dataset')] |
| 71 | +public function testSum(int $a, int $b, int $c): void { ... } |
| 72 | +``` |
| 73 | + |
| 74 | +The second argument is a label that appears in reports. Useful when a test fails and you want to immediately see which scenario broke. |
| 75 | + |
| 76 | +### DataProvider |
| 77 | + |
| 78 | +Like PHPUnit, Testo expects data providers to return dataset collections: `iterable<array>`. The attribute accepts `non-empty-string|callable` as a pointer to the provider. |
| 79 | + |
| 80 | +1. If a string is provided, Testo first looks for a method in the same class. If not found, it checks if it's a `callable` (a function or `callable-string` like `Class::method`). |
| 81 | + |
| 82 | + ```php |
| 83 | + // Method in current class |
| 84 | + #[DataProvider('dataSum')] |
| 85 | + |
| 86 | + // callable-string |
| 87 | + #[DataProvider('AnyClass::method')] |
| 88 | + ``` |
| 89 | + |
| 90 | +2. Another `callable` example is `callable-array`: |
| 91 | + |
| 92 | + ```php |
| 93 | + // Method from another class |
| 94 | + #[DataProvider([AnyClass::class, 'method'])] |
| 95 | + ``` |
| 96 | + |
| 97 | +3. Don't forget about invokable classes with `__invoke()` method: |
| 98 | + |
| 99 | + ```php |
| 100 | + // Class with __invoke() method |
| 101 | + #[DataProvider(new FileReader('sum-args.txt'))] |
| 102 | + ``` |
| 103 | + |
| 104 | + Valentin Udaltsov reached out with a request for this feature, not knowing it was already implemented. |
| 105 | + |
| 106 | +  |
| 107 | + _Thanks for the use case._ |
| 108 | + |
| 109 | +4. And the best part: closures directly in attributes. I don't have ideas why someone would need this, but if PHP 8.5 allows it, why not? |
| 110 | + |
| 111 | + ```php |
| 112 | + // Just a closure (PHP 8.5) |
| 113 | + #[DataProvider(static function(): iterable { |
| 114 | + yield from Source::fromFile(); |
| 115 | + yield from Source::fromCache(\getenv('CACHE_KEY')); |
| 116 | + })] |
| 117 | + // Or |
| 118 | + #[DataProvider(SomeClass::method(...))] |
| 119 | + ``` |
| 120 | + |
| 121 | +### Combining Providers |
| 122 | + |
| 123 | +You probably guessed that stacking multiple Data-attributes (`#[DataSet]` and `#[DataProvider]`) on a function will grow the dataset collection, similar to a UNION query in SQL. |
| 124 | + |
| 125 | +```php |
| 126 | +#[DataSet([1, 1, 2])] |
| 127 | +#[DataProvider('dataSum')] |
| 128 | +#[DataProvider(SomeClass::method(...))] |
| 129 | +public function testSum(int $a, int $b, int $c): void { ... } |
| 130 | +``` |
| 131 | + |
| 132 | +The test runs for all datasets sequentially: first `[1, 1, 2]` from `DataSet`, then all from `dataSum`, then all from `SomeClass::method()`. |
| 133 | + |
| 134 | +::: info 🤔 But what if you want to combine datasets in more interesting ways? |
| 135 | +::: |
| 136 | + |
| 137 | +### DataZip |
| 138 | + |
| 139 | + |
| 140 | + |
| 141 | +Pairs providers element by element: first with first, second with second. |
| 142 | + |
| 143 | +Useful when a provider is already used in other tests: |
| 144 | + |
| 145 | +```php |
| 146 | +// Provider 'users' is already used in testLogin, testLogout, testProfile... |
| 147 | +#[DataProvider('users')] |
| 148 | +public function testLogin(string $user): void { ... } |
| 149 | + |
| 150 | +// Here we want to add expected permissions for each user |
| 151 | +#[DataZip( |
| 152 | + new DataProvider('users'), // admin, guest, bot |
| 153 | + new DataProvider('canDelete'), // true, false, false |
| 154 | +)] |
| 155 | +public function testDeletePermission(string $user, bool $expected): void { ... } |
| 156 | +``` |
| 157 | + |
| 158 | +Result: `admin` → `true`, `guest` → `false`, `bot` → `false`. |
| 159 | + |
| 160 | +### DataCross |
| 161 | + |
| 162 | + |
| 163 | + |
| 164 | +Cartesian product — all possible combinations. 3 browsers × 3 screen sizes = 9 tests. |
| 165 | + |
| 166 | +```php |
| 167 | +#[DataCross( |
| 168 | + new DataProvider('browsers'), // chrome, firefox, safari |
| 169 | + new DataProvider('screenSizes'), // desktop, tablet, mobile |
| 170 | +)] |
| 171 | +public function testResponsiveLayout(string $browser, int $width, int $height): void { ... } |
| 172 | +``` |
| 173 | + |
| 174 | +Special thanks to [phpunit-data-provider](https://github.com/t-regx/phpunit-data-provider) authors for inspiration. |
| 175 | + |
| 176 | +### DataUnion |
| 177 | + |
| 178 | + |
| 179 | + |
| 180 | +The `#[DataUnion]` attribute merges multiple providers into one — simply concatenates datasets into a single collection, just like stacking multiple `#[DataProvider]` attributes on a test. |
| 181 | + |
| 182 | +::: info 🫤 Wait, why a separate attribute? |
| 183 | +::: |
| 184 | + |
| 185 | +`DataUnion` is needed **inside** `DataCross` or `DataZip`: |
| 186 | + |
| 187 | +```php |
| 188 | +#[DataCross( |
| 189 | + new DataUnion( |
| 190 | + new DataProvider('legacyFormats'), |
| 191 | + new DataProvider('modernFormats'), |
| 192 | + ), |
| 193 | + new DataProvider('compressionLevels'), |
| 194 | +)] |
| 195 | +public function testExport(string $format, int $compression): void { ... } |
| 196 | +``` |
| 197 | + |
| 198 | +All formats (legacy + modern) are crossed with each compression level. |
| 199 | + |
| 200 | +### Nesting |
| 201 | + |
| 202 | +Providers can be nested to any depth: |
| 203 | + |
| 204 | +```php |
| 205 | +#[DataCross( |
| 206 | + new DataZip( |
| 207 | + new DataCross( |
| 208 | + new DataProvider('users'), // alice, bob |
| 209 | + new DataProvider('roles'), // admin, viewer |
| 210 | + ), |
| 211 | + new DataProvider('canEdit'), // true, false, true, false |
| 212 | + ), |
| 213 | + new DataUnion( |
| 214 | + new DataSet([new Document('readme.md')], 'readme'), |
| 215 | + new DataProvider('documents'), // doc1, doc2 |
| 216 | + ), |
| 217 | +)] |
| 218 | +public function testDocumentAccess( |
| 219 | + string $user, |
| 220 | + string $role, |
| 221 | + bool $canEdit, |
| 222 | + Document $doc |
| 223 | +): void { ... } |
| 224 | +``` |
| 225 | + |
| 226 | +Here `users × roles` produces 4 combinations, which are zipped with 4 expected results, and all of that is crossed with 3 documents (1 from DataSet + 2 from DataProvider) = 12 tests. |
| 227 | + |
| 228 | +## Can PHPUnit do this? |
| 229 | + |
| 230 | +Not out of the box. But there's [t-regx/phpunit-data-provider](https://github.com/t-regx/phpunit-data-provider) package that adds `cross()`, `zip()`, `join()` and other methods. |
| 231 | + |
| 232 | +Here's what similar code looks like: |
| 233 | + |
| 234 | +```php |
| 235 | +#[DataProvider('usersWithPermissions')] |
| 236 | +public function testDeletePermission(string $user, bool $expected): void { ... } |
| 237 | + |
| 238 | +public function usersWithPermissions(): DataProvider |
| 239 | +{ |
| 240 | + return DataProvider::zip( |
| 241 | + DataProvider::list('admin', 'guest', 'bot'), |
| 242 | + DataProvider::list(true, false, false) |
| 243 | + ); |
| 244 | +} |
| 245 | +``` |
| 246 | + |
| 247 | +It works, but requires an intermediate wrapper method. In Testo, composition happens declaratively — right in the attributes above the test. |
| 248 | + |
| 249 | +::: tip Write less, test more |
| 250 | +::: |
0 commit comments