Skip to content

Understudy

Rasuvaeff\Understudy\Understudy

ClassPackage: rasuvaeff/understudySourceVersion: 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()

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): void

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

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,
): void

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

  • $times — exact count; defaults to at least one

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(object $double): void

Makes an understudy fail on any call no expectation matched.

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 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()

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(object $double, non-empty-string $label): void

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

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.

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.

checkpoint()

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

Verifies the current context and clears what has been settled, keeping the understudies themselves — for a long test that runs in phases.

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.