Search by

rasuvaeff / understudy-phpunit

rasuvaeff

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

Package info

github.com/rasuvaeff/understudy-phpunit

pkg:composer/rasuvaeff/understudy-phpunit

Statistics

Installs: 81

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.5.1 2026-09-19 20:51 UTC

This package is auto-updated.

Last update: 2026-09-21 06:32:51 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), 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.9 || ^0.10)

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?". There is no per-double form of it: Understudy::strict($double) is strict dispatch — "fail on any call no expectation matched" — and says nothing about a stub that was configured and never called. The per-double equivalent is when(…)->times(n).

Verification runs before your teardown here, and after it under understudy-testo. assertPostConditions() is called by PHPUnit before tearDown(); the Testo interceptor runs outside #[AfterTest]. Neither is wrong, but a test whose expectation is fulfilled by teardown itself fails here and passes there. reset() runs after teardown in both.

A test that creates no double is not touched at all: nothing is counted for it, so #[DoesNotPerformAssertions] keeps meaning what it says.

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.

An override that forgets to compose is not silent: a body that passed, held doubles, and never reached the trait's verification fails in the #[After] hook with this recipe in the message — and the context is still reset.

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;
use function Rasuvaeff\Understudy\when;

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 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.

Security

The trait hooks #[Before]/#[After] around each test and, on a passing body, calls the engine's verifyAll(). It executes no code beyond what the test itself runs and writes nothing.

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