Understudy
Rasuvaeff\Understudy\Understudy
Class — Package: rasuvaeff/understudy — Source — Version: v0.4.1
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()
static for(class-string<\T>|\T $target, class-string $interfaces): objectCreates 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()
static when(callable $call): WhenBuilderStubs a call: allowed any number of times, including none.
expect()
static expect(callable $call): ExpectBuilderDeclares a call the code under test is expected to make: exactly once unless times() says otherwise, and checked by verifyAll().
verifyAll()
static verifyAll(bool $strictStubs = false): voidChecks 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?".
verify()
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,
): voidAsserts, after the fact, how many times a call was made.
$times— exact count; defaults to at least one
calls()
static calls(callable $call): arrayEvery recorded call matching the specification, in order.
lastCall()
static lastCall(callable $call): ?InvocationThe 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()
static strict(object $double): voidMakes an understudy fail on any call no expectation matched.
lean()
static lean(object $double): voidStops 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()
static forwarding(object $double, ?object $real = NULL): voidDelegates unmatched calls to a real instance, recording each one.
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.
Only the call at the boundary is recorded. A real method that calls another method on itself does so inside the real object; understudy proxies an object, it does not instrument one.
delegate()
static delegate(class-string<\T> $contract, \T $real): objectBuilds 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:
$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()
static wire(class-string $sut, array<string,mixed> $overrides = []): arrayBuilds a real subject with an understudy for every constructor dependency, and hands back both.
['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()
static bypassFinals(class-string|null $class = NULL): voidLifts 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:
Exception\BypassUnavailable— when the class is already loaded, is not a class, orfile://belongs to somebody else
Understudy::bypassFinals(FinalGate::class); // one class
Understudy::bypassFinals(); // every class, from bootstrapOpt-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()
static defaults(class-string $contract, callable $factory): voidRegisters 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()
static label(object $double, non-empty-string $label): voidNames one understudy in failure messages, which is what makes two doubles of the same contract tellable apart.
unused()
static unused(object $double): voidAsserts that nothing was called on this understudy at all.
forget()
static forget(object $double): voidRetires 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()
static nothingElse(object $double, object $more): voidAsserts 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()
static allVerified(object $double): voidAsserts this understudy's expectations are satisfied and that nothing else happened to it — the two halves of "I have described everything".
expectSequence()
static expectSequence(callable $calls): voidArms 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.
Understudy::expectSequence(
fn () => $repo->begin(),
fn () => $repo->save($book),
fn () => $repo->commit(),
);
$service->handle($command); // fails here, on the call that broke itTotality 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()
static verifySequence(callable $calls): voidAsserts that these calls are exactly what happened in this context, in this order, across every understudy — no more, no fewer.
transcript()
static transcript(object $double): stringEvery call this understudy received, with its arguments and outcome — for reading while a failure is being diagnosed, not for asserting on.
scope()
static scope(callable $callback, bool $strictStubs = false): mixedRuns 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.
checkpoint()
static checkpoint(bool $strictStubs = false): voidVerifies the current context and clears what has been settled, keeping the understudies themselves — for a long test that runs in phases.
reset()
static reset(): voidDrops 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()
static idle(): boolWhether 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.