Introduction
Datasets are Pest's answer to PHPUnit's @dataProvider — a way to run the same test logic against many inputs without writing a loop or copy-pasting the test. They're cleaner than the PHPUnit equivalent because the dataset lives right next to the test.
Key Concepts
- Dataset: A list of input tuples. Pest runs the test once per tuple.
- Named dataset: A dataset registered globally with a name, so multiple tests can share it.
- Higher-order tests: A fluent syntax that chains methods directly on
test()without a closure body.
Real World Context
A validation test that checks every combination of invalid input — empty title, title too long, missing body, invalid email, malformed date. Copy-pasting the same test 5 times is ugly; a dataset keeps it to one test definition with 5 rows.
Deep Dive
Inline datasets
phpit('rejects invalid post titles', function (string $title, string $expectedError) { $response = $this->actingAs($this->user) ->post('/posts', ['title' => $title, 'body' => 'ok']); $response->assertSessionHasErrors(['title' => $expectedError]); })->with([ 'empty' => ['', 'The title field is required.'], 'too long' => [str_repeat('a', 256), 'The title may not be greater than 255 characters.'], 'only whitespace' => [' ', 'The title field is required.'], ]);
Each key becomes part of the test name in output: it rejects invalid post titles with dataset "empty".
Simple datasets (no keys)
When you don't need names, just pass a flat array:
phpit('marks these statuses as final', function (string $status) { expect(Order::make(['status' => $status])->isFinal())->toBeTrue(); })->with(['paid', 'refunded', 'cancelled']);
Named datasets (reusable)
Register in tests/Pest.php:
phpdataset('http_5xx_codes', [500, 502, 503, 504]);
Use in any test file:
phpit('retries on server errors', function (int $status) { Http::fake(['*' => Http::response([], $status)]); expect(fn () => (new ApiClient)->fetch('/users')) ->toThrow(RetryableException::class); })->with('http_5xx_codes');
Higher-order tests
For super simple assertions, skip the closure entirely:
phpit('uses the right defaults on new Post models') ->expect(new Post()) ->status->toBe('draft') ->publishedAt->toBeNull();
The ->status and ->publishedAt are higher-order property access — Pest dives into the expectation and runs matchers against each property.
Common Pitfalls
- Mutating state between dataset rows — each row runs in its own fresh transaction (if you use
RefreshDatabase), but if you reuse a shared variable from thebeforeEachblock, state can leak. - Dataset rows with different argument counts — every row must have the same number of arguments as the test closure parameters, or Pest throws at boot.
Best Practices
- Key your datasets with human-readable names — makes failure reports easy to scan.
- Register shared datasets globally in
tests/Pest.php— so the same list of edge cases can be referenced by multiple tests.
Summary
->with([...])chains a dataset onto a test.- Keys become human-readable row names in output.
dataset('name', [...])registers reusable datasets globally.- Higher-order property syntax skips closures for simple cases.