Skip to content

Stubbing ​

A stub says what a call answers. It is permission, not a claim: a stub that goes unused does not fail a test unless you ask for strict stubs.

php
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]),
);
VerbAnswers with
returns($value, …)the value; with several, one per call, and the last one repeats
throws($exception)the exception, thrown at the call site
answers(fn (Invocation $call) => …)whatever the callback computes from the actual call
throwsWith(fn (Invocation $call) => …)an exception built from the call, one per call
php
// One value per call, then the last one repeats.
when(fn () => $repository->mode())->returns('fast', 'slow');

For a different answer per call in a longer sequence, see Chaining behaviour.

throws() takes an instance, which cannot know what the call carried. throwsWith() is for the exception that has to:

php
when(fn () => $publisher->publish(Arg::any()))
    ->throwsWith(fn (Invocation $call) => new PublishException(
        message: 'Publish failed',
        outboxMessage: $call->arg('message'),
    ));

Invocation::arg() reads one argument by position or by the contract's own parameter name.

Which stub wins ​

Two tiers, whichever order they were declared in: a specification that pins at least one argument to something narrower than "anything" — a literal, any matcher except Arg::any(), or a tail pinned empty by Arg::none() — is tried before one that says nothing about any argument. A catch-all registered after a specific stub therefore steals nothing: the specific one answers its own calls, and the catch-all takes the rest. Within a tier, a later stub for the same call wins; earlier ones stay reachable as fallbacks for calls whose arguments the later one does not match.

Two specifics are never compared against each other for narrowness — registration order decides between them.

Two consequences worth holding on to:

  • An exhausted call-count expectation keeps answering the matching call. When a broad fallback should take over later calls, give it a non-overlapping matcher rather than relying on exhaustion.
  • Overlap is fine and documented; equality is not. A when() and an expect() naming the exact same call are refused at registration — see Getting started.

An expectation needs no answer ​

Counting and answering are separate concerns. expect() without returns() is complete: the mode's type-safe default supplies the value, and a matched expectation satisfies even a strict double, because the call was expected.

What belongs in a specification closure ​

Exactly one call, on a double, with the arguments you mean.

php
when(fn () => $repository->find(123))->returns($book);   // yes
when(fn () => $repository->find(123)->title)->…          // no: reads a property off the result

The closure is a real call — see Concepts. The arguments are evaluated in the ordinary way, which is what makes matchers typed and what makes a matcher built but never passed to a specification a reported error rather than a silent no-op.

Next ​