Test doubles in Testo

SkillDev tools

Isolate a collaborator in a Testo test with a test double, pick the right kind (dummy, stub, spy, mock, fake, partial) and build it with Double (testo/bridge-double), Mockery (testo/bridge-mockery), or a hand-written class, and place any fixture class a test declares. Use when the user says "mock", "stub", "spy", "fake", "test double", "in-memory repository", "isolate the dependency", "createMock", "partial mock", "verify it was called", "should receive"; when a test needs a collaborator that touches I/O, time, randomness, or a third-party service; or when a test declares a helper class of its own and it needs a home.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Test doubles in Testo skill

What this skill tells your AI

The instructions your AI receives, as published by php-testo/testo in skills/testo-test-doubles/SKILL.md and read by ahel’s review.

Testo core ships no doubling facility. A test isolates a collaborator through one of three routes, each with its own reference next to this file:

RouteReferenceReach for it when
Hand-written fake/stub/spy classreferences/handwritten.mdThe default, library installed or not: a real class under tests/ reads without a DSL, is reused across tests, survives a library swap, and holds state.
Double (testo/bridge-double)references/double.mdA library earns its place: a wide third-party interface, call order as the contract, or the surrounding tests already double this way. Choosing fresh on PHP 8.3+.
Mockery (testo/bridge-mockery)references/mockery.mdThe same cases, when the project already uses Mockery or must stay on PHP 8.2.

Whichever route you take, every class the test itself declares gets a file and a directory: references/placement.md. That reference answers the fixture case too, which is where a test lands when no double is warranted at all.

Run every command from the project root.

Vocabulary

A test double is any stand-in for a real collaborator. Mocks are one kind, not a synonym. The ladder, from least to most knowledge about the interaction:

KindDoesVerifies interaction?Reach for it when
DummyFills a parameter, is never calledNoThe SUT needs an instance and nothing else
StubReturns canned answersNo — you assert on the SUT's resultYou only need to feed a value in
SpyStub that records calls; you inspect them after the actYes, post hoc"Was it called, with what?" matters and the test should read Arrange → Act → Assert
MockExpectations declared before the act, checked at teardownYes, up frontCall count or order is the contract
FakeA working simplified implementation (in-memory repository, fixed clock)No — behaves for realThe collaborator is stateful or used by many tests

Fixture — the category that is not on this ladder. A fixture is a class the test declares as material rather than as a stand-in: a synthetic value object it feeds the SUT, a target class a data provider drives, an interface written to be doubled, a subclass of the SUT that exists to be recognised. It replaces nothing, so no rung fits it, and it is placed rather than built — references/placement.md. Misreading a fixture as "just a helper" is what leaves it stranded at the foot of a test file.

Orthogonal axes, independent of the ladder:

  • Loose vs strict — a loose double answers unconfigured calls with a safe default; a strict one throws on them. Default loose; go strict when an unexpected call is itself the bug you are hunting.
  • Full vs partial — a partial replaces some methods and runs the real code for the rest. Rare and a smell: it usually means the class under test is doing two jobs.
  • Verification point — both bridges verify at teardown for every test and turn an unmet expectation into Status::Failed (not Aborted). There is no Mockery::close() / verify() boilerplate to write.

Step 1 — Pick the kind

Choose the lowest rung that expresses the contract:

  1. Real object first. Value objects, DTOs, enums, final classes, pure functions: instantiate them. Never double these. A real object the test had to invent is a fixture: references/placement.md gives it a file.
  2. Stub when the test asserts on what the SUT returns or does with the value.
  3. Spy when the test asserts on how the collaborator was used. Prefer it over a mock: the check sits in the Assert phase where the reader expects it.
  4. Mock only when the number or order of calls is the behaviour under test (save called exactly once, open before write).
  5. Fake when the collaborator is stateful, a port you own, or when three or more tests would otherwise configure the same stub. Reference: references/handwritten.md.

Double only collaborators, never the system under test. A test that doubles the class it is testing tests the double. A subclass of the SUT that exists only to be observed is a fixture, not a double.

Step 2 — Pick the tool

Run the pre-flight. It reads only composer.json, vendor/ and testo.php, so it needs no confirmation:

php <skillDir>/scripts/precheck.php          # add --root=PATH when not at the project root

<skillDir> is this skill's own directory (the folder holding SKILL.md). It prints, per library, whether the library and its Testo bridge are installed and whether the plugin is registered in testo.php, then a verdict:

Whatever it prints, a hand-written class (references/handwritten.md) stays the first choice; the verdict says which library is available for the cases that earn one:

  • DOUBLE: READYreferences/double.md for those cases.
  • MOCKERY: READYreferences/mockery.md for those cases.
  • Both READY → the one the surrounding tests already use; for a fresh file prefer Double.
  • Neither → offer to install a bridge only when the user asks for a mocking library or the test would need three or more behaviour-verifying doubles; the install steps live in the matching reference. A project stays on one library — never add a second.

A library installed without its bridge plugin registered is the trap the pre-flight exists for: expectations then silently go unverified and a mock-only test comes out Status::Risky. Fix registration before writing the test (steps in the reference).

Rules shared by every route

  • Type the variable as an intersection so static analysis sees both the real contract and the double's verbs: /** @var DoubleInterface&Repository $repo */ (Double) or /** @var MockInterface&Repository $repo */ (Mockery).
  • A double-only test still counts as asserting. Both bridges mirror verified expectations into the Assert history, so a test whose only checks are expects() / shouldHaveReceived() passes, not Risky. A hand-written spy has no such hook: assert on its recorded calls with Assert::*.
  • Argument matching is strict. Double compares scalars with ===; Mockery's with() is loose (==) but its object matching is by identity. When porting between the two, re-check every with().
  • No static / magic-method doubling. Neither library doubles static methods; Double allows only __invoke, __toString, __serialize, __unserialize, __clone. Wrap the static call in an instance you can double.
  • Fibers are safe. Both bridges park their process-global state on every suspension, so doubles work under #[RunInFiber] / #[RunInRevolt] (see testo-async).
  • Migrating from PHPUnit? createMock() chains convert to Double mechanically via testo/bridge-rector; the rest of the flow is in testo-migrate-from-phpunit.

Related skills

  • testo-write-tests#[Test], Assert, Expect, lifecycle hooks the double sits inside.
  • testo-configure — where plugins: live in testo.php when registering a bridge.
  • testo-async — fiber-driven tests that hold doubles across suspensions.
  • testo-migrate-from-phpunit — Rector-assisted createMock()Double::for() conversion.

Signals

GitHub stars
215
Forks
18
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
testo-test-doubles
Source
github.com/php-testo/testo