pestphp/pest-plugin-expectations
An extension for the Pest PHP testing framework that adds extra expectation APIs, letting you write fluent, readable assertions with expect(). It supports custom expectation creation and ships additional built-in expectations for cleaner, more expressive test suites.
Step 0 — check whether you need this at all. The package's only capability is registering reusable custom expectations. Pest 1.x+ ships that natively via expect()->extend(), and the plugin has been archived since 2021-05-19. Before anything else:
composer show pestphp/pest | grep versions
grep -rn "expect(" tests/ | wc -l
If the first command reports Pest 1.x or newer, you are done — the plugin adds nothing and risks duplicate registration. Read src/ in the repo for context, do not install.
Step 1 — the native pattern you actually want. In tests/Pest.php (loaded once per suite):
use Pest\Expectation;
expect()->extend('toBePaid', function () {
return $this->toBeInstanceOf(App\Models\Order::class)
->and($this->value->status)->toBe('paid');
});
Then, in any test, expect($order)->toBePaid();. That is the entire value proposition, in five lines, owned in-repo.
Step 2 — if you are genuinely pinned to Pest v0 / PHPUnit 8–9. That is the only constraint under which the plugin is even arguable. Install as a dev dependency only:
composer require --dev pestphp/pest-plugin-expectations
There is no config to publish and no service provider to register — it hooks Pest's expectation registry through plugin discovery. Verify it resolved cleanly with composer why-not phpunit/phpunit 10 on a throwaway branch before committing anything.
Where to look first. tests/Pest.php for registration, composer.json for the Pest/PHPUnit ceiling, and the repo's src/ for the exact expectation-class contract — it is small enough to read end to end, and the contract is not something to infer from the README alone.
First use case to try. Pick the assertion you copy-paste most often across tests — typically expect($order->status)->toBe('paid') or expect($user->subscribed())->toBeTrue(). Name it in product language (toBePaid, toBeSubscribed), register it once, and replace every call site.
Inline extensions for one-off vocabulary. For a handful of expectations, keep them in tests/Pest.php and move on. This is the right answer for most Laravel apps — a dozen domain assertions do not justify a file structure.
expect()->extend('toBeWithinRange', function (int $min, int $max) {
return $this->toBeGreaterThanOrEqual($min)->toBeLessThanOrEqual($max);
});
File-per-group for larger surfaces. When the count passes ~10, split into tests/Expectations/Orders.php, tests/Expectations/Billing.php, and require them from Pest.php. Keep the extend() calls at the top level of each file, not inside a function — Pest loads Pest.php once and you want a single, deterministic registration pass.
The class-based shape the plugin encouraged. If you are reading the repo for design ideas, the pattern is: one small class per expectation, a pure predicate over the value, and a registration step that wires the class into the expectation registry. Port it natively by delegating to the class from an extend() closure:
expect()->extend('toBePaid', function () {
return $this->toBeInstanceOf(Order::class)
->and((new IsPaidExpectation)($this->value))->toBeTrue();
});
The class holds the branching logic (statuses, refund states, partial payments); the closure stays one line. That keeps the tricky part unit-testable on its own.
Laravel-flavoured domain expectations that earn their keep.
expect()->extend('toBeSubscribed', function () {
return $this->and($this->value->subscribed('default'))->toBeTrue();
});
expect()->extend('toHaveValidationError', function (string $field) {
return $this->toHaveStatus(422)->and($this->value->json('errors'))->toHaveKey($field);
});
expect()->extend('toBeMoney', function (int $cents, string $currency = 'USD') {
return $this->toBeInstanceOf(Money::class)
->and($this->value->getAmount())->toBe($cents)
->and($this->value->getCurrency()->getCode())->toBe($currency);
});
The toHaveValidationError example is the highest-leverage one in a typical Laravel codebase — it collapses four assertions spread across every feature test into one line, and it reads as the product requirement.
Compose domain expectations on domain expectations. Nothing stops toBeRefunded() from calling toBePaid() first. Do that rather than duplicating the precondition; it keeps the failure message pointing at the real cause.
Keep factories and expectations separate. A paidOrder() factory state and a toBePaid() expectation look similar and are not interchangeable. Factories build fixtures; expectations assert outcomes. Mixing them produces tests that pass because the helper lies.
Migration pattern: inventory → rewrite → delete → guard. Grep every expect( call site and tag it deletable, rewrite as native extend(), or plain PHPUnit assertion. Rewrite first, run the full suite with a coverage diff, then remove the dependency in its own commit. Add a composer conflict entry or a CI lint so nobody copy-pastes it back in.
Archived means zero upstream. No CVE channel, no triage, no fixes. If it broke tomorrow, the whole remediation is yours. Treat any surviving usage as a removal ticket with a named owner, not as infrastructure.
The version ceiling is the real cost, not breakage. A May 2021 release implies PHP ≤8.0 / PHPUnit ≤9 constraints. On PHP 8.2+ with PHPUnit 10/11 Composer will refuse resolution or force downgrades that drag the rest of your test toolchain backwards. Always verify with a disposable-branch install — never infer compatibility from the MIT license or 2021-era green CI.
Duplicate registration is the dangerous failure. Once Pest's core supplies expect(), having the plugin also register into the same registry risks a bootstrap fatal or — worse — silently shadowed assertions, where tests stay green while asserting less than reviewers assume. That is the failure mode to protect against, and it is invisible until something ships broken.
Inside extend(), $this is the Expectation, not your value. Use $this->value for the raw subject. Do not use ($value) — the closure is bound per expectation call, and capturing the wrong thing gives you assertions that pass against a stale object.
Return a chained expectation, not true. Returning a raw boolean short-circuits the chain and can leave the expectation as a silent no-op depending on the Pest version. Return $this->toBe(...), $this->and(...), or another Expectation.
Don't do I/O inside an expectation. No database queries, no HTTP calls, no Storage:: reads. An expectation that touches the world fails with a stack trace pointing at toBePaid, which hides the actual cause. Fetch in arrange, assert in the expectation.
Prefer chaining over assert() inside the closure. A raw assert() throws away Pest's diff output — you get a generic failure instead of "expected 'paid', got 'pending'". Chain real expectations so failure messages stay useful.
Registration is per test suite. Pest loads tests/Pest.php per suite. If you have separate Unit/ and Feature/ bootstraps, an expectation registered in one is not available in the other unless the file is required from both. Symptom is a confusing Call to undefined method Pest\Expectation::toBePaid().
Name for the domain, not the assertion. toBePaid is right; toBeStatusPaid leaks the implementation. Avoid names that collide with Pest's built-ins (toBe, toBeTrue, toHaveCount) — you will get confusing resolution behaviour and IDE misdirection.
IDE support is on you. Core expectations are documented and autocompleted for free. Custom ones are not — add a @method docblock stub or a small .phpstorm.meta.php file if your team leans on completion, or the assertions will be undiscoverable to new joiners.
Debugging checklist. Expectation not found? Check which Pest.php the suite loaded. Assertion passes when it should not? expect($value)->dd() to confirm what is wrapped. Bootstrap fatal? Comment out the plugin's autoload entry and re-run — plugin-discovery conflicts surface as unrelated-looking errors under incident pressure.
Extension points worth knowing. Pest's Pest\Expectation class is the only thing you need to subclass or delegate to; everything else in the plugin is convenience. That is the reason the whole package collapses to a day of in-house work — and the reason the standing rule should be: test-tooling extensions live in-repo on framework-native extension points.
How can I help you explore Laravel packages today?