PHPackages                             rasuvaeff/understudy - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Testing &amp; Quality](/categories/testing)
4. /
5. rasuvaeff/understudy

ActiveLibrary[Testing &amp; Quality](/categories/testing)

rasuvaeff/understudy
====================

Test double library for PHP with a call-closure API

v0.1.1(today)0149↑2638.3%[4 issues](https://github.com/rasuvaeff/understudy/issues)4BSD-3-ClausePHPPHP 8.3 - 8.5CI passing

Since Aug 25Pushed todayCompare

[ Source](https://github.com/rasuvaeff/understudy)[ Packagist](https://packagist.org/packages/rasuvaeff/understudy)[ Docs](https://github.com/rasuvaeff/understudy)[ RSS](/packages/rasuvaeff-understudy/feed)WikiDiscussions master Synced today

READMEChangelog (3)Dependencies (11)Versions (3)Used By (4)

rasuvaeff/understudy
====================

[](#rasuvaeffunderstudy)

[![Latest Stable Version](https://camo.githubusercontent.com/8314e2f8e9ea901a2c917ad7fea99cd8da36081432202b322b923eb2ba8fa36a/68747470733a2f2f706f7365722e707567782e6f72672f7261737576616566662f756e64657273747564792f76)](https://packagist.org/packages/rasuvaeff/understudy)[![Total Downloads](https://camo.githubusercontent.com/65566371bcf5f997a3a222c66891dd667cd983f0f74c85a1cd8d8a8cc43789de/68747470733a2f2f706f7365722e707567782e6f72672f7261737576616566662f756e64657273747564792f646f776e6c6f616473)](https://packagist.org/packages/rasuvaeff/understudy)[![Build](https://github.com/rasuvaeff/understudy/actions/workflows/build.yml/badge.svg)](https://github.com/rasuvaeff/understudy/actions/workflows/build.yml)[![Static analysis](https://github.com/rasuvaeff/understudy/actions/workflows/static-analysis.yml/badge.svg)](https://github.com/rasuvaeff/understudy/actions/workflows/static-analysis.yml)[![Psalm level](https://camo.githubusercontent.com/68f7f31799f2b93c710b14ba3877072e7fe07ec9d7cee3fdf67e14beab3e1b6f/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f7073616c6d2d6c6576656c5f312d626c75652e737667)](https://github.com/rasuvaeff/understudy/actions/workflows/static-analysis.yml)[![PHP](https://camo.githubusercontent.com/523c5e1bf025a9da7d75ffff385542c3e8e844e228d6afc3db5d3386c9fad5a7/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f646570656e64656e63792d762f7261737576616566662f756e64657273747564792f706870)](https://packagist.org/packages/rasuvaeff/understudy)[![License](https://camo.githubusercontent.com/6cb285b57819f8de0acfb34923298f4f569f962544e8fe35331da2d163f4e485/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e73652d4253442d2d332d2d436c617573652d626c75652e737667)](LICENSE.md)[Русская версия](README.ru.md)

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](llms.txt) is a compact API reference written for it.

Why another one
---------------

[](#why-another-one)

UnderstudyMockery / PHPUnit / doubleSpecifying a calla real call in a closurea method-name stringMembers added to the doublenone`shouldReceive`, `expects`, `allows`, …Test runnerany (thin adapters)tied to PHPUnit/Pest, or noneFibersone context per fibershared static stateThe call-closure form comes from [MockK](https://mockk.io) (Kotlin), [FakeItEasy](https://fakeiteasy.github.io) and [moq](https://github.com/moq/moq)(C#), and [mocktail](https://pub.dev/packages/mocktail) (Dart). No PHP library had it.

Migrating from Mockery
----------------------

[](#migrating-from-mockery)

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

MockeryUnderstudyNotes`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 itPerformance
-----------

[](#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](perf/README.md).

understudyMockeryProphecyPHPUnitbuild a double (1-method contract)**1.28µs**+359%+936%+255%¹build a double (8-method contract)**1.29µs**+354%+919%+254%¹stub: build, stub, one call, tear down8.74µs+30%+81%\*\*−7%\*\*¹mock: build, expect, call, verify10.8µs+19%+159%\*\*−10%\*\*²marginal cost of one call to a stub0.82µs1.64µs—³**0.71µs**¹added to process start (cold)**1.00×**1.30–1.53×3.62–4.43×3.86–4.94×⁴retained per live double**435–450 B**513 B~8.5 KB~1.25 KB¹ `createStub()` ² `createMock()` ³ too unstable to quote — Prophecy's per-call path allocates enough that garbage collection, not the call, dominates the measurement. ⁴ a ratio rather than milliseconds: cold start spans 29% between runs, so its absolute figures are not stable enough to quote.

Understudy builds doubles roughly three and a half times cheaper than the next fastest, and starts a process in a third of the added time. It does **not** win everywhere, and less than it used to: PHPUnit is now ahead on both stub and mock scenarios end to end — it dispatches a call in 0.71µs against understudy's 0.82µs and no longer pays enough at build time to make up for it. Per-double memory has also grown, from ~350 B, and the reason is in `perf/README.md`: the dispatch work that took per-call cost from 1.75µs to 0.82µs keeps a second list per double to do it.

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

Requirements
------------

[](#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
------------

[](#installation)

```
composer require --dev rasuvaeff/understudy
```

Usage
-----

[](#usage)

### Creating a double

[](#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 constructornever runs — the double is built without it, so no side effect of construction reaches your testpublic and protected methodsoverridden 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 closureprivate and static methodsuntouched — the target keeps them, because there is no instance state to interceptthe destructorreplaced with an empty one, so nothing is torn down that was never builtwritable public propertiesstart 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 itA `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

[](#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 mattersit works only for a class not yet read from disk; a class is read once per processThe process is changedthe class really is not final any more, so reflection in your test sees something production does not`final` methods staya final method cannot be overridden either way, so a class carrying one is still refusedPHAR and preloaded classestheir source arrives as `phar://`, or before any bootstrap ran, so it never passes through the `file://` wrapperThe opcode cache is not a way backhowever 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 onAnother source transformerif something else is already rewriting PHP source, understudy refuses rather than replacing it silently; a wrapper that leaves source alone composes and is acceptedWhen 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

[](#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.

MatcherMatches`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 onlyThe 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

[](#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

[](#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

[](#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?

[](#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.

### Phases, scopes and transcripts

[](#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

[](#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` before the test even runs.

### Retiring a replaced double

[](#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

[](#modes)

ModeUnmatched call answers withLoose (default)a type-safe default: `null`, `0`, `''`, `[]`, an empty generator …Strict (`Understudy::strict($double)`)an immediate failure naming the methodForwarding (`Understudy::forwarding($double, $real)`)whatever the real instance answers, recorded like any other callA 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

[](#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

[](#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 parameterWhat it getsa class or interfacea double, returned in `doubles` under the parameter namea nullable objecta double — `null` is something the test can ask for explicitlyan intersectionone double of both contractsa union of several object typesrefused: picking one would be a guessan object that cannot be doubled, with a defaultits own default, applied by PHPa scalar with a defaultthe declared default, and no doublea scalar without onerefused, naming the override to passa variadic tailleft empty; inventing entries would invent collaboratorsa by-reference parameterrefused — 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

[](#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

[](#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](https://nsubstitute.github.io). `Understudy::label($double, '…')`names a double when several of the same contract are in play.

### Cleaning up

[](#cleaning-up)

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

The [understudy-testo](https://github.com/rasuvaeff/understudy-testo) and [understudy-phpunit](https://github.com/rasuvaeff/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

[](#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
--------

[](#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 `WeakMap`s 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
--------

[](#examples)

Runnable scripts live in [examples/](examples/).

The understudy family
---------------------

[](#the-understudy-family)

PackageWhat it is**rasuvaeff/understudy** *(this package)*The engine: doubles, matchers, expectations, verification.[rasuvaeff/understudy-testo](https://github.com/rasuvaeff/understudy-testo)Testo adapter — verification and reset around every test.[rasuvaeff/understudy-phpunit](https://github.com/rasuvaeff/understudy-phpunit)PHPUnit and Pest adapter — the same, through a trait.[rasuvaeff/understudy-psalm](https://github.com/rasuvaeff/understudy-psalm)Psalm plugin — matcher-aware specifications and misuse diagnostics.[rasuvaeff/understudy-phpstan](https://github.com/rasuvaeff/understudy-phpstan)PHPStan extension — the same for PHPStan, plus its own rules.Development
-----------

[](#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
-------

[](#license)

BSD-3-Clause. See [LICENSE.md](LICENSE.md).

###  Health Score

44

—

FairBetter than 90% of packages

Maintenance100

Actively maintained with recent releases

Popularity15

Limited adoption so far

Community11

Small or concentrated contributor base

Maturity42

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~0 days

Total

2

Last Release

0d ago

### Community

Maintainers

![](https://www.gravatar.com/avatar/b0812d5572a7041dfe36e222d295b2e6dc55833a605350fcde58a51a5965ed30?d=identicon)[rasuvaeff](/maintainers/rasuvaeff)

---

Top Contributors

[![rasuvaeff](https://avatars.githubusercontent.com/u/1352718?v=4)](https://github.com/rasuvaeff "rasuvaeff (39 commits)")

---

Tags

fakemockmockingphpphp-libraryphp8spystubtest-doubletestingunit-testingtestingmockstubtest doublefakespyDouble

###  Code Quality

Static AnalysisPsalm, Rector

Code StylePHP CS Fixer

Type Coverage Yes

### Embed Badge

![Health badge](/badges/rasuvaeff-understudy/health.svg)

```
[![Health](https://phpackages.com/badges/rasuvaeff-understudy/health.svg)](https://phpackages.com/packages/rasuvaeff-understudy)
```

###  Alternatives

[phpspec/prophecy

Highly opinionated mocking framework for PHP 5.3+

8.5k563.0M820](/packages/phpspec-prophecy)[mockery/mockery

Mockery is a simple yet flexible PHP mock object framework

10.7k536.9M28.7k](/packages/mockery-mockery)[php-mock/php-mock

PHP-Mock can mock built-in PHP functions (e.g. time()). PHP-Mock relies on PHP's namespace fallback policy. No further extension is needed.

37020.4M140](/packages/php-mock-php-mock)[phake/phake

The Phake mock testing library

4858.3M356](/packages/phake-phake)[php-mock/php-mock-phpunit

Mock built-in PHP functions (e.g. time()) with PHPUnit. This package relies on PHP's namespace fallback policy. No further extension is needed.

1739.2M597](/packages/php-mock-php-mock-phpunit)[php-mock/php-mock-mockery

Mock built-in PHP functions (e.g. time()) with Mockery. This package relies on PHP's namespace fallback policy. No further extension is needed.

392.3M117](/packages/php-mock-php-mock-mockery)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
