News

Mock PHP Classes in Tests With the Double Library

Published
Mock PHP Classes in Tests With the Double Library image

Double is a PHP test double library by Jason McCreary, the creator of Laravel Shift. It replaces the usual choice between a mock, a spy, and a partial with a single kind of object: you create a double for a class or interface, and the verbs you use afterward decide how it behaves. Double requires PHP 8.3 and is at v0.4.0 at the time of writing.

Here's what the package covers:

  • One constructorDouble::for() takes classes, interfaces, several interfaces at once, or a real instance.
  • Three modes — loose (the default) returns type-safe values for unconfigured calls, strict throws on them, and passthru delegates to a real object.
  • Two setup verbsexpects() for a call that must happen, allows() for one that may.
  • Spy-style checks on every doublereceived() looks back at what happened, with no spy declared up front.
  • Argument matchersArgument::any(), type(), same(), matches(), contains(), capture(), not(), and remaining().
  • Failure messages that include the call log — an unmet expectation reports what the method was actually called with.
  • PHPUnit integration — failures report as failures instead of errors, passing checks count as assertions, and a trait verifies every double automatically.
  • A Mockery mapping — the docs include a method-by-method reference for converting an existing suite.

The Same Test, Written Both Ways

If you're coming from Mockery, here's the same test written both ways. First, in Mockery:

use Mockery;
 
$repository = Mockery::spy(BookRepository::class);
$repository->shouldReceive('find')->once()->with(123)->andReturn($book);
 
$service = new CatalogService($repository);
$service->lookup(123);
 
$repository->shouldHaveReceived('recordView')->with($book);
 
Mockery::close();

The same test in Double:

use JMac\Testing\Double;
 
$repository = Double::for(BookRepository::class);
$repository->expects('find')->with(123)->returns($book);
 
$service = new CatalogService($repository);
$service->lookup(123);
 
$repository->received('recordView')->with($book);

Four things change:

  • There's no mock() or spy() decision at the top, since received() works on any double.
  • once() is dropped, because that's already what expects() means.
  • shouldReceive() and andReturn() become expects() and returns(). Double keeps one verb per concept and no aliases.
  • Mockery::close() has no counterpart. Call $repository->verify(), or add the VerifiesDoubles trait and let it run for you.

Failure output differs too. If the code under test calls find('Baz') instead of find('baz'), Mockery reports:

Method find('baz') from Mockery_0_BookRepository should be called
exactly 1 times but called 0 times.

Double reports:

Double `foo` expected `find('baz')` to be called exactly 1 time, but it was never called.
 
The following calls to `find` were made during this test: `find('Baz')`

Double names the class you doubled instead of a generated Mockery_0_ identifier, and the second line lists what the method actually received.

Creating a Double and Picking a Mode

Double::for() returns a real object that satisfies instanceof and any type hint expecting the target:

use JMac\Testing\Double;
 
$repository = Double::for(BookRepository::class);
 
$service = new CatalogService($repository);

Pass more than one target and you get a single double implementing all of them. Everything after the first must be an interface, the same rule PHP applies to intersection types:

$logger = Double::for(LoggerInterface::class, FlushableInterface::class);

Every double has exactly one mode, which controls what happens when a call doesn't match anything you configured. Loose mode, the default, returns a type-safe value based on the method's declared return type: false for bool, 0 for int, [] for array, the first case of an enum, the double itself for self. For a non-nullable class or interface return type, it generates a fresh double of that type instead of returning null and failing on the return type a few lines later. Generation only happens one level deep. If the generated double is then asked to return something of its own, it stops and tells you to configure it explicitly.

Strict mode fails on the first unconfigured call. Passthru delegates unconfigured calls to a real instance, and still records every call:

$repository = Double::for(BookRepository::class)->strict();
 
$logger = Double::for(Logger::class)->passthru($realLogger);

Configuration lives on the double itself rather than a separate builder object, so seven method names are reserved: expects, allows, strict, passthru, received, unused, and verify. Doubling a class that declares one of them throws immediately and names the collision. This comes up in Laravel apps, since allows() is part of the Gate contract.

Expectations and Argument Matching

Configuring a double reads left to right, and every modifier has a default if you leave it off:

$repository->expects('find')->with(123)->returns($book);
$repository->allows('find')->with(999)->throws(new NotFoundException());
$repository->allows('calculateTax')->resolves(fn (...$args) => $gateway->calculateTax(...$args));

expects() means the call must happen exactly once unless you say otherwise. allows() means it may happen any number of times, including zero.

Call counts work differently from Mockery, which has a separate word for each shape and stacks two calls for the open-ended ones:

$repository->shouldReceive('save')->once();
$repository->shouldReceive('save')->twice();
$repository->shouldReceive('save')->times(5);
$repository->shouldReceive('save')->between(1, 3);
$repository->shouldReceive('save')->atLeast()->times(2);
$repository->shouldReceive('save')->atMost()->times(5);
$repository->shouldReceive('save')->never();

Double routes all of them through one verb, using named arguments for the open-ended cases:

$repository->expects('save'); // once, the default
$repository->expects('save')->times(2); // twice
$repository->expects('save')->times(5); // exactly 5
$repository->expects('save')->times(1, 3); // between 1 and 3
$repository->expects('save')->times(minimum: 2); // at least 2
$repository->allows('save')->times(maximum: 5); // at most 5
$repository->allows('save')->never(); // shorthand for times(0)

never() remains its own method since it reads better than times(0). Everything else is the same verb with different arguments, so there's no atLeast() or atMost() prefix and no second call to chain. The same counts work on received() after the fact:

$repository->received('save')->with($book)->times(2);
$repository->received('delete')->never();

Passing several values to returns() builds a queue, holding at the last value once the list runs out. When two expectations could match the same call, the most recently registered one wins, so you write the broad default first and the specific overrides after it.

A plain value in with() compares scalars and arrays with === and objects with ==. Anything looser goes through the Argument facade:

use JMac\Testing\Matching\Argument;
 
$repository->allows('save')->with(Argument::type(Book::class))->returns(true);
$repository->allows('find')->with(Argument::any(1, 2, 3))->returns($book);
$repository->allows('find')->with(Argument::matches('/^\d+$/'))->returns($book);
$repository->allows('saveAll')->with(Argument::contains($book))->returns(true);
$repository->allows('combine')->with('-', Argument::remaining())->returns('a-b-c');

Argument::same() checks identity where a plain object only checks equivalence. Argument::capture($var) matches anything and writes the real argument into a variable for further assertions. Argument::not() with no argument returns an object with its own verbs, including type(), same(), contains(), matches(), and any(), so a negation reads left to right instead of nesting.

Where call order is part of the contract, mark the relevant expectations ordered(). A call arriving early throws immediately and names both methods:

$connection->expects('open')->ordered();
$connection->expects('write')->ordered();
$connection->expects('close')->ordered();

Ordering is checked per double. Configuring a static method is rejected up front with a message explaining why, since there's no instance for a static call to run through.

Verification

verify() checks every expects() you registered, and is the equivalent of Mockery::close(). received() goes the other direction, checking after the fact, and it's available on every double regardless of how it was created. A received() chain runs its check once the statement finishes, since it can't know whether you're about to chain ->with() or ->never() onto it.

There's also unused(), which asserts a double received zero calls to any method and names every call it did see:

Double `Logger` expected no calls at all, but received: `info('something happened')`.

Mockery's shouldNotHaveBeenCalled() sounds like the same check, but it only tests whether the mock itself was invoked as a callable, not whether any of its methods were called.

Failure Messages

Every failure message names the double, names the call, and points at a next step. A typo'd method name is caught when you configure it, not at the end of the test:

Can't configure `sav` on a double for `BookRepository`. That method
does not exist. Did you mean `save`?

The suggestion only appears when something is genuinely close. In strict mode, an unconfigured call suggests the allows() line that would fix it:

Double `foo` received an unexpected call to `bar(1, 2)`. Strict mode
requires every call to be configured. For example:
`$foo->allows('bar')->returns(...)`.

The variable name there is a guess derived from the double's label, so the example is a starting point rather than something to paste. If bar was already called somewhere else in the test, the suggestion is replaced by the actual call log. Doubles generated automatically by loose mode also say so in their own failure messages, so you can tell where an object you never created came from.

PHPUnit Integration

Nothing in the library requires PHPUnit, but three things change when it's installed. Failures extend PHPUnit's AssertionFailedError, so an unmet expectation is reported as a failure rather than an error, while setup mistakes stay errors. Passing verifications register a real assertion, so a test whose only check is received() isn't flagged as risky. And a trait removes the manual verify() call:

use JMac\Testing\Integrations\PHPUnit\VerifiesDoubles;
 
class TestCase extends \PHPUnit\Framework\TestCase
{
use VerifiesDoubles;
}

Every double created during a test, and every received() assertion made on one, is checked when the test finishes. All of this is detected at runtime with nothing to configure, and it works with PHPUnit 11 and 12.

Converting an Existing Suite

The docs include a method-by-method mapping. The most common lines:

Mockery Double
Mockery::mock(Foo::class) Double::for(Foo::class)
Mockery::spy(Foo::class) Double::for(Foo::class), then received()
shouldIgnoreMissing() the default
shouldDeferMissing() passthru($realInstance)
shouldReceive('foo')->once()->andReturn($x) expects('foo')->returns($x)
shouldReceive('foo')->andReturn($x) allows('foo')->returns($x)
andThrow($e) / andReturnUsing($fn) throws($e) / resolves($fn)
shouldHaveReceived('foo') received('foo')
shouldNotHaveReceived('foo') received('foo')->never()
shouldNotHaveBeenCalled() unused()
once() / twice() / between($a, $b) times(1) / times(2) / times($a, $b)
atLeast()->times($n) / atMost()->times($n) times(minimum: $n) / times(maximum: $n)
Mockery::close() verify(), or the VerifiesDoubles trait

The matcher table follows the same shape: Mockery::any() to Argument::any(), Mockery::on() to Argument::satisfies(), Mockery::isSame() to Argument::same(), and andAnyOtherArgs() to Argument::remaining(). A few Mockery features are deliberately absent, including aliases, ducktype(), static method mocking, globally() ordering across mocks, and byDefault(). Each one has a note in the docs on what to write instead. There's also a free Double Converter that automates the conversion from Mockery to Double.

Installation

Install the package as a dev dependency:

composer require --dev jasonmccreary/double

There's no service provider and no configuration file, so it works in any suite built on PHPUnit or Pest, inside Laravel or not. To double a final class, call Double::bypassFinals() as the first line of your PHPUnit bootstrap file, ahead of the autoloader. It rewrites final class out of the source before PHP compiles it, so a class already loaded elsewhere in the process stays rejected.

The full documentation is at testdoublephp.com, and you can find Double on GitHub.

Paul Redmond photo

Staff writer at Laravel News. Full stack web developer and author.

Sponsored

laravelcloud logo
Laravel Cloud

Easily create and manage your servers and deploy your Laravel applications in seconds.

Visit Laravel Cloud

The latest

View all →
Laravel Discount: Coupon Codes, Usage Limits, and Stacking image

Laravel Discount: Coupon Codes, Usage Limits, and Stacking

Read article
Laravel Truss: Live Interactive Database ER Diagrams image

Laravel Truss: Live Interactive Database ER Diagrams

Read article
Laravel artisan dev: Run Server, Queue, Logs, and Vite image

Laravel artisan dev: Run Server, Queue, Logs, and Vite

Read article
Validate and Convert HEIC Images in Laravel image

Validate and Convert HEIC Images in Laravel

Read article
Saga Lara Flow: Durable Workflows and Compensating Transactions on Laravel Queues image

Saga Lara Flow: Durable Workflows and Compensating Transactions on Laravel Queues

Read article
Reject Unexpected Array Keys with Laravel Validation image

Reject Unexpected Array Keys with Laravel Validation

Read article