Skip to content

Understudy ​

Rasuvaeff\Understudy\Understudy

Class — Package: rasuvaeff/understudy — Source — Version: v1.1.0-1-g6ed023f

The whole public surface, as static methods so that an understudy itself can stay free of service members: every one of them would be a name the doubled contract can no longer use.

The same three verbs exist as free functions in this namespace — see functions.php — and read better in tests. This class is the collision-free form, which matters in Pest, where expect() is already taken.

Methods ​

for() ​

php
static for(class-string<\T>|\T $target, class-string $interfaces): object

Creates an understudy for one contract, optionally combined with further interfaces.

An instance may be passed instead of a class name. The double then stands in for that object's class and remembers the object as its forwarding target — but keeps answering with defaults until forwarding() says otherwise. Wrapping something is not the same as delegating to it, and a double that started running real code the moment it was built would be a surprise, not a shorthand.

when() ​

php
static when(callable $call): WhenBuilder

Stubs a call: allowed any number of times, including none.

expect() ​

php
static expect(callable $call): ExpectBuilder

Declares a call the code under test is expected to make: exactly once unless times() says otherwise, and checked by verifyAll().

verifyAll() ​

php
static verifyAll(bool $strictStubs = false): int

Checks every expectation of the current context: the ones expect() declared, and the stubs that opted in through times().

With strictStubs, a stub that was never called fails too — the Mockito reading of "why did you configure it, then?".

Returns how many checks that came to, so a runner adapter can count real assertions instead of a flat one. One check is: an expectation that carries a claim (expect(), or times() on a stub); a plain stub under strictStubs, where its use is what is checked; an ordering constraint over expectations that declared one; an armed protocol, whose completeness is checked here. A failed check throws, so the number only ever counts checks that passed — and checks verify() made earlier are not part of it: those answered at their own call sites, and this call does not re-decide them.

verify() ​

php
static verify(
    callable $call,
    int<0, max>|null $times = NULL,
    int<0, max>|null $minimum = NULL,
    int<0, max>|null $maximum = NULL,
    bool $never = false,
    callable|null $where = NULL,
): void

Asserts, after the fact, how many times a call was made.

  • $times — exact count; defaults to at least one
  • $never — the call must not have happened at all; refused beside a count
  • $where — a guard over the whole argument list

A where: predicate narrows the claim the same way ->where() narrows a specification: only calls whose whole argument list the closure accepts are counted.

calls() ​

php
static calls(callable $call): array

Every recorded call matching the specification, in order.

lastCall() ​

php
static lastCall(callable $call): ?Invocation

The most recent recorded call matching the specification, or null when there was none.

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

strict() ​

php
static strict(\T $double): object

Makes an understudy fail on any call no expectation matched.

Answers with the double it configured, so the mode can be chosen where the double is handed over — ClientInterface::class => Understudy::strict(Understudy::for(ClientInterface::class)) in a container definition, rather than as a statement that has to find a variable to name.

lean() ​

php
static lean(object $double): void

Stops this understudy's call log from retaining returned values.

The log holds every invocation together with its outcome until reset() — and with the runner adapters that is after the test's own teardown. For plain data that is only memory; for a value that owns an OS resource — a stream, a connection, a lock — the resource is still held while teardown runs. A lean understudy keeps the invocation (method, arguments, sequence), so matching, verify(), transcript() and nothingElse() work unchanged, but the returned value is not kept: Invocation::returned() raises OutcomeUnavailable, the way it already does for a call that threw. It also caps the per-call memory growth of a hot loop through the double.

One-way for the double's lifetime. Understudy::scope() is the other remedy: it drops the whole context — outcomes included — before the lifecycle teardown runs.

forwarding() ​

php
static forwarding(object $double, ?object $real = NULL): void

Delegates unmatched calls to the real implementation, recording each one.

Throws:

With $real, the double starts standing in front of that object; without it, the mode is turned on for a double built from an instance by for(). Splitting the two is deliberate: for($real) gives you a double that remembers where it came from, and until you say so it still answers with defaults rather than running real code.

A double of a class runs the real bodies inline, on itself: the state the real instance held is copied onto the double, and an unmatched call executes the parent implementation with $this still the double — so a self-call inside the real code re-enters the double, meets stubs, an armed protocol and the call log, and is recorded like the boundary call. Invocation::callOriginal() runs inline there too, over the state the double now holds. A double of an interface has no bodies to reach and proxies to the real instance — only the boundary call is recorded, and self-calls happen inside the real object where nothing can see them.

Inline needs $real to be exactly the doubled class: a subclass instance carries overrides the doubled class's own bodies do not contain, and it keeps the proxy form so that those overrides answer. A final or private method is not overridden either, so a self-call to one of those runs the real body directly — it works, over the double's state, but it is not intercepted and the log does not show it. And static::class inside a real body names the generated class, not the doubled one.

Calling this twice with the same instance changes nothing; re-pointing an inline double at a different instance is refused, because the state copy cannot be taken twice — a readonly property is already initialized, and whatever the real code has written since would be discarded.

delegate() ​

php
static delegate(class-string<\T> $contract, \T $real): object

Builds a double of one contract that delegates every unmatched call to the given instance, and hands the double back — for() plus forwarding() in one expression:

php
$store = Understudy::delegate(StoreInterface::class, $this->store);
when(fn () => $store->delete(Arg::any()))->throws(new StoreException('unreachable'));

A separate verb rather than an overload of forwarding(): that method answers void for a double built earlier, and a return value that appears only for one shape of the first argument is the kind of magic a reader should not have to know about.

The target is validated the way forwarding() validates it: it must satisfy the contract, and an understudy is refused — delegating to one sends every call back into the dispatcher it came from.

wire() ​

php
static wire(class-string $sut, array<string,mixed> $overrides = []): array

Builds a real subject with an understudy for every constructor dependency, and hands back both.

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

It 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, and anything else would be guessing about a design the test cannot see.

overrides replaces one dependency by parameter name, 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 — a half-built subject would show the test a TypeError from inside code it did not write.

bypassFinals() ​

php
static bypassFinals(class-string|null $class = NULL): void

Lifts final off a class so it can be doubled, before the class is loaded.

  • $class — null lifts it for every class the process loads from here on, which is what a bootstrap wants

Throws:

php
Understudy::bypassFinals(FinalGate::class);  // one class
Understudy::bypassFinals();                  // every class, from bootstrap

Opt-in, and deliberately so. Doubling a final class means telling PHP something untrue about the code under test for the rest of the process, and the technique has limits worth meeting knowingly: it works only for classes not yet loaded, it needs a file:// wrapper nothing else has claimed, and it cannot reach a class inside a PHAR or one that was preloaded.

final on methods is never touched. Such a method stays unoverridable after the class opens up, and a double that let one through would run the target's real code — so a class carrying one is still refused.

Preferred alternatives, in order: double an interface the class implements; for a value object, build a real one; introduce an interface. Bypass is for the case where none of those is available — somebody else's final class standing between a test and the code under test.

defaults() ​

php
static defaults(class-string $contract, callable $factory): void

Registers what a loose double should hand back for one contract.

A nested double of LoggerInterface answers everything with a default and tells the test nothing; a NullLogger is what it wanted. Resolution is by distance in the type graph — exact match first, then the nearest registered ancestor — so the answer does not depend on the order the factories were registered in.

The registry belongs to the current context: sibling Fibers do not see each other's, and reset() drops them with the test.

label() ​

php
static label(\T $double, non-empty-string $label): object

Names one understudy in failure messages, which is what makes two doubles of the same contract tellable apart.

Answers with the double it named, for the same reason Understudy::strict() does.

unused() ​

php
static unused(object $double): void

Asserts that nothing was called on this understudy at all.

forget() ​

php
static forget(object $double): void

Retires an understudy on purpose.

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() says it was retired, so verification and reset stop seeing it. Calling anything on the object afterwards fails with ForgottenDouble.

One-way, like every other form of forgetting here: a double belongs to exactly one context for its whole life.

nothingElse() ​

php
static nothingElse(object $double, object $more): void

Asserts that every call these understudies received has been accounted for: matched by an expect(), or claimed by a successful verify().

Accepts any number of doubles, so one line can close out a test that used several: every double named is checked, and a failure reports each offender rather than stopping at the first.

allVerified() ​

php
static allVerified(object $double): void

Asserts this understudy's expectations are satisfied and that nothing else happened to it — the two halves of "I have described everything".

expectSequence() ​

php
static expectSequence(callable $calls): void

Arms a protocol before the code under test runs, so that a call breaking the order fails at that call — with the subject's own frame on top of the stack — instead of in teardown.

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

$service->handle($command);   // fails here, on the call that broke it

Totality is scoped to the doubles the protocol names: a call on one of them is either the step due or something the test configured, and a double the protocol never names is invisible to it. That is why a query a subject makes between two steps has to be stubbed — without a when() 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.

Each step is due exactly once, in order. expect(...)->ordered() is the tool for a relative order that tolerates repeats and calls in between; verifySequence() is the same total protocol checked afterwards.

An armed protocol is also a claim: verifyAll() reports the steps the subject never reached, so arming one and never exercising it fails.

verifySequence() ​

php
static verifySequence(callable $calls): void

Asserts that these calls are exactly what happened in this context, in this order, across every understudy — no more, no fewer.

Called with nothing, it asserts that nothing happened, and that is not the oversight it resembles: expectSequence() refuses an empty protocol because arming one would put every later call on trial with no step to try it against, while this one reads the log that already exists, and "the log is empty" is a real question with a real answer. A call that did happen is reported the same way any extra call is.

transcript() ​

php
static transcript(object $double): string

Every call this understudy received, with its arguments and outcome — for reading while a failure is being diagnosed, not for asserting on.

scope() ​

php
static scope(callable $callback, bool $strictStubs = false): mixed

Runs a callback in a nested context of its own.

On success the callback's expectations are verified; the context is then dropped either way. A failure inside the callback is never replaced by a teardown error — the original is what the reader needs.

Verified is the context this call opened, and only it: the enclosing context is still running and its claims are none of a nested scope's business. A scope is a local lifetime — the caller reaches for it to end a few doubles early, often while its own expectations are deliberately unfinished — so answering for the whole test here would fail a self-contained scope for something the test has not got to yet. The test as a whole is answered for by verifyAll(), checkpoint() and the runner adapter's teardown, which are the calls that mean the test is over. A Fiber started inside the callback keeps a context of its own that outlives the scope, so it stays for those to check.

checkpoint() ​

php
static checkpoint(bool $strictStubs = false): void

Verifies every context the test put understudies in and clears what has been settled — the claims a matching expect() or a successful verify() accounted for — keeping the understudies, their modes and their labels, for a long test that runs in phases. A call covered by a when() stub alone stays in the log, because nothingElse() still reads it; reset(), scope() and lean() are the ways to let go of everything.

reset() ​

php
static reset(): void

Drops every context this test put understudies in — the caller's and any a Fiber owns. Adapters call it after each test, unconditionally.

Wider than isolation on purpose. A Fiber keeps its own recording phase, call log and sequence counter so that concurrent bodies never collide; but teardown is about the test, and a context the adapter cannot see is a context whose doubles answer the next one.

idle() ​

php
static idle(): bool

Whether the test holds no understudies at all, in any context it used.

Runner adapters use it as an integration guard: a context that is not idle by the time the next test begins means some earlier test's cleanup never ran, and its doubles are about to leak into this one.