rasuvaeff / understudy-phpunit
PHPUnit adapter for the understudy test double library: automatic verification and cleanup after every test
Requires
- php: 8.3 - 8.5
- phpunit/phpunit: ^11.5 || ^12.0 || ^13.0
- rasuvaeff/understudy: ^0.1 || ^0.2 || ^0.3 || ^0.4
Requires (Dev)
- ergebnis/composer-normalize: ^2.51
- friendsofphp/php-cs-fixer: ^3.95
- infection/infection: ^0.33
- maglnet/composer-require-checker: ^4.17
- rasuvaeff/rector-named-literals: ^1.0
- rector/rector: ^2.4
- roave/backward-compatibility-check: ^8.0
- testo/bridge-infection: ^0.1.6
- testo/testo: ^0.10.39
- vimeo/psalm: ^6.16
This package is auto-updated.
Last update: 2026-08-30 09:45:04 UTC
README
PHPUnit adapter for rasuvaeff/understudy —
a test double library where a configured call is a real call:
when(fn () => $repo->find(123))->returns($book).
The trait ends every test with understudy's own bookkeeping done for you:
- verify after success — after a body that reaches
assertPostConditions(), everyexpect()is checked. An expectation the code never fulfilled fails the test as an assertion failure; - original failure wins — after a failing body nothing is verified, so the adapter can never mask the error that actually happened;
- reset always — an
#[After]hook drops the context unconditionally. One test can never leak a double into the next; - early guard — an
#[Before]hook refuses to start over a context some earlier test left behind, which is what broken integration looks like.
The reset runs after your tearDown(). PHPUnit invokes #[After] hooks
once tearDown() has finished, and the call log retains every returned value
until that reset — so a value a double returned is still referenced while your
teardown runs. For a value that owns an OS resource — a stream, a connection,
a lock — the resource is still held: a forwarding double that returned real
file streams made teardown's directory removal fail with "Directory not
empty", on Windows only, because POSIX unlinks open files. Build such a double
lean (Understudy::lean($double) keeps calls, not returned values; understudy
0.4+), or build and use it inside Understudy::scope(), which drops the
context before teardown.
Using an AI coding assistant? llms.txt is a compact API reference it can load instead of guessing.
Requirements
- PHP 8.3 – 8.5
phpunit/phpunit(^11.5 || ^12.0 || ^13.0)rasuvaeff/understudy(^0.1 || ^0.2 || ^0.3)
Pest works too — it runs on PHPUnit, so the same trait applies through
uses(). Proven against Pest 4; see the Pest section below.
Installation
composer require --dev rasuvaeff/understudy-phpunit
Usage
<?php use function Rasuvaeff\Understudy\expect; use function Rasuvaeff\Understudy\when; use PHPUnit\Framework\TestCase; use Rasuvaeff\Understudy\PhpUnit\UnderstudyPHPUnitIntegration; use Rasuvaeff\Understudy\Understudy; final class CheckoutTest extends TestCase { use UnderstudyPHPUnitIntegration; public function testChargesForTheCart(): void { $books = Understudy::for(BookRepositoryInterface::class); expect(fn () => $books->find(7))->returns($expected = new Book(7)); $receipt = (new Checkout($books))->charge([7]); self::assertSame($expected->price, $receipt->total); } }
One registration says both things: find(7) must be called exactly once, and
it answers $expected. If the service never calls it, the test fails after its
body — with an unmet-expectation report naming the call, not with a silent
green.
Two rules of the engine decide that shape, and both are easy to miss coming from another library:
- Arm before the run. An
expect()counts only the calls that arrive after it is declared. Written below the action it counts zero and fails as "called never" about a call that did happen. To claim a call that already happened, useverify(). - One registration per call. A
when()stub and anexpect()naming the exact same call are refused withConflictingExpectation— whichever came later would take the dispatch and silently void the other. Say both things once:expect(...)->returns(...), orwhen(...)->times(...).
Strict stubs
A base class can flip strictness for a whole project:
abstract class ProjectTestCase extends TestCase { use UnderstudyPHPUnitIntegration; protected function understudyStrictStubs(): bool { return true; } }
A stub configured but never called then fails its test — the Mockito reading
of "why did you configure it, then?". Per-double strictness stays available
through Understudy::strict($double) regardless of this setting.
Overriding assertPostConditions() yourself
PHP resolves a method-name conflict between class and trait silently in favour of the class — the trait's verification would stop running without any error. Compose explicitly:
use Rasuvaeff\Understudy\PhpUnit\UnderstudyPHPUnitIntegration { UnderstudyPHPUnitIntegration::assertPostConditions as understudyAssertPostConditions; } protected function assertPostConditions(): void { // your post-conditions ... $this->understudyAssertPostConditions(); }
The trait runs parent::assertPostConditions() before verifying, so your own
post-conditions always run and their failure is reported ahead of an unmet
expectation — the check closer to the test body wins. Keep that order in an
explicit composition too.
Pest
Pest already owns the global expect() function, so import understudy's
setup verb under another name:
use function Rasuvaeff\Understudy\expect as expectCall; use function Rasuvaeff\Understudy\verify as verifyCall; uses(UnderstudyPHPUnitIntegration::class)->in(__DIR__); it('charges for the cart', function () { $books = Understudy::for(BookRepositoryInterface::class); expectCall(fn () => $books->find(7))->returns(new Book(7)); // one registration (new Checkout($books))->charge([7]); }); it('reads the call back afterwards', function () { $books = Understudy::for(BookRepositoryInterface::class); when(fn () => $books->find(7))->returns(new Book(7)); (new Checkout($books))->charge([7]); verifyCall(fn () => $books->find(7)); // after the action });
expect() is a claim made before the code under test runs — it counts the
calls that arrive after it, not the ones that already happened. Reading a call
back after the action is verify(). Pest's own expect() keeps working
untouched, and the collision-free static form
Understudy::when()/expect()/verify() works everywhere as well.
Both spellings are executed by tests/Integration/Fixtures/Pest, a Pest
project of its own; make test-pest installs and runs it.
API
| Member | Purpose |
|---|---|
UnderstudyPHPUnitIntegration |
The trait: verify-after-success, reset-in-finally semantics via #[After], #[Before] guard, optional project-wide strict stubs |
Everything else — for(), when(), expect(), verify(), matchers,
forwarding, wire() — belongs to
rasuvaeff/understudy and is
documented there. This package adds no operations of its own.
Examples
See examples/.
The understudy family
| Package | What it is |
|---|---|
| rasuvaeff/understudy | The engine: doubles, matchers, expectations, verification. |
| rasuvaeff/understudy-testo | Testo adapter — verification and reset around every test. |
| rasuvaeff/understudy-phpunit (this package) | PHPUnit and Pest adapter — the same, through a trait. |
| rasuvaeff/understudy-psalm | Psalm plugin — matcher-aware specifications and misuse diagnostics. |
| rasuvaeff/understudy-phpstan | PHPStan extension — the same for PHPStan, plus its own rules. |
Development
No PHP/Composer on the host — everything runs through Docker:
docker run --rm -v "$PWD":/app -w /app composer:2 composer build docker run --rm -v "$PWD":/app -w /app composer:2 composer test:integration
Or with Make: make build, make cs-fix, make psalm, make test.
The integration suite spawns real PHPUnit processes over fixture projects in
tests/Integration/Fixtures/; it needs no external services.