Skip to main content

🎭 Test Doubles and Dependency Injection

Real classes talk to databases, payment gateways, and email servers β€” none of which you want to touch inside a unit test. Test doubles stand in for those collaborators so you can test one unit in isolation, and dependency injection is the design that makes swapping them in possible. Together they are the foundation of fast, reliable PHP tests.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Distinguish the five kinds of test double β€” dummy, stub, spy, mock, fake β€” and pick the right one
  • Apply dependency injection (constructor, setter, method) to make a class testable
  • Create stubs and mocks with PHPUnit's built-in tools using modern APIs
  • Use Mockery for expressive spies, partial mocks, and argument matching
  • Recognise mocking anti-patterns and test behaviour rather than implementation

Estimated Time: 50–65 minutes  β€’  Difficulty: Intermediate–Advanced

Hands-on: Test an OrderService that depends on a repository, a payment gateway, and a mailer.

In This Lesson

What Are Test Doubles?

A test double is an object that stands in for a real dependency during a test. The name comes from a "stunt double" in film: it looks enough like the real actor to play a specific scene, without the real one having to take the risk.

πŸ’‘ Why bother? Suppose UserService depends on a UserRepository that hits a database. If you test UserService with the real repository, a failing test could mean a bug in the service or a bad database connection, slow I/O, or missing data. Swap in a double and any failure can only be the service β€” that's isolation.

Doubles buy you four things: isolation (test one unit at a time), control (force any scenario, even rare ones like "the payment gateway timed out"), verification (assert a collaborator was called correctly), and speed (no network or disk).

The Five Types

Gerard Meszaros gave us a shared vocabulary. All five are "test doubles"; the differences are about how much they do and what you assert on them:

TypeRoleYou assert on it?
DummyPassed to satisfy a signature, never actually usedNo
StubReturns canned answers to method callsNo β€” on the result instead
SpyRecords how it was called, checked afterwardsYes β€” after the fact
MockPre-programmed with expectations; fails if unmetYes β€” expectations set up front
FakeA real but simplified implementation (e.g. in-memory DB)No β€” on behaviour

πŸ“– Stub vs Mock β€” the key distinction

A stub feeds state into your code ("when asked, return this user") and you assert on the output. A mock verifies interactions ("this method must be called exactly once, with these arguments"). Use a stub for "given X, does my code produce Y?"; use a mock for "does my code correctly tell its collaborator to do something?"

flowchart TD A{What do you
need to verify?} --> B[The return value
of my code] A --> C[That a collaborator
was called] B --> D[Use a STUB] C --> E[Use a MOCK / SPY] A --> F[Just fill a
parameter slot] F --> G[Use a DUMMY]

Dependency Injection

Doubles are only possible if a class lets you supply its dependencies from outside. That is dependency injection (DI). Contrast the two designs:

❌ Hard-wired dependency (untestable)

class UserService
{
    private UserRepository $users;

    public function __construct()
    {
        // Created internally β€” a test can never replace it
        $this->users = new UserRepository();
    }
}

βœ… Injected dependency (testable)

class UserService
{
    // Depend on an interface, not a concrete class
    public function __construct(
        private readonly UserRepositoryInterface $users
    ) {}

    public function name(int $id): string
    {
        return $this->users->findById($id)?->name() ?? 'Unknown';
    }
}

πŸ’‘ Depend on interfaces

Type-hinting an interface rather than a concrete class means the real repository and any test double can both satisfy it. This is the Dependency Inversion Principle: high-level code depends on abstractions, not details.

There are three ways to inject, from most to least common:

  • Constructor injection β€” required dependencies passed to __construct(). The default choice; the object is always valid once built.
  • Setter injection β€” optional dependencies set via setLogger(...) after construction.
  • Method injection β€” a dependency passed to the single method that needs it.
flowchart LR S[UserService] -->|depends on| I[UserRepositoryInterface] I -.implemented by.-> R[Real DB repository] I -.implemented by.-> D[Test double]

Doubles with PHPUnit

PHPUnit can generate doubles for any class or interface. createStub() makes a state double; createMock() makes one you can also set expectations on.

Creating a stub

#[Test]
public function it_returns_the_users_name(): void
{
    // A stub configured to return a fixed user
    $repo = $this->createStub(UserRepositoryInterface::class);
    $repo->method('findById')->willReturn(new User(1, 'John Doe'));

    $service = new UserService($repo);

    $this->assertSame('John Doe', $service->name(1));
}

Shaping stub responses

// A fixed value
$repo->method('findById')->willReturn($user);

// Different values on consecutive calls
$repo->method('findById')
     ->willReturnOnConsecutiveCalls($user1, $user2, null);

// Compute from arguments
$repo->method('findById')
     ->willReturnCallback(fn (int $id) =>
         $id === 1 ? new User(1, 'John') : null);

// Return an argument, or throw
$repo->method('save')->willReturnArgument(0);
$repo->method('findById')->willThrowException(new NotFoundException());

Creating a mock with expectations

#[Test]
public function deleting_a_user_sends_a_notification(): void
{
    $repo = $this->createStub(UserRepositoryInterface::class);
    $repo->method('findById')->willReturn(new User(1, 'John'));
    $repo->method('delete')->willReturn(true);

    $notifier = $this->createMock(NotifierInterface::class);
    $notifier->expects($this->once())         // must be called exactly once
             ->method('userDeleted')
             ->with($this->equalTo(1));        // with this argument

    $service = new UserService($repo, $notifier);
    $this->assertTrue($service->delete(1));
    // PHPUnit verifies the expectation automatically at test end
}

⚠️ withConsecutive() is gone

The old withConsecutive() for asserting a sequence of calls was removed in PHPUnit 10. Replace it with willReturnMap() (match by argument), willReturnCallback(), or a small counter/collector in a callback. Argument-matching is usually clearer than order-matching anyway.

// Modern replacement: match responses to arguments, not call order
$repo->method('findById')->willReturnMap([
    [101, $product1],
    [102, $product2],
]);

Mockery for Expressive Mocks

Mockery is a standalone library with a fluent syntax that many find more readable than PHPUnit's, plus first-class spies and partial mocks. It's what Laravel uses under the hood.

composer require --dev mockery/mockery
use Mockery;
use PHPUnit\Framework\TestCase;

class UserServiceTest extends TestCase
{
    protected function tearDown(): void
    {
        Mockery::close();   // verifies expectations & cleans up
        parent::tearDown();
    }

    public function test_it_fetches_a_user(): void
    {
        $repo = Mockery::mock(UserRepositoryInterface::class);
        $repo->shouldReceive('findById')
             ->once()
             ->with(1)
             ->andReturn(new User(1, 'John Doe'));

        $service = new UserService($repo);

        $this->assertSame('John Doe', $service->name(1));
    }
}

⚠️ Always call Mockery::close()

Mockery only verifies its expectations when you call Mockery::close(), so put it in tearDown(). Forget it, and a mock whose expectations were never met will silently pass β€” a false green.

Spies verify after the fact

// A spy: act first, assert afterwards β€” reads more naturally for some tests
$repo = Mockery::spy(UserRepositoryInterface::class);

$service = new UserService($repo);
$service->register('John', 'john@example.com');

$repo->shouldHaveReceived('save')->once();

Partial mocks and argument matchers

// Mock only one method; the rest run for real
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('isAdmin')->andReturn(true);

// Flexible argument matching
$mailer->shouldReceive('send')->with(
    'john@example.com',
    Mockery::type('string'),
    Mockery::pattern('/welcome/i')
);

Worked Example: OrderService

Let's tie it together. OrderService depends on three collaborators β€” a product repository, a payment gateway, and a mailer β€” all injected. We'll test the happy path and a failure path using stubs (for state) and mocks (for interactions).

<?php
class OrderService
{
    public function __construct(
        private readonly ProductRepositoryInterface $products,
        private readonly PaymentGatewayInterface $gateway,
        private readonly MailerInterface $mailer,
    ) {}

    public function placeOrder(int $userId, string $sku, int $qty): Order
    {
        $product = $this->products->findBySku($sku)
            ?? throw new \InvalidArgumentException("Unknown product: $sku");

        if ($product->stock() < $qty) {
            throw new \RuntimeException('Insufficient stock');
        }

        $total  = $product->price() * $qty;
        $result = $this->gateway->charge($userId, $total);

        if (! $result->succeeded()) {
            throw new \RuntimeException('Payment failed: ' . $result->message());
        }

        $order = new Order($userId, $sku, $qty, $total, $result->transactionId());
        $this->mailer->sendConfirmation($userId, $order);

        return $order;
    }
}

Happy path

#[Test]
public function it_charges_and_confirms_a_valid_order(): void
{
    // Stub: state the service reads
    $products = $this->createStub(ProductRepositoryInterface::class);
    $products->method('findBySku')->willReturn(new Product('BOOK', 20.0, stock: 5));

    // Stub the gateway to succeed
    $gateway = $this->createStub(PaymentGatewayInterface::class);
    $gateway->method('charge')
            ->willReturn(new PaymentResult(true, 'txn_123'));

    // Mock: verify the confirmation email is sent once
    $mailer = $this->createMock(MailerInterface::class);
    $mailer->expects($this->once())
           ->method('sendConfirmation')
           ->with(1, $this->isInstanceOf(Order::class));

    $service = new OrderService($products, $gateway, $mailer);
    $order   = $service->placeOrder(1, 'BOOK', 2);

    $this->assertSame(40.0, $order->total());
    $this->assertSame('txn_123', $order->transactionId());
}

Failure path: payment declined

#[Test]
public function it_does_not_email_when_payment_is_declined(): void
{
    $products = $this->createStub(ProductRepositoryInterface::class);
    $products->method('findBySku')->willReturn(new Product('BOOK', 20.0, stock: 5));

    $gateway = $this->createStub(PaymentGatewayInterface::class);
    $gateway->method('charge')
            ->willReturn(new PaymentResult(false, 'Card declined'));

    // The mailer must NEVER be called on a failed payment
    $mailer = $this->createMock(MailerInterface::class);
    $mailer->expects($this->never())->method('sendConfirmation');

    $service = new OrderService($products, $gateway, $mailer);

    $this->expectException(\RuntimeException::class);
    $this->expectExceptionMessage('Payment failed: Card declined');

    $service->placeOrder(1, 'BOOK', 2);
}

βœ… Notice the pattern

Data the service reads (product, payment result) came from stubs. The one interaction we truly care about (was the customer emailed?) is verified with a mock β€” expects($this->once()) for success, expects($this->never()) for failure. That's the right division of labour.

DI Containers

Injecting dependencies by hand is fine for a handful of classes, but a real app has hundreds. A dependency injection container knows how to build objects and their dependency graph for you. PHP-DI is a popular standalone choice:

use DI\ContainerBuilder;
use function DI\autowire;

$container = (new ContainerBuilder())
    ->addDefinitions([
        // Bind an interface to a concrete implementation
        UserRepositoryInterface::class => autowire(DbUserRepository::class),
        MailerInterface::class         => autowire(SmtpMailer::class),
    ])
    ->build();

// The container resolves the whole graph via constructor type-hints
$service = $container->get(UserService::class);

πŸ’‘ Containers in tests

In tests you rarely need the container β€” construct the class directly with doubles, which is clearer. When you do want a container (for a wider integration test), just rebind the interfaces to mocks. This "swap the binding" move is exactly how frameworks like Symfony and Laravel let you replace real services during testing.

Hands-on Exercise

πŸ‹οΈ Test a Refund Flow

Objective: Add tests for cancelOrder() on a service that depends on an order repository, a payment gateway, and a mailer. Practise choosing stubs vs mocks and verifying both "happens" and "must not happen" interactions.

Here is the method under test:

public function cancelOrder(int $orderId): bool
{
    $order = $this->orders->findById($orderId);
    if ($order === null || $order->status() !== 'paid') {
        return false;   // nothing to cancel
    }

    $refund = $this->gateway->refund($order->transactionId());
    if (! $refund->succeeded()) {
        throw new \RuntimeException('Refund failed');
    }

    $order->markCancelled();
    $this->orders->save($order);
    $this->mailer->sendCancellation($order->userId(), $order);

    return true;
}

Your task β€” write three tests:

  1. Success: a paid order is refunded, saved, the customer is emailed once, and the method returns true.
  2. Not cancellable: when the order is missing or not paid, the gateway and mailer are never called and the method returns false.
  3. Refund fails: when the gateway refund fails, a RuntimeException is thrown and no cancellation email is sent.
πŸ’‘ Hint

Stub findById to return the order state you want. Use expects($this->once()) on the mailer for test 1, and expects($this->never()) for tests 2 and 3. For test 2, stub findById to return null (or an order whose status() is 'pending').

βœ… Sample solution
<?php
namespace Tests\Unit;

use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;

class OrderCancellationTest extends TestCase
{
    #[Test]
    public function a_paid_order_is_refunded_and_emailed(): void
    {
        $order = new Order(userId: 7, status: 'paid', transactionId: 'txn_9');

        $orders = $this->createMock(OrderRepositoryInterface::class);
        $orders->method('findById')->willReturn($order);
        $orders->expects($this->once())->method('save')->with($order);

        $gateway = $this->createStub(PaymentGatewayInterface::class);
        $gateway->method('refund')->willReturn(new RefundResult(true));

        $mailer = $this->createMock(MailerInterface::class);
        $mailer->expects($this->once())
               ->method('sendCancellation')
               ->with(7, $order);

        $service = new OrderService($orders, $gateway, $mailer);

        $this->assertTrue($service->cancelOrder(1));
    }

    #[Test]
    public function an_unpaid_order_cannot_be_cancelled(): void
    {
        $orders = $this->createStub(OrderRepositoryInterface::class);
        $orders->method('findById')
               ->willReturn(new Order(userId: 7, status: 'pending', transactionId: ''));

        $gateway = $this->createMock(PaymentGatewayInterface::class);
        $gateway->expects($this->never())->method('refund');

        $mailer = $this->createMock(MailerInterface::class);
        $mailer->expects($this->never())->method('sendCancellation');

        $service = new OrderService($orders, $gateway, $mailer);

        $this->assertFalse($service->cancelOrder(1));
    }

    #[Test]
    public function a_failed_refund_throws_and_sends_no_email(): void
    {
        $order = new Order(userId: 7, status: 'paid', transactionId: 'txn_9');

        $orders = $this->createStub(OrderRepositoryInterface::class);
        $orders->method('findById')->willReturn($order);

        $gateway = $this->createStub(PaymentGatewayInterface::class);
        $gateway->method('refund')->willReturn(new RefundResult(false));

        $mailer = $this->createMock(MailerInterface::class);
        $mailer->expects($this->never())->method('sendCancellation');

        $service = new OrderService($orders, $gateway, $mailer);

        $this->expectException(\RuntimeException::class);
        $service->cancelOrder(1);
    }
}

Best Practices & Anti-Patterns

βœ… Do❌ Avoid
Mock interfaces you ownMocking the class under test itself
Use the simplest double that works (stub > mock)A mock where a stub would do
Use real value objectsMocking simple value objects like Money
Verify the few interactions that matterAsserting on every single call
Test observable behaviourTesting private implementation details

⚠️ Beware "mock-happy" tests

If a test mocks everything and asserts on every interaction, it often just restates the implementation in test form β€” it passes only while the code is written exactly one way, and breaks on any harmless refactor without catching a single real bug. When a class is painful to test because it has too many collaborators, that's usually a design smell: consider splitting it. Prefer fakes (like an in-memory repository) over long chains of stub setup where you can.

βœ… Don't mock what you don't own

Avoid mocking third-party classes directly (an SDK's HTTP client, say). Wrap them behind your own interface (an Adapter) and mock that. When the library changes its API, only the adapter breaks β€” not dozens of tests.

Summary & Quiz

πŸŽ‰ Key Takeaways

  • Test doubles come in five kinds β€” dummy, stub, spy, mock, fake β€” each with a distinct job.
  • Stubs feed state and you assert on output; mocks/spies verify interactions.
  • Dependency injection (constructor injection against interfaces) is what makes doubles possible.
  • PHPUnit's createStub/createMock and Mockery both build doubles; withConsecutive() is gone β€” use willReturnMap.
  • Test behaviour, not implementation; over-mocking produces brittle tests that catch nothing.

🎯 Quick Quiz

Question 1: You want to test "given a user exists, my service returns their name." Which double fits best?

Question 2: Why type-hint an interface instead of a concrete class in a constructor?

Question 3: What replaced PHPUnit's removed withConsecutive() for matching calls?

πŸ“š Further Reading

πŸš€ What's Next?

You can now isolate any unit under test. Next we zoom all the way out to the opposite end of the pyramid: end-to-end testing principles, where nothing is mocked and you verify the whole system as a user would.

πŸŽ‰ Excellent!

Doubles and dependency injection are two of the most transferable skills in testing β€” you'll use the exact same thinking in JavaScript, Python, and beyond.