rasuvaeff/understudy

Test double library for PHP with a call-closure API

Maintainers

Package info

github.com/rasuvaeff/understudy

pkg:composer/rasuvaeff/understudy

Transparency log

Statistics

Installs: 353

Dependents: 4

Suggesters: 0

Stars: 0

Open Issues: 2

v0.2.0 2026-08-27 05:40 UTC

README

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

Test double library for PHP where the call you configure is a real call:

when(fn () => $repository->find(123))->returns($book);

No method-name strings, so refactoring and IDE navigation work without a plugin, and a typo in a method name cannot happen. No service methods on the double either — every one of them would be a name the doubled contract can no longer use.

Using an AI coding assistant? llms.txt is a compact API reference written for it.

Why another one

Understudy Mockery / PHPUnit / double
Specifying a call a real call in a closure a method-name string
Members added to the double none shouldReceive, expects, allows, …
Test runner any (thin adapters) tied to PHPUnit/Pest, or none
Fibers one context per fiber shared static state

The call-closure form comes from MockK (Kotlin), FakeItEasy and moq (C#), and mocktail (Dart). No PHP library had it.

Migrating from Mockery

No aliases and no converter — the table maps the verb you know to the shape here. Two rows are traps, marked ⚠.

Mockery Understudy Notes
Mockery::mock(BookRepository::class) Understudy::for(BookRepository::class)
$mock->shouldReceive('find') when(fn () => $mock->find(...)) a real call; no method-name string
->once() / ->twice() / ->times(3) expect(fn () => ...)->times(3) an expect() is checked by verifyAll() / the adapter
->atLeast()->once() expect(...)->times(minimum: 1)
->andReturn($book) ->returns($book)
->andReturnUsing(fn ...) ->answers(fn (Invocation $i) => ...) arguments come from $i->args
->andThrow(new NotFound()) ->throws(new NotFound())
->with(123, Mockery::any()) inside the closure: find(123, Arg::any())
Mockery::on(fn ($x) => ...) Arg::satisfies(fn ($x) => ...)
$mock->shouldNotHaveReceived('save') Understudy::unused($mock)
$mock->shouldHaveReceived('save') verify(fn () => $mock->save(...)) after the fact; add nothingElse() — see below
Mockery::close() adapter's reset(), or your own teardown
->shouldReceive(...)->once() used as setup when(...)->returns(...) a when() is permission, not a claim — if you only needed a value, expect() would make incidental setup a failing test
⚠ a spy counting every call expect() + Understudy::nothingElse($mock) expect() counts only calls matching its arguments; without nothingElse() a second call with different arguments passes — a hand-rolled counter caught it, the migration must not lose it

Performance

Against Mockery 1.6.15, Prophecy 1.26.1 and PHPUnit 12.5.33 on PHP 8.5.6. Filtered means, three runs; understudy is the baseline. Full methodology, raw tables and the environment in perf/README.md.

understudy Mockery Prophecy PHPUnit
build a double (1-method contract) 2.06µs +216% +683% +155%¹
build a double (8-method contract) 2.06µs +217% +641% +158%¹
stub: build, stub, one call, tear down 10.6µs +17% +76% **−17%**¹
mock: build, expect, call, verify 12.8µs +4% +128% **−27%**²
marginal cost of one call to a stub 0.86µs 1.61µs 1.51µs 0.69µs¹
added to process start (cold) 1.00× 1.50× 4.96× 5.38׳
retained per live double 467–482 B 513 B ~8.5 KB ~1.25 KB

¹ createStub() ² createMock() ³ a ratio rather than milliseconds: cold start moves far more between runs than its ratios do.

Understudy builds doubles about two and a half times cheaper than the next fastest, and starts a process in a fifth of the added time. It does not win everywhere: PHPUnit is ahead on both stub and mock scenarios end to end — it dispatches a call in 0.69µs against understudy's 0.86µs and no longer pays enough at build time to make up for it.

Building a double costs more than it did, 2.06µs against 1.28µs in the figures published with 0.1.x. Those figures were taken at a commit before 0.1.0 and described no released version: a regression landed between them and the first tag, and has been shipping since. It is bisected and documented in perf/README.md; an attempt to remove it moved the cost elsewhere and was reverted.

These numbers are informational and gate nothing. Regenerate them with make perf before quoting them anywhere.

Requirements

  • PHP 8.3 – 8.5
  • ext-tokenizer

No runtime dependencies beyond that (ext-mbstring is not needed — failure messages count characters through PCRE, which cannot be disabled).

Installation

composer require --dev rasuvaeff/understudy

Usage

Creating a double

use Rasuvaeff\Understudy\Understudy;

$repository = Understudy::for(BookRepository::class);

for() returns the contract's own type, so your IDE and static analyser treat $repository as a BookRepository. Several interfaces can be combined:

$double = Understudy::for(BookRepository::class, Countable::class);

Understudy unifies compatible signatures across those interfaces: parameter types are widened, return types use the narrowest compatible declaration or a synthesised interface intersection, and named arguments follow the first (primary) interface. Static contract methods exist on the generated class so the interface can be implemented, but calling one raises InvalidCallSpecification: a static call has no double instance to own its state.

A class can be the first target, with interfaces after it:

$repository = Understudy::for(DoctrineBookRepository::class, Countable::class);

What a class double does and does not do:

the target's constructor never runs — the double is built without it, so no side effect of construction reaches your test
public and protected methods overridden and dispatched; a protected one shows up in the transcript and under strict mode, but PHP's own visibility keeps it out of a setup closure
private and static methods untouched — the target keeps them, because there is no instance state to intercept
the destructor replaced with an empty one, so nothing is torn down that was never built
writable public properties start at an empty value of their type; object-typed, hooked, final, readonly and private(set) ones are left uninitialized, and reading one raises PHP's own error
clone produces a double of its own: same contracts, no expectations, no call log, owned by the context that cloned it

A readonly target produces a readonly double, which PHP requires and which costs nothing — the double declares no properties of its own.

Some targets are refused before anything is generated, each with the reason and what to do instead: a final class, a class with a non-private final instance method, an enum, a trait, an internal class, an anonymous class, any class that is not the first target, and any contract declaring an abstract property hook — an interface property, or an abstract one on a class: this engine intercepts calls, and reading a property is not one. A double that cannot intercept every method would run the target's real code against an object whose constructor never ran, which is worse than not building it at all.

Parameter defaults are reproduced rather than approximated: a class constant is rendered through its declaring class, an enum case as itself, and an object default from its own source expression — new Stamp(7) and [new Stamp(7)] alike — which is never evaluated while the double is generated. A default whose source names self, static or parent refuses the target, because those resolve against the generated class and would answer something the contract never promised.

Doubling a final class

Somebody else's final class, no interface, and no way to change it — that is what bypassFinals() is for:

// In your test bootstrap, before the class is autoloaded.
Understudy::bypassFinals(FinalGate::class);   // one class
Understudy::bypassFinals();                    // every class this process loads

It is opt-in because the technique has limits that are better met knowingly than discovered:

Order matters it works only for a class not yet read from disk; a class is read once per process
The process is changed the class really is not final any more, so reflection in your test sees something production does not
final methods stay a final method cannot be overridden either way, so a class carrying one is still refused
PHAR and preloaded classes their source arrives as phar://, or before any bootstrap ran, so it never passes through the file:// wrapper
The opcode cache is not a way back however warm the cache is, and whether or not it holds the bypassed file — Linux keeps it out, Windows does not — the class stays open. Not being cached is a cost where it happens, not a guarantee to rely on
Another source transformer if something else is already rewriting PHP source, understudy refuses rather than replacing it silently; a wrapper that leaves source alone composes and is accepted

When a class is still final at for(), the refusal says which of these it was — bypass never asked for, asked for other classes but not this one, or asked for and out of reach — rather than sending you to check the thing that is already right.

In order of preference: double an interface the class implements; for a value object, build a real one; introduce an interface. Bypass is the answer when none of those is available.

Stubbing

use Rasuvaeff\Understudy\Arg;
use Rasuvaeff\Understudy\Invocation;

use function Rasuvaeff\Understudy\when;

when(fn () => $repository->find(123))->returns($book);
when(fn () => $repository->find(404))->throws(new NotFound());
when(fn () => $repository->find(Arg::any()))->answers(
    fn (Invocation $call) => new Book(title: (string) $call->args[0]),
);

// One value per call, then the last one repeats.
when(fn () => $repository->mode())->returns('fast', 'slow');

A later stub for the same call wins; earlier ones stay reachable as fallbacks when their arguments do not match. An exhausted call-count expectation keeps answering the matching call, so use a non-overlapping matcher when a broad fallback should handle later calls.

Matcher Matches
Arg::any() anything, including null
Arg::int(min:, max:) an int in range — a numeric string does not match
Arg::float(min:, max:) a float in range — an int does not match
Arg::string(matches:) a string, optionally against a PCRE pattern
Arg::bool() a boolean
Arg::same($v) strict identity; for objects, the same instance
Arg::not($v) negates a literal or another matcher
Arg::allOf(...) everything the operands accept; an operand is a matcher or a literal
Arg::anyOf(...) anything at least one operand accepts, so anyOf('draft', 'review') reads as a set
Arg::instanceOf($class) an instance of the class or interface
Arg::satisfies($fn) whatever the predicate accepts
Arg::containing($entries) an array holding these entries and possibly more
Arg::count(minimum:, maximum:) an array or Countable of that size
Arg::which($method, $value) an object whose getter answers this value
Arg::none() an empty variadic tail — last argument only
Arg::remaining() the whole variadic tail, any length — last argument only

The type matchers are deliberately strict: Arg::int() rejects '5', and Arg::float() rejects 1. A matcher pins the declared type as much as the value, which is the point in a codebase that runs with strict_types.

Arg::which() calls only a public, non-static method that needs no arguments. A getter that throws counts as a mismatch, never as an error — matching runs while the code under test is executing, and a matcher must not be the thing that breaks it.

Expecting a call

use function Rasuvaeff\Understudy\expect;

expect(fn () => $repository->save($book));            // exactly once
expect(fn () => $repository->count())->times(1, 3);   // a range

Understudy::verifyAll();

expect() states how often a call must happen and verifyAll() checks it. A when() stub is permission rather than a claim — ->times(2) turns it into one. verifyAll(strictStubs: true) additionally fails a stub that was never used.

An expectation needs no returns(): counting and answering are separate concerns, so the mode's type-safe default supplies the value, and a matched expectation satisfies a strict double because the call was expected.

Pest has a global expect() of its own — import this one as expect as expectCall, or call Understudy::expect().

Chaining behaviour

when(fn () => $breaker->call($operation))
    ->returns('ok')
    ->then()->throws(new ConnectionLost());

One link per call, and the last link keeps answering once the chain runs out.

Verifying

use function Rasuvaeff\Understudy\verify;

verify(fn () => $repository->save($book));                 // at least once
verify(fn () => $repository->save($book), times: 2);       // exactly twice
verify(fn () => $repository->save($book), minimum: 2);     // no upper bound
verify(fn () => $repository->ping(), never: true);

Understudy::unused($repository);                           // nothing at all

Every double records every call, so verification never has to be set up in advance.

Has everything been described?

Understudy::nothingElse($repository);   // every call was accounted for
Understudy::nothingElse($repository, $clock, $mailer);   // across several doubles
Understudy::allVerified($repository);   // expectations met AND nothing else
Understudy::verifySequence(             // the exact protocol, across doubles
    fn () => $repository->begin(),
    fn () => $repository->save($book),
    fn () => $repository->commit(),
);

A call counts as accounted for when an expect() matched it, or a successful verify() claimed it. A when() stub accounts for nothing — it is permission, not a description of what happened — and a failed verify() accounts for nothing either. nothingElse() takes any number of doubles: one line closes out the whole test, and a failure names every offender rather than stopping at the first.

expect(...)->ordered() constrains the ordered expectations relative to each other; unrelated calls may happen in between. When the whole protocol matters, verifySequence() is the tool. It compares the double identity as well as the method and arguments, even when several doubles implement the same contract. allVerified() checks ordered expectations too.

Failing at the call that broke the order

Both tools above are retrospective: the exception is raised in teardown, and the stack trace points at verifyAll() rather than at the call that went out of turn. expectSequence() arms the protocol before the subject runs, so the refusal happens inside the offending call and the subject's own frame is on top of the stack:

Understudy::expectSequence(
    fn () => $repository->begin(),
    fn () => $repository->save($book),
    fn () => $repository->commit(),
);

$service->handle($command);   // fails here, on the call that broke it
Understudy `BookRepository` received a protocol call out of turn: step 2 of 3 was expected to be `save(App\Book#1 {title: 'Dune'})`.

The call was:
    commit()

The protocol is:
    1. begin()
    2. save(App\Book#1 {title: 'Dune'})   <- due here
    3. commit()
Scope the doubles the protocol names. A double it never names is invisible to it
On a named double the call is the step due, or something the test configured — anything else is refused
Each step due exactly once, in order. ordered() is the tool for a relative order that tolerates repeats
Unfinished arming is also a claim: verifyAll() reports the steps the subject never reached
One at a time arming a second protocol while one is still running is refused; a finished one may be replaced
checkpoint() verifies the protocol with everything else, then drops it — it belongs to the phase that declared it

The price of the second row is deliberate: a query the subject makes between two steps — $repository->find(7) between begin and save — has to be stubbed with when(). Without it the protocol cannot tell "not part of this" from "you got the order wrong", and guessing would put the failure back in teardown, which is what arming exists to avoid.

A subject with a broad catch can swallow the refusal. That is why arming is a claim as well as a guard: the test still fails, in teardown, with the step it stopped at.

Phases, scopes and transcripts

Understudy::checkpoint();                       // verify, then forget what is settled
$result = Understudy::scope(fn () => ...);      // nested context, verified on success
echo Understudy::transcript($repository);       // every call and its outcome
Understudy::idle();                             // true when the context holds no doubles

transcript() retains every invocation until reset() or checkpoint(). Avoid unbounded hot loops through a double when the arguments or results hold large object graphs; use a real fake for load-sized workloads.

scope() returns whatever its callback returns, and drops the nested context either way — a failure inside is never replaced by a teardown error. A double created in a scope is invalid after that scope closes. Configuration and verification must run in the context that owns the double; normal calls may be made from another Fiber and are still recorded in the owner's log. checkpoint() keeps the understudies, their modes and their labels while clearing what the current phase has settled.

Reading the call log

use Rasuvaeff\Understudy\Arg;

$calls = Understudy::calls(fn () => $repository->find(Arg::any()));

$calls[0]->args;          // [123]
$calls[0]->didReturn();   // true
$calls[0]->returned();    // the value it answered with
$calls[1]->thrown();      // the throwable, if it threw

null is a valid return value, which is why the outcome is asked about (didReturn()) rather than inferred from the value.

$last = Understudy::lastCall(fn () => $repository->find(Arg::any()));

$last?->args;   // the newest matching call, null when there was none

lastCall() is the null-safe replacement for count($calls) - 1: an empty log has no last element, and static analysis cannot prove otherwise, so the index arithmetic reports int<-1, max> before the test even runs.

Retiring a replaced double

Understudy::forget($replaced);

For the double a test built and then replaced — $this->generator = $this->fixedGenerator('other') leaves the first one behind, still holding its stubs. Under verifyAll(strictStubs: true) that stub is a failure about a double the test no longer uses; forget() retires it, so verification, accounting and reset stop seeing it. Calling anything on the object afterwards — or asking about its calls — fails with ForgottenDouble, which names forget() rather than sending you looking for a reset() you never wrote. One-way, like every other form of forgetting here.

Modes

Mode Unmatched call answers with
Loose (default) a type-safe default: null, 0, '', [], an empty generator …
Strict (Understudy::strict($double)) an immediate failure naming the method, the call, and what did not accept it
Forwarding (Understudy::forwarding($double, $real)) whatever the real instance answers, recorded like any other call

A loose double never invents a value by running someone else's constructor, and never hands back an unconstructed instance of a real class. What it can hand back is another understudy: a return type that can itself be doubled becomes one, one level deep, which the same test can configure. That double is a generated stand-in, not the target with its constructor skipped.

One level, and no further — a double created this way refuses to produce another, so $a->b()->c() says so rather than inventing a third collaborator the test never asked for. Registering a factory for C is how you say you meant it. Where no safe value exists it says so, and names the way out.

Saying what a default should be

A nested double of LoggerInterface answers everything with a default and tells the test nothing. A NullLogger is usually what it wanted:

Understudy::defaults(LoggerInterface::class, fn () => new NullLogger());
Understudy::defaults(ClockInterface::class, fn () => FakeClock::frozen());

A registration outranks null on a nullable return: a method declared ?ClockInterface answers with the registered clock, because saying what the type should be means it there too. Without a registration such a method is still null.

The nearest registration wins, measured as distance in the type graph: an exact match first, then the closest registered ancestor. Two ancestors the same distance away raise AmbiguousDefaultFactory rather than letting whichever was registered first decide — a tie has no order a reader could predict. A factory that produces the wrong type raises InvalidDefaultValue.

Registrations belong to the current context: sibling Fibers do not see each other's, and Understudy::reset() drops them with the test. Register them in a per-test fixture rather than once for a whole suite.

Wiring a subject

['sut' => $service, 'doubles' => $d] = Understudy::wire(CatalogService::class);

/** @var Repository $repository */
$repository = $d['repository'];
when(fn () => $repository->find(1))->returns($book);

Assert::same($service->lookup(1), $book);

wire() reads the constructor and nothing else: no container, no property injection, no setters. A unit test cares about the collaborators the class itself asks for.

Constructor parameter What it gets
a class or interface a double, returned in doubles under the parameter name
a nullable object a double — null is something the test can ask for explicitly
an intersection one double of both contracts
a union of several object types refused: picking one would be a guess
an object that cannot be doubled, with a default its own default, applied by PHP
a scalar with a default the declared default, and no double
a scalar without one refused, naming the override to pass
a variadic tail left empty; inventing entries would invent collaborators
a by-reference parameter refused — overrides are values, and passing one would promise a reference semantics wire() does not have

overrides: ['name' => $value] replaces one dependency with a real instance or a double you built yourself; those are yours already, so they do not appear in doubles. Every refusal happens before the constructor runs, so a wrong type is reported by wire() rather than as a TypeError from inside the subject.

A variadic tail takes a list, and every element is checked against the declared type before the constructor runs:

['sut' => $service] = Understudy::wire(TaggedService::class, ['tags' => ['a', 'b']]);

Anything that is not a list — a bare value, a string-keyed array — is refused by name, as is an element of the wrong type. Filling a tail this way means the parameters before it are passed positionally, so an omitted optional one has its declared default materialized; that is the one place wire() evaluates a default rather than letting PHP apply it.

Forwarding to a real object

$real = $container->get(CacheInterface::class);
$spy = Understudy::for(CacheInterface::class);
Understudy::forwarding($spy, $real);

when(fn () => $spy->get('key'))->throws(new PoolOverload());

Everything the test did not configure runs for real and is recorded; get('key') throws. The target has to satisfy every contract the double stands in for, or it is refused.

Understudy::for($real) is the shorthand for a non-final class: it builds a double of that object's class and remembers the object, but keeps answering with defaults until Understudy::forwarding($double) turns delegation on. Wrapping something is not the same as delegating to it. A final class is refused — its class is already loaded, so the double cannot keep the concrete type you are holding.

Inside an answer, one call can go through on its own:

when(fn () => $spy->get('key'))
    ->answers(fn (Invocation $call) => strtoupper((string) $call->callOriginal()));

Five things are worth knowing before relying on it:

  • Only the call at the boundary is recorded. If the real method calls another method on itself, that happens inside the real object. Understudy proxies an object; it does not instrument one.
  • A : never method reaches the real implementation. Its throw lives there, and a forwarding double has something that can answer for itself.
  • An understudy is not a valid target. Forwarding to one — itself included — sends every call back into a dispatcher, and an unmatched one keeps coming back until the stack runs out.
  • A by-reference argument is the caller's variable. A forwarded method writes to it, and the call log keeps both readings — what was passed and what it became — so a verification still sees the value the caller handed over.
  • A fluent method comes back as the double. When the real instance returns itself, the double is returned instead, so a chain stays doubled. A static method that returns a different instance of the real class is refused — that object is not a double, and returning it would break the override's own : static.

Failure messages

Understudy `BookRepository` expected `tag('alpha', 2)` to be called exactly 1 time,
but it was never called.

The following calls to `tag` were made during this test:
    tag(*'beta'*, 2)

The asterisks mark the argument that differed — borrowed from NSubstitute. Understudy::label($double, '…') names a double when several of the same contract are in play.

An object argument is matched by identity, so two instances never match however equally they read — and the message has to be able to show which of the two reasons it was:

Understudy `BookRepository` expected `save(App\Book#1 {title: 'Dune'})` to be called
exactly 2 times, but it was called 1 time.

The following calls to `save` were made during this test:
    save(App\Book#1 {title: 'Dune'})
    save(*App\Book#2 {title: 'Dune'}*)

#1 and #2 are aliases numbered within one message, in order of first appearance: the same instance keeps one number wherever it appears, so the log line above says "this is the object you named" and the marked one says "this is a rebuilt copy". They are not object ids — an id is reused after a collection, and the same failing test would print different numbers on different runs.

The braces list public properties, up to five, at the same depth budget the rest of the message uses. Nothing is called to render them: an object that keeps its state behind getters renders as its alias alone, because running a getter to print a message would run the code under test at the worst possible moment.

A strict double refuses at the call rather than in teardown, and says what it compared the call against:

Understudy `BookRepository` is strict and received an unexpected call to `tag()`.

The call was:
    tag('beta', 1)

Nothing configured for `tag` accepted it:
    tag(*string(matches: /^a/)*, *2*)

Configure it first: when(fn () => $double->tag(...))->returns(...)

The marks are read from the expectation's side: each one is an argument that rejected this call, including a position the call never carried. Everything configured for that method is listed — the dispatcher's own order, up to five, then a count — because a stub that could never have matched is often exactly the one the test meant to write. When nothing at all is configured for the method, there is nothing to compare against and the message stays the single line naming it.

Cleaning up

Understudy::reset();
Understudy::idle();   // true when the current context holds no doubles

The understudy-testo and understudy-phpunit adapters verify and reset for you after every test; without one, call reset() in your own teardown. Isolation and accounting are different things: each Fiber gets its own recording phase, call log and sequence counter, but verifyAll(), reset(), idle() and checkpoint() cover every context the test put understudies in. A body that runs in a Fiber is still the test's, and an adapter asks about the test from wherever it stands.

Using Pest

Pest already owns the global expect() function, so importing understudy's setup verb collides with it. Import the function under another name:

use function Rasuvaeff\Understudy\expect as expectCall;

expectCall(fn () => $books->find(7));

or use the collision-free static form everywhere:

Understudy::expect(fn () => $books->find(7));

when() and verify() are globally free and need no alias.

Security

Understudy generates a class per set of contracts and evaluates it once per process. It never loads code from user input, never touches the filesystem, and holds all state in WeakMaps keyed by the double object — never by spl_object_id(), which PHP reuses after collection.

It is a development dependency. Do not install it in production.

Examples

Runnable scripts live in examples/ — one per concept: the three modes, wire(), ordering and protocol verification, the defaults registry, and reading a failure as data. Each checks itself and exits non-zero on a mismatch, so bin/package-audit runs them as a gate rather than linting them.

The understudy family

Package What it is
rasuvaeff/understudy (this package) The engine: doubles, matchers, expectations, verification.
rasuvaeff/understudy-testo Testo adapter — verification and reset around every test.
rasuvaeff/understudy-phpunit 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

make build          # validate, normalize, require-checker, cs, psalm, test
make cs-fix
make psalm
make test
make mutation       # infection, gate at 85% MSI
make release-check

make perf-install   # once: the comparative benchmark harness in perf/
make perf           # against Mockery, Prophecy and PHPUnit
make perf-cold      # cold start, one process per double
make perf-memory    # bytes retained per live double

Or through Docker directly:

docker run --rm -v "$PWD":/app -w /app composer:2 composer build

spikes/ holds the feasibility fixtures the design rests on; bash spikes/run.sh runs them under any PHP 8.3+ binary.

License

BSD-3-Clause. See LICENSE.md.