Skip to content

Commit d95a8b6

Browse files
committed
Add a new article "Data Providers"
1 parent aea2845 commit d95a8b6

File tree

10 files changed

+522
-9
lines changed

10 files changed

+522
-9
lines changed

.vitepress/theme/BlogSponsor.vue

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,31 +13,31 @@ const isBlog = () => route.path.includes('/blog/')
1313
<div class="sponsor-content">
1414
<p>
1515
<template v-if="lang === 'ru'">
16-
Хотите поддержать проект? <a href="/ru/sponsor">Станьте спонсором</a>.
16+
Хотите поддержать проект? <a href="/ru/sponsor">Станьте спонсором</a> или <a href="https://github.com/php-testo/testo" target="_blank">поставьте звёздочку</a>.
1717
</template>
1818
<template v-else-if="lang === 'es'">
19-
¿Quieres apoyar el proyecto? <a href="/es/sponsor">Conviértete en patrocinador</a>.
19+
¿Quieres apoyar el proyecto? <a href="/es/sponsor">Conviértete en patrocinador</a> o <a href="https://github.com/php-testo/testo" target="_blank">danos una estrella</a>.
2020
</template>
2121
<template v-else-if="lang === 'zh' || lang === 'zh-CN'">
22-
想支持这个项目吗?<a href="/zh/sponsor">成为赞助商</a>。
22+
想支持这个项目吗?<a href="/zh/sponsor">成为赞助商</a>或<a href="https://github.com/php-testo/testo" target="_blank">给我们一个星标</a>
2323
</template>
2424
<template v-else-if="lang === 'de'">
25-
Möchten Sie das Projekt unterstützen? <a href="/de/sponsor">Werden Sie Sponsor</a>.
25+
Möchten Sie das Projekt unterstützen? <a href="/de/sponsor">Werden Sie Sponsor</a> oder <a href="https://github.com/php-testo/testo" target="_blank">geben Sie uns einen Stern</a>.
2626
</template>
2727
<template v-else-if="lang === 'fr'">
28-
Vous voulez soutenir le projet ? <a href="/fr/sponsor">Devenez sponsor</a>.
28+
Vous voulez soutenir le projet ? <a href="/fr/sponsor">Devenez sponsor</a> ou <a href="https://github.com/php-testo/testo" target="_blank">donnez-nous une étoile</a>.
2929
</template>
3030
<template v-else-if="lang === 'pt'">
31-
Quer apoiar o projeto? <a href="/pt/sponsor">Torne-se um patrocinador</a>.
31+
Quer apoiar o projeto? <a href="/pt/sponsor">Torne-se um patrocinador</a> ou <a href="https://github.com/php-testo/testo" target="_blank">dê-nos uma estrela</a>.
3232
</template>
3333
<template v-else-if="lang === 'ja'">
34-
プロジェクトを支援しませんか?<a href="/ja/sponsor">スポンサーになる</a>。
34+
プロジェクトを支援しませんか?<a href="/ja/sponsor">スポンサーになる</a>か<a href="https://github.com/php-testo/testo" target="_blank">スターを付けてください</a>
3535
</template>
3636
<template v-else-if="lang === 'ko'">
37-
프로젝트를 지원하시겠습니까? <a href="/ko/sponsor">스폰서가 되세요</a>.
37+
프로젝트를 지원하시겠습니까? <a href="/ko/sponsor">스폰서가 되세요</a> 또는 <a href="https://github.com/php-testo/testo" target="_blank">별을 눌러주세요</a>.
3838
</template>
3939
<template v-else>
40-
Want to support the project? <a href="/sponsor">Become a sponsor</a>.
40+
Want to support the project? <a href="/sponsor">Become a sponsor</a> or <a href="https://github.com/php-testo/testo" target="_blank">give us a star</a>.
4141
</template>
4242
</p>
4343
</div>

.vitepress/theme/style.css

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,11 @@ html {
124124
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.15);
125125
}
126126

127+
/* Prevent inline code from wrapping */
128+
.vp-doc :not(pre) > code {
129+
white-space: nowrap;
130+
}
131+
127132
/* Custom info/tip boxes */
128133
.vp-doc .custom-block {
129134
border-radius: 12px !important;

CLAUDE.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,13 @@ author: Author Name
7272
- `image` — used for preview in blog list, og:image for social sharing, and displayed in post header
7373
- `author` — displayed in blog list and post header
7474

75+
**Optional frontmatter:**
76+
- `outline` — controls heading levels shown in the right sidebar (table of contents):
77+
- `outline: [2, 3]` — show h2 and h3 (default behavior)
78+
- `outline: 'deep'` — show all levels (h2-h6)
79+
- `outline: false` — hide outline completely
80+
- `outline: 2` — show only h2
81+
7582
## VitePress Commands
7683

7784
```bash

blog/data-providers.md

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
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+
![Telegram](/blog/data-providers/telegram.png)
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+
![DataZip](/blog/data-providers/zip.png)
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+
![DataCross](/blog/data-providers/cross.png)
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+
![DataUnion](/blog/data-providers/union.png)
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+
:::
645 KB
Loading
1.1 MB
Loading
61.5 KB
Loading
620 KB
Loading

public/blog/data-providers/zip.png

660 KB
Loading

0 commit comments

Comments
 (0)