rasuvaeff/understudy-phpunit

PHPUnit adapter for the understudy test double library: automatic verification and cleanup after every test

Maintainers

Package info

github.com/rasuvaeff/understudy-phpunit

pkg:composer/rasuvaeff/understudy-phpunit

Transparency log

Statistics

Installs: 12

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.1.3 2026-08-28 14:19 UTC

This package is auto-updated.

Last update: 2026-08-30 09:45:04 UTC


README

Latest Stable Version Total Downloads Build Static analysis Psalm level PHP License Русская версия

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(), every expect() 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, use verify().
  • One registration per call. A when() stub and an expect() naming the exact same call are refused with ConflictingExpectation — whichever came later would take the dispatch and silently void the other. Say both things once: expect(...)->returns(...), or when(...)->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.

License

BSD-3-Clause