In Pest, how do ->with() and dataset() feed a test, and how does a bound dataset differ from a PHPUnit data provider?
answer
- one test run per row
- string keys become dataset "name"
- several with() calls form a cartesian product
- shared datasets in tests/Datasets
- 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
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
Recall that ->with([...]) runs a test once per row and that dataset('name', ...) defines a reusable set used as ->with('name').
Explain row shapes, labels from string keys, shared and scoped datasets, and the cartesian product of chained with() calls.
Show when a bound dataset is needed because the data depends on beforeEach() setup, and the typing rule that makes it resolve.
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