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

php
it('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:

php
it('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:

php
dataset('http_5xx_codes', [500, 502, 503, 504]);

Use in any test file:

php
it('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:

php
it('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

  1. 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 the beforeEach block, state can leak.
  2. 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

  1. Key your datasets with human-readable names — makes failure reports easy to scan.
  2. 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.
✓ Completed