Mock PHP Classes in Tests With the Double Library
Composer Pacakge PHP #testing #mocking #phpunit #php #laravel #packages

Mock PHP Classes in Tests With the Double Library

4 min read Mohamed Said Mohamed Said

What Is the Double Library?

Double is a PHP 8.3 test double library by Jason McCreary, the creator of Laravel Shift. Instead of choosing between a mock, a spy, and a partial at creation time, you create one kind of object — a double — and the verbs you use afterward determine how it behaves. The package is at v0.4.0 and works with PHPUnit 11 and 12 as well as Pest.

Install it as a dev dependency:

composer require --dev jasonmccreary/double

No service provider or configuration file is needed.

Creating a Double

Double::for() accepts a class name, an interface, multiple interfaces, or a real instance:

use JMac\Testing\Double;

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

The returned object satisfies instanceof and any type hint expecting the target, so it drops straight into a constructor without casting.

Three Modes

| Mode | Behaviour on unconfigured calls | |---|---| | Loose (default) | Returns a type-safe value (false, 0, [], a fresh double, etc.) | | Strict | Throws immediately | | Passthru | Delegates to a real instance and still records every call |

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

Expectations and Argument Matching

Two verbs cover all setup: expects() (must be called) and allows() (may be called).

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

Call counts use a single times() method with named arguments instead of Mockery's chained helpers:

$repository->expects('save');                    // exactly once
$repository->expects('save')->times(2);          // exactly twice
$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();            // zero calls

The Argument facade handles looser matching:

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('saveAll')->with(Argument::contains($book))->returns(true);

Argument::capture($var) writes the real argument into a variable for further assertions. Argument::not() negates any matcher without nesting.

Verification

Call verify() at the end of a test, or add the VerifiesDoubles trait to your base test case and let it run automatically:

use JMac\Testing\Integrations\PHPUnit\VerifiesDoubles;

class TestCase extends \PHPUnit\Framework\TestCase
{
    use VerifiesDoubles;
}

received() checks after the fact on any double — no spy declaration required:

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

unused() asserts a double received zero calls to any method and lists every call it actually saw.

Failure Messages

Double names the class you doubled (not a generated identifier), and on an unmet expectation it lists what the method was actually called with:

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')`

A typo in a method name is caught at configuration time, not at the end of the test, and a "did you mean" suggestion is included when something close exists.

Migrating From Mockery

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

  • Mockery::mock(Foo::class)Double::for(Foo::class)
  • shouldReceive('foo')->once()->andReturn($x)expects('foo')->returns($x)
  • shouldHaveReceived('foo')received('foo')
  • Mockery::close()verify() or the VerifiesDoubles trait

A free Double Converter from Laravel Shift automates the migration.

Key Takeaways

  • One constructor (Double::for()) replaces mock, spy, and partial decisions.
  • expects() and allows() are the only setup verbs; times() with named arguments handles all count shapes.
  • Loose mode returns type-safe defaults so tests don't break on unconfigured calls.
  • received() provides spy-style post-hoc assertions on every double.
  • PHPUnit integration turns failures into proper assertion failures and the VerifiesDoubles trait removes manual verify() calls.
  • Requires PHP 8.3; works inside or outside Laravel with PHPUnit 11/12 or Pest.

Full documentation is at testdoublephp.com.


Source: Mock PHP Classes in Tests With the Double Library — Laravel News

Found this useful?

Frequently Asked Questions

3 questions
Q01 How does Double differ from Mockery in PHP tests?
Double uses a single `Double::for()` constructor instead of separate mock/spy/partial factories. Setup uses only two verbs — `expects()` and `allows()` — and spy-style `received()` checks are available on every double without declaring a spy upfront. There is no `Mockery::close()` equivalent; you call `verify()` or use the `VerifiesDoubles` trait instead.
Q02 What PHP version does the Double library require?
Double requires PHP 8.3 or higher. It works with PHPUnit 11 and 12 as well as Pest, inside or outside a Laravel application.
Q03 Can I migrate an existing Mockery test suite to Double automatically?
Yes. The Double documentation includes a full method-by-method mapping from Mockery to Double, and Laravel Shift provides a free Double Converter tool that automates the conversion.

Continue reading

More Articles

View all