nunomaduro/pest
Pest is an elegant PHP testing framework focused on simplicity. Built on top of PHPUnit, it offers a clean, expressive syntax that makes writing tests enjoyable, with readable expectations and a developer-friendly API.
Read the archive banner first. nunomaduro/pest is the archived v0 line of Pest. The repository itself points to pestphp/pest as the successor, stars/dependents are effectively zero, and there will be no patches. So "getting started" splits into two very different paths, and you need to pick one before running a single command.
| Situation | Action |
|---|---|
| New Laravel project, greenfield test suite | Do not install this. composer require pestphp/pest --dev plus pestphp/pest-plugin-laravel. Stop reading. |
Legacy service already pinned to nunomaduro/pest |
Contain it, learn just enough to keep CI green, and open a migration ticket. Read on. |
| Evaluating testing frameworks for standardization | Evaluate pestphp/pest. This package is disqualified on maintenance alone. |
Minimal steps for the legacy case. Do not install anything until you know what already resolves:
composer show nunomaduro/pest # present at all? which constraint?
composer show phpunit/phpunit # the real constraint on the v0 line
php -v # v0 predates PHP 8; expect a pinned 7.x
composer why nunomaduro/pest # who pulled it in?
If the lockfile already resolves and CI is green, do not run composer update. On an archived package, an update is a one-way door: PHPUnit or PHP will float to a version the v0 line was never tested against, and there is no upstream fix coming.
If you must install it fresh into a legacy app, pin hard:
composer require --dev nunomaduro/pest:^0.x
Never ^1.0 or * — Composer will resolve the wrong package family and you will spend an afternoon debugging an autoload that isn't the one on disk.
Then wire the bootstrap. v0 relies on PHPUnit's discovery, with Pest's global functions loaded via Composer's files autoload. Your phpunit.xml typically looks like:
<phpunit bootstrap="vendor/autoload.php" colors="true">
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory>tests/Feature</directory>
</testsuite>
</testsuites>
</phpunit>
Some v0 tags require an explicit suite bootstrap call (a Pest\TestSuite::create(...)-style entry point) in a tests/Pest.php or tests/bootstrap.php. Read the tests/ directory of the exact tagged version in vendor/ — it is the only trustworthy specification for the tag you pinned. The README headline describes a moving target that no longer matches the code.
Where to look first, in order:
composer.json / composer.lock — the PHP and PHPUnit constraints are what actually decide whether anything runs.vendor/nunomaduro/pest/src/ — find the functions.php listed under files in the package's autoload block. That file is the complete public API surface: test(), it(), expect(), and the hook helpers. Everything a developer can call is in there.vendor/nunomaduro/pest/tests/ — working examples for your exact tag.phpunit.xml and any tests/Pest.php.First use case. Open one existing legacy Pest test file and read it as PHPUnit with sugar. test('name', fn () => ...) is a TestCase method; expect($x)->toBe(1) is assertSame(1, $x). Your first productive act is not writing a new test — it is being able to translate the file in your head, so you can debug a red CI run without framework archaeology. Your second is picking the smallest test file and migrating it (see Implementation Patterns).
Pest's value is that assertions read left-to-right and failures print the actual value inline. The patterns that carry over unchanged to pestphp/pest:
test('order totals include tax', function () {
$order = Order::factory()->create(['subtotal' => 10000]);
expect($order->total())
->toBeInt()
->toBe(12100);
});
Fluent property access on objects and arrays — this is the feature developers miss most when dropping back to raw PHPUnit:
expect($user->profile)
->name->toBe('Nuno')
->team->name->toBe('Laravel');
expect($payload)
->toHaveKey('id')
->id->toBe(42);
Negation, type checks, and collections round it out:
expect($response->json())->not->toBeEmpty();
expect($items)->toHaveCount(3);
expect($this->isValid())->toBeTrue();
Verify each method exists in your tag before standardising on it — the expectation vocabulary grew across v0 tags and only stabilised in the successor. When in doubt, grep the src/ directory for function to.
// tests/Unit/MoneyTest.php
test('adds amounts without float drift', function () {
expect(Money::of(10)->add(Money::of(5))->amount())->toBe(15);
});
it('rejects negative amounts', function () {
expect(fn () => Money::of(-1))->toThrow(InvalidArgumentException::class);
});
test() and it() are aliases with different failure-message grammar. Pick one convention per repo and enforce it — mixed usage makes failure output read inconsistently during CI triage.
beforeEach(function () {
$this->user = User::factory()->create();
});
afterEach(function () {
Cache::flush();
});
Hooks bind to $this inside the closure, so $this->user is available in every test in the file — this is the closest v0 gets to a setUp() without a class. For cross-file setup, use a tests/Pest.php that is required from the bootstrap and call uses(...)/beforeEach there. Confirm the exact helper names against your tag; this is the area where v0 differs most from current docs.
test('validates email formats', function (string $email, bool $valid) {
expect(Validator::isEmail($email))->toBe($valid);
})->with([
['[email protected]', true],
['not-an-email', false],
['a@b', false],
]);
This is the single biggest authoring-velocity win for a developer: one test body, N cases, per-case failure output. It is also the pattern that makes the eventual migration cheap, because the data table is framework-agnostic — you can move it to a PHPUnit data provider mechanically.
Pest sits on PHPUnit, so an existing TestCase class and a new Pest file run in the same suite, same CI job, same coverage driver. Use that deliberately:
// tests/Feature/CheckoutTest.php (Pest, new)
test('checkout applies coupon', function () { ... });
// tests/Feature/Legacy/CartTest.php (PHPUnit, untouched)
class CartTest extends TestCase { /* ... */ }
Migrate outward-in: leaf unit tests first (few dependencies, no Laravel container), feature tests last. Run both suites in CI in parallel and compare counts (--testsuite) before deleting anything.
When you move a file — to pestphp/pest or to plain PHPUnit — translate mechanically:
| Pest v0 | PHPUnit equivalent |
|---|---|
expect($a)->toBe($b) |
assertSame($b, $a) |
expect($a)->toEqual($b) |
assertEquals($b, $a) |
expect($a)->toBeTrue() |
assertTrue($a) |
expect($a)->toBeEmpty() |
assertEmpty($a) |
expect($a)->toHaveCount(3) |
assertCount(3, $a) |
expect($a)->toContain($b) |
assertContains($b, $a) |
expect($fn)->toThrow(E::class) |
expectException(E::class) |
expect($a)->not->toBeNull() |
assertNotNull($a) |
test('name', $fn) |
public function test_name() |
->with([...]) |
@dataProvider / #[DataProvider] |
Interim tactic: while a file is still on the archived package, write new assertions in it using plain PHPUnit calls ($this->assertSame(...)) rather than expect(). They work identically under Pest v0 and cost nothing to migrate later. Reserve expect() for code you'd rewrite anyway.
- name: Tests
run: vendor/bin/pest --colors=never
vendor/bin/pest is a thin wrapper over PHPUnit's runner, so --filter, --testsuite, --stop-on-failure, --parallel (if your PHPUnit version supports it), and --coverage-* flags behave as PHPUnit's do. If the wrapper breaks after an environment bump, vendor/bin/phpunit still runs the same suite — that escape hatch is worth knowing on a day when CI is red and you need a signal.
Package-name confusion is the #1 failure mode. nunomaduro/pest and pestphp/pest both define global test(), it(), and expect() functions. Installing both produces "cannot redeclare function" fatals or, worse, silently loads one and not the other depending on autoload order. Symptoms look like framework bugs but are dependency resolution bugs. Diagnose with:
composer show | grep -i pest
grep -r "function expect" vendor/ --include=*.php -l
If two packages own the function, remove one. Never "fix" it by editing vendor/.
Documentation drift will waste your team's time. Every Pest tutorial, Stack Overflow answer, and blog post now targets pestphp/pest. New hires will follow current docs, hit a missing method or an unfamiliar hook signature, and lose half a day. Mitigate explicitly: a tests/README.md stating the pinned version, a link to the archive banner, and the migration mapping table above. This is cheap and pays for itself on the first onboarding.
Composer will flag it as abandoned. Expect warnings during composer install/update and possible failures from composer audit gates in CI. Do not silence the warning globally — scope the exception to the specific package and log it as tracked tech debt with an owner and an exit date. A blanket audit.abandoned = ignore hides real problems elsewhere.
Pin PHP and PHPUnit explicitly. The v0 line predates PHP 8 and modern PHPUnit majors. In composer.json:
"require-dev": {
"nunomaduro/pest": "0.x.y",
"phpunit/phpunit": "^9.5"
}
Exact pins, no carets on the framework. If a composer update wants to pull PHPUnit 10+, that is a blocker, not a workaround candidate — there is no upstream patch coming, and any local vendor/ patch evaporates on the next install. If you find yourself writing one, you have found the trigger for the migration ticket.
Bootstrap problems present as "no tests executed." When the runner reports zero tests with exit code 0, the cause is almost always a bad bootstrap path in phpunit.xml or a missing files autoload for the global functions. Debug in this order: vendor/bin/phpunit --debug (shows exactly which files were loaded), then php -r "require 'vendor/autoload.php'; var_dump(function_exists('test'));". A false there means the autoload entry is missing, not that your tests are wrong.
Debugging red runs. --filter='checkout' to isolate, --stop-on-failure to see the first real error instead of a cascade, --debug to see file load order. Inside a test, expect() failures print the expected and actual values together, which is genuinely better than a bare assertSame message — this is the one place the archived package still earns its keep.
Hook ordering bites. beforeEach closures run per test, not per file, and the order relative to a parent uses() set is easy to misread. If state leaks between tests, move the reset into the test itself rather than fighting hook precedence; explicit beats clever, and it makes migration easier.
Extension points exist but are a trap. Custom expectations via expect()->extend('toBeSorted', fn () => ...) are available in this line. Great for readability — but anything you build there is code with no upstream support and no upgrade path. If you must add one, keep it a thin wrapper over a plain PHPUnit assertion so it can be inlined during migration.
Don't let the friction become the excuse. The most likely real-world outcome of inheriting this package is that engineers quietly stop writing tests, because setup feels brittle. Measure it: tests-per-PR before and after you move to pestphp/pest. That delta is the argument for the migration, and it is usually large enough to make the decision uncontroversial.
How can I help you explore Laravel packages today?