skip to content

In Pest, how do ->with() and dataset() feed a test, and how does a bound dataset differ from a PHPUnit data provider?

level: middleimportance: should knowfreq 45%

answer

  1. one test run per row
  2. string keys become dataset "name"
  3. several with() calls form a cartesian product
  4. shared datasets in tests/Datasets
  5. closure rows resolve after beforeEach()

basics

~20 s

->with([...]) runs the test once per row, passing the row's values as arguments; dataset('name', [...]) defines a reusable set referenced by name. A bound dataset row is a closure Pest calls after beforeEach(), unlike a static PHPUnit provider that runs before setUp().

solid answer

~50 s

`it('prices items', function (string $sku, int $qty, int $total) {...})->with([...])` runs once per row; a row may be a scalar or an array of arguments, and associative rows map to parameter names. A string key labels the run (`with dataset "bulk order"`); otherwise Pest prints the exported values. `dataset('skus', [...])` in `tests/Datasets` (shared) or a folder's `Datasets.php` (scoped) is referenced as `->with('skus')`. Chaining two `->with()` calls combines them as a cartesian product. `->with()` also works on `describe()`. The key difference from PHPUnit: a static data provider runs while the suite is built, before `setUp()`; in Pest, a **row that is a closure** is a bound dataset, called just before the test with `$this` bound, after `beforeEach()` - so it can use state or a database prepared there. In rows with several values, type the parameter that receives the closure, or Pest passes the closure through unresolved.

code

php · 14 lines
php
<?php
declare(strict_types=1);

use App\Shop\Cart;

it('totals a line', function (string $sku, int $qty, int $expected) {
    $cart = new Cart();
    $cart->add($sku, $qty);

    expect($cart->total())->toBe($expected);
})->with([
    'single item' => ['SKU-1', 1, 999],
    'bulk order' => ['SKU-1', 10, 8990],
]);

go deeper

for a junior

Recall that ->with([...]) runs a test once per row and that dataset('name', ...) defines a reusable set used as ->with('name').

for a middle

Explain row shapes, labels from string keys, shared and scoped datasets, and the cartesian product of chained with() calls.

for a senior

Show when a bound dataset is needed because the data depends on beforeEach() setup, and the typing rule that makes it resolve.

for a principal

Decide where shared case tables live and how large combined datasets may grow before they cost more run time than they return.

## Inline datasets with `->with()` A **dataset** runs the same test several times with different arguments. In Pest you attach it by chaining `->with()` onto the test: ```php it('rejects invalid quantities', function (int $qty) { expect(fn () => (new Cart())->add('SKU-1', $qty)) ->toThrow(InvalidArgumentException::class); })->with([0, -1, -100]); ``` Each row becomes one run. Rows can be: - a **scalar**, passed as the single argument (Pest wraps it in an array); - an **array** of positional arguments, `['SKU-1', 2, 1998]`; - an **associative array**, whose keys are matched to the closure's parameter names in any order. ## Naming runs Without keys, Pest labels each run with an export of its values, for example `with (0)`. With a string key, the run is labelled `with dataset "bulk order"`: ```php ->with([ 'single item' => ['SKU-1', 1, 999], 'bulk order' => ['SKU-1', 10, 8990], ]); ``` If the description contains `:dataset`, the label is interpolated into the name instead. Rows that would produce identical labels get `#1`, `#2` suffixes. ## Reusable datasets with `dataset()` ```php // tests/Datasets/Skus.php dataset('skus', ['SKU-1', 'SKU-2', 'SKU-3']); // any test it('knows the sku', function (string $sku) { /* ... */ })->with('skus'); ``` A file in `tests/Datasets` is **shared** across the suite; a `Datasets.php` inside a folder is **scoped** to that folder, and the closest scope wins. Defining the same name twice in one scope is an error, and referencing an unknown name fails with a dataset-does-not-exist error. ## Combining Chaining two `->with()` calls produces the **cartesian product**: `->with(['SKU-1', 'SKU-2'])->with([1, 5])` yields four runs, labelled with both parts joined by ` / `. A dataset attached to `describe(...)->with([...])` feeds every test inside that block. ## Datasets built by code `->with(fn () => range(1, 20))` or a generator closure computes the rows. That closure is called when Pest resolves the dataset while building the suite - before any `beforeEach()` - so it behaves like a PHPUnit data provider: it can compute values, but it cannot see per-test state. ## Bound datasets: the real difference from PHPUnit A PHPUnit data provider is a public static method that PHPUnit calls while building the suite, before `setUp()`. It therefore cannot use anything `setUp()` prepares, such as a database schema or a configured service. Pest adds **bound datasets**: when a *row* is a closure, Pest passes it through untouched and, just before running the test, calls it with `$this` bound to the test case - **after** `beforeEach()` has run: ```php beforeEach(function () { $this->catalog = new Catalog(['SKU-1' => 999]); }); it('prices a line', function (CartLine $line) { expect($line->total())->toBe(1998); })->with([ fn () => new CartLine($this->catalog->find('SKU-1'), 2), ]); ``` Two rules come with it: 1. **Type the parameter in multi-value rows.** When a row carries several values, Pest resolves a closure only if the matching parameter is typed with something other than `Closure`, `callable` or `mixed`; an untyped parameter counts as `mixed` and would receive the closure itself. A row that is a single closure is resolved unless the parameter is typed `Closure` or `callable`. 2. **Lazy by design.** The closure runs once per run, inside that test, so each run gets fresh objects. ## Choosing a form - **Inline `->with([...])`** for a handful of cases that belong to one test and read best next to it. - **`dataset()` in `tests/Datasets`** when several tests or files use the same table, such as a list of valid SKUs. - **A scoped `Datasets.php`** when a table belongs to one feature folder and should not leak into the rest of the suite. - **Closure rows** only when the data depends on something `beforeEach()` prepares; otherwise plain values are simpler and faster. ## Comparison | | PHPUnit data provider | Pest `->with()` rows | Pest closure rows | |---|---|---|---| | Declared as | static method + attribute | inline array or `dataset()` | closure per row | | Evaluated | at suite build | at suite build | per run, after `beforeEach()` | | Can use `$this` / setup state | no | no | yes | | Scalar rows | no, arrays only | yes | yes | ## Pitfalls - Supplying fewer values than the closure's required parameters fails with a dataset-arguments mismatch. - Huge cartesian products multiply run time silently; watch the run count. - Datasets hide cases: name the rows so a failure says which case broke.

  • In Pest, what does ->with(['SKU-1', 'SKU-2'])->with([1, 5]) produce?
    Four runs: every SKU combined with every quantity, a cartesian product. Each run receives the SKU then the quantity as arguments, and its label joins both parts with ` / `. Adding a third dataset multiplies the count again, so combinations grow quickly.
  • In Pest, why can a closure in a multi-value dataset row arrive in the test as a Closure instead of its result?
    When a row carries several values, Pest resolves a closure only if the matching parameter is typed with something other than `Closure`, `callable` or `mixed`; an untyped parameter counts as `mixed`. Type it, for example `CartLine $line`, and Pest calls the closure after `beforeEach()`. A single-closure row is resolved unless its parameter is typed `Closure` or `callable`.

saying these in an interview costs you the question

  • Thinks every Pest dataset row must be an array
  • Believes a dataset closure passed to with() can read beforeEach() state
  • Leaves the closure's parameter untyped in a multi-value bound row
  • Expects two with() calls to append rows rather than combine them
  • Says Pest datasets are evaluated exactly like PHPUnit providers in every case