skip to content

When converting a PHPUnit CartTest class to Pest, how do setUp(), a #[DataProvider] and expectException() map, and what behaviour changes along the way?

level: seniorimportance: should knowfreq 28%

answer

  1. setUp() becomes beforeEach() on $this
  2. provider becomes ->with() or dataset()
  3. expectException becomes toThrow or ->throws
  4. helpers move to a base class
  5. higher-order tests need lazy closures

basics

~20 s

setUp() becomes beforeEach() storing state on $this, a #[DataProvider] becomes ->with() or a named dataset(), and expectException() becomes ->throws() or toThrow(). Private helpers must move to a base class wired with pest()->extend(), and datasets gain post-setup evaluation.

solid answer

~50 s

Each test method becomes an `it()`/`test()` closure in a file of the same name; the file's generated class still runs on PHPUnit, so PHPUnit assertions keep working. `setUp()` becomes `beforeEach(function () { $this->cart = new Cart(); })`; `tearDown()` becomes `afterEach()`. A `#[DataProvider('prices')]` becomes `->with([...])` or a `dataset()`; scalar rows and closure rows (evaluated after `beforeEach()`) are now possible, and labels change from `with data set "x"` to `with dataset "x"`, so CI filters on names need updating. `expectException()` becomes `->throws()` with the same whole-test semantics, or `toThrow()` around the one call. Private helper methods cannot live in a closure file: move them to a base test case bound with `pest()->extend()->in()`. Single-expectation tests can become higher-order tests, but their value must be wrapped in a closure. Pest documents a Drift plugin (`--drift`) that automates most of this.

code

php · 25 lines
php
<?php
// tests/Unit/CartTest.php after conversion
declare(strict_types=1);

use App\Shop\Cart;

beforeEach(function () {
    $this->cart = new Cart();
});

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

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

it('rejects a zero quantity', function () {
    expect(fn () => $this->cart->add('SKU-1', 0))
        ->toThrow(InvalidArgumentException::class);

    expect($this->cart->count())->toBe(0);
});

go deeper

for a junior

Recall the basic mapping: setUp() to beforeEach(), data provider to ->with(), expectException() to ->throws() or toThrow().

for a middle

Explain where helpers and typed properties go, how dataset labels change, and why higher-order tests need a lazy closure.

for a senior

Plan a safe conversion: automated tooling, test counts before and after, updated CI filters, and a mixed suite during the transition.

for a principal

Weigh whether converting a large PHPUnit suite is worth the churn, and set the conventions the converted suite must follow.

## The starting point A typical PHPUnit class for a shopping cart, written for PHPUnit 13: ```php final class CartTest extends TestCase { private Cart $cart; protected function setUp(): void { $this->cart = new Cart(); } public static function prices(): iterable { yield 'single item' => ['SKU-1', 1, 999]; yield 'bulk order' => ['SKU-1', 10, 8990]; } #[DataProvider('prices')] public function testTotal(string $sku, int $qty, int $expected): void { $this->cart->add($sku, $qty); $this->assertSame($expected, $this->cart->total()); } public function testRejectsZeroQuantity(): void { $this->expectException(InvalidArgumentException::class); $this->cart->add('SKU-1', 0); } } ``` ## The mapping | PHPUnit | Pest | |---|---| | class `CartTest extends TestCase` | file `CartTest.php` with closures; base class via `pest()->extend()` | | `setUp()` / `tearDown()` | `beforeEach()` / `afterEach()` | | `setUpBeforeClass()` | `beforeAll()` (no `$this`) | | `public function testTotal(...)` | `it('totals ...', function (...) { ... })` | | `#[DataProvider('prices')]` | `->with([...])` or `->with('prices')` from `dataset()` | | `expectException(X::class)` | `->throws(X::class)` or `expect(fn () => ...)->toThrow(X::class)` | | `$this->assertSame(a, b)` | `expect(b)->toBe(a)` - or keep `$this->assertSame()` | | private helper method | method on a base test case, reached as `$this->helper()` | Converted: ```php beforeEach(function () { $this->cart = new Cart(); }); it('totals a line', function (string $sku, int $qty, int $expected) { $this->cart->add($sku, $qty); expect($this->cart->total())->toBe($expected); })->with([ 'single item' => ['SKU-1', 1, 999], 'bulk order' => ['SKU-1', 10, 8990], ]); it('rejects a zero quantity', function () { $this->cart->add('SKU-1', 0); })->throws(InvalidArgumentException::class); ``` ## What changes in behaviour 1. **Typed properties disappear.** `private Cart $cart` becomes a dynamic property on the generated class. It is allowed there, but static analysis loses the type unless you add annotations or move state to a base class with a declared property. 2. **Helpers need a new home.** A closure file has no class body, so private helper methods move to a base test case wired with `pest()->extend(ShopTestCase::class)->in('Unit')`, or become plain functions. 3. **Dataset timing can change.** A PHPUnit provider always runs before `setUp()`. In Pest, rows that are closures run after `beforeEach()`. Translating a provider row by row keeps the old timing; switching to closure rows is an opportunity, not a requirement. 4. **Names change.** PHPUnit labels runs `with data set "bulk order"`; Pest labels them `with dataset "bulk order"`, and test names come from descriptions rather than method names. CI jobs, baselines or dashboards keyed on test names must be updated. 5. **Exception semantics stay if you use `->throws()`**: it registers the same PHPUnit expectation for the whole body. Switching to `toThrow()` narrows the check to one call, which is usually stricter and better. 6. **Attributes on methods** such as `#[Group]` become chained calls like `->group('cart')`. ## Higher-order tests as a final simplification A test whose body is a single expectation can drop its closure: ```php it('starts empty') ->expect(fn () => (new Cart())->count()) ->toBe(0); ``` The closure passed to `->expect()` is essential: the value must be built when the test runs, not when the file is loaded. Writing `->expect((new Cart())->count())` would evaluate while Pest collects tests, outside the test's lifecycle. Use this form for short checks; anything with setup reads better as a normal closure. ## A checklist per converted file 1. Move `setUp()`/`tearDown()` bodies into `beforeEach()`/`afterEach()`, keeping state on `$this`. 2. Move private helpers to a base test case or plain functions, and wire the base class in `tests/Pest.php`. 3. Turn each data provider into named `->with()` rows or a shared `dataset()`, keeping the keys so cases stay identifiable. 4. Replace `expectException()` with `->throws()`, or better, `toThrow()` around the single call. 5. Translate method attributes such as `#[Group('cart')]` into chained calls like `->group('cart')`. 6. Run the file and compare its test count with the PHPUnit class it replaced. ## Doing it at scale Pest's documentation describes a Drift plugin (`pestphp/pest-plugin-drift`, run as `vendor/bin/pest --drift`) that converts PHPUnit classes automatically and leaves some cases for manual work; check that it supports your Pest major before relying on it. Whatever the tool, compare the test count before and after, and run both suites side by side for a while - Pest runs PHPUnit classes too, so a mixed suite is a valid intermediate state.

  • After converting CartTest to Pest, why can CI filters, baselines or dashboards keyed on test names stop matching?
    Test names now come from descriptions instead of method names, and dataset runs are labelled by Pest: its output shows `with dataset "bulk order"` where PHPUnit printed `with data set "bulk order"`. Anything that selects or tracks tests by name has to be re-checked against the new names as part of the conversion.
  • When converting to Pest, where should CartTest's private makeCartWithItems() helper go?
    A Pest file has no class body for private methods. Put the helper on a base test case, for example `ShopTestCase`, and bind it with `pest()->extend(ShopTestCase::class)->in('Unit')`; tests then call `$this->makeCartWithItems()`. A plain namespaced function also works when it needs no test-case access.
  • Is a half-converted suite, with some PHPUnit classes and some Pest files, a problem?
    No. Pest runs on PHPUnit and executes ordinary PHPUnit test classes alongside closure files. Converting folder by folder, and comparing test counts at each step, is a safe way to migrate a large suite.

saying these in an interview costs you the question

  • Believes Pest cannot run existing PHPUnit test classes
  • Keeps private helper methods in a Pest test file
  • Assumes dataset run names stay identical after conversion
  • Writes ->expect($value) in higher-order tests without a closure
  • Expects $this in beforeAll() after converting setUpBeforeClass()