Skip to content

Introducing Double

Main Thread 5 minutes published

I've been writing PHP for 25 years. When I've needed a "mock," I've reached for Mockery. It's been the de facto PHP mocking library for over a decade. But... but, I've always had gripes.

Over the years, I've tried to address these by contributing to Mockery. I've submitted Mockery::capture() and other patches. It's a slow process. The library internals take time to understand. Getting a PR merged takes time. That might be expected of a widely-adopted open-source project.

Either way, I just kind of lived with my gripes. I suspect we all have. But occasionally I'd daydream of a better experience. I just never had the capacity to build it. At least until AI. AI has increased my capacity. So I took my shot at building a new PHP mocking library, from the ground up. It's called Double.

Before continuing, let me state: I mean no disrespect to Mockery. As I said, it has been the de facto mocking library for PHP. I respect it for that. Double stands on its shoulders — using both what it did well, and what it didn't.

Anyway, things started as a massive architectural conversation with Claude. Followed by a few weeks of livestreaming the development and testing. Then the official release. Since you may watch the development, in this article, I want to focus on what came out of that architectural discussion.

The goals

Double is a modern PHP test-double library that puts developer experience first. It stands on the shoulders of Mockery and RSpec, so there isn't much new to learn — you just get a smoother experience using it. It's built from the ground up around four goals.

Now, I believe code speaks louder than words. So before getting into each goal, let's look at a code snippet using 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);

This reads like a sentence: it creates a double for BookRepository. That double expects find to be called with 123, and returns $book. The double also received a call to recordView with $book. It's easy and familiar. No new methods to learn. No testing terms. No fluff. Just a streamlined API to write the test you need.

Lower barrier to entry

Testing already has a barrier to entry. I don't want Double to add to it. I want you to create a double and start testing. No upfront decision between a mock, spy, or partial. Double provides a sensible, type-safe double by default. You may reach for more advanced modes if needed. Otherwise, Double figures it out for you.

I chose the name Double to align with this goal. A Test Double is a generic term for any case where you replace a production object for testing purposes.

Failures for humans

This was the main motivation. Honestly, I would have never built Double had Mockery had better failure messages. Again, not to gripe too much, but technically Mockery doesn't even have "failures." It throws exceptions. While there may be an argument for using an exception, combined with the overly technical message, the developer experience for a Mockery "failure" was painful.

Double's messages name the double, name the call, and, whenever possible, suggest what to do about it. Here's a real failure message:

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

One clean API

Double is a handful of methods you already know, and keeps one verb per concept: expects(), allows(), returns(). If the grammar feels familiar, that's on purpose. It deliberately borrows from the testing tools that came before it.

Double also has a single API. No aliases. Whether Mockery intended to or not, it has multiple ways to do the same thing. Some see that as flexibility. I see it as ambiguity. Especially in a small library. It's possible this ambiguity has led to some of the nuances of Mockery. For example: the shouldReceive without a quantifier, and the common misuse of methods like shouldNotHaveBeenCalled().

Ready for contribution

Finally, I want Double to be easy to contribute to. The codebase is small, well-documented, and AI-ready. Adding an argument matcher or improving a failure message doesn't require reverse-engineering a dense codebase. Any PHP developer should be able to contribute to Double. Especially assisted by AI.

Where this is headed

Double is still v0. I believe it's already beyond feature parity with Mockery. But I want to keep pushing on the developer experience. I want more adoption by more test suites before tagging 1.0.

I've already converted my own test suites over, and built a free Double Converter to automate migrating your test suite from Mockery to Double, too.

So, try it out. Tell me what you think!

End of article
Scroll to top