PHPMocking & Test Doubles

Mocking & Test Doubles

Real applications are full of dependencies a unit test doesn't want to deal with: a database connection, a third-party payment API, the current time, the filesystem. If a test for an order-processing function actually hits a payment provider's live API every time it runs, the test suite becomes slow, unreliable when the network hiccups, and potentially expensive if it triggers real charges. Mocking solves this by substituting a fake, controllable stand-in — a test double — for the real dependency, so the test can focus purely on the logic of the class under test.

Why isolate the unit under test

Consider a class that processes an order by charging a customer through a PaymentGateway and then saving the result with an OrderRepository. A unit test for OrderProcessor shouldn't need a live payment gateway or a real database — it should verify that OrderProcessor calls those collaborators correctly given various scenarios (successful charge, declined card, gateway timeout), regardless of how the real payment gateway happens to be implemented.

The class under test

PHP
<?php

class OrderProcessor
{
    public function __construct(
        private PaymentGateway $paymentGateway,
        private OrderRepository $orderRepository,
    ) {
    }

    public function process(Order $order): bool
    {
        $charged = $this->paymentGateway->charge($order->total());

        if (!$charged) {
            return false;
        }

        $this->orderRepository->save($order);

        return true;
    }
}

Because PaymentGateway and OrderRepository are injected through the constructor rather than created inside the class, a test can pass in fake versions instead. This is exactly why constructor injection is worth using even in small projects — it's what makes a class mockable in the first place.

createMock() and createStub()

PHPUnit can generate a fake implementation of any class or interface at runtime with $this->createMock(). The generated object accepts any method call the real class defines, returns null by default from each one, and lets you configure specific return values or expectations per test.

Using createMock() to fake a successful charge

PHP
<?php

use PHPUnit\Framework\TestCase;

class OrderProcessorTest extends TestCase
{
    public function testProcessSavesOrderWhenChargeSucceeds(): void
    {
        $paymentGateway = $this->createMock(PaymentGateway::class);
        $paymentGateway->method('charge')->willReturn(true);

        $orderRepository = $this->createMock(OrderRepository::class);

        $processor = new OrderProcessor($paymentGateway, $orderRepository);
        $order = new Order(total: 49.99);

        $result = $processor->process($order);

        $this->assertTrue($result);
    }
}

createStub() is nearly identical but signals intent: a stub only supplies canned return values (like willReturn(true) above) and isn't meant to have call expectations asserted on it, while a mock is meant for verifying interactions actually happened. In practice PHPUnit's createMock() can do both, but reaching for createStub() when you only care about a return value makes the test's intent clearer to the next reader.

Setting expectations on how a mock is called

Beyond controlling return values, mocks can assert that a method was called a specific number of times, or with specific arguments. This is useful for verifying side effects that don't show up in a return value — for example, confirming the order was actually saved.

Asserting save() is called exactly once

PHP
<?php

public function testProcessSavesOrderExactlyOnce(): void
{
    $paymentGateway = $this->createStub(PaymentGateway::class);
    $paymentGateway->method('charge')->willReturn(true);

    $orderRepository = $this->createMock(OrderRepository::class);
    $orderRepository->expects($this->once())
        ->method('save')
        ->with($this->isInstanceOf(Order::class));

    $processor = new OrderProcessor($paymentGateway, $orderRepository);
    $processor->process(new Order(total: 49.99));
}

expects($this->once()) says the test should fail if save() is called zero times or more than once. ->with(...) further restricts which arguments count as a matching call. This test has no explicit assert* call at all — the mock's expectation is itself the assertion, and PHPUnit checks it automatically when the test method finishes.

Testing the declined-charge path

PHP
<?php

public function testProcessDoesNotSaveWhenChargeIsDeclined(): void
{
    $paymentGateway = $this->createStub(PaymentGateway::class);
    $paymentGateway->method('charge')->willReturn(false);

    $orderRepository = $this->createMock(OrderRepository::class);
    $orderRepository->expects($this->never())->method('save');

    $processor = new OrderProcessor($paymentGateway, $orderRepository);
    $result = $processor->process(new Order(total: 49.99));

    $this->assertFalse($result);
}

$this->never() is just as useful as $this->once() — confirming a method was not called is often exactly the behavior you're trying to protect, here making sure a declined payment never results in an order being saved.

Mocks vs. real integration tests

Mocking is powerful, but it comes with a real tradeoff. A mock only behaves the way you told it to behave — it doesn't know anything about how the real PaymentGateway actually responds to edge cases, network errors, or a changed API contract. A test suite built entirely out of mocked collaborators can pass with flying colors while the real integration between those pieces is completely broken.

Over-mocking hides real bugs
If every single dependency in your test suite is mocked, you're testing that your code calls its dependencies the way you *think* they work — not that the dependencies actually work that way. A classic failure mode: the real `PaymentGateway::charge()` method gets renamed or its return type changes from `bool` to a `PaymentResult` object, but every test still passes because the mocks were never updated to reflect it. Keep a smaller number of integration tests that exercise the real collaborator (or a realistic fake, like a test database) alongside your mocked unit tests, specifically to catch this class of problem.
  • Mock or stub dependencies that are slow, external, or hard to control (databases, HTTP APIs, the clock, the filesystem).

  • Use createStub() when you only need a canned return value; use createMock() when you need to assert a method was actually called.

  • expects($this->once()), expects($this->never()), and ->with(...) verify how a collaborator was used, not just what it returned.

  • Balance mocked unit tests with a smaller set of real integration tests so a broken assumption about a dependency doesn't slip through unnoticed.

Tip
Mock at the boundary of your own code — the interface your class depends on — rather than mocking deep internal details of a third-party library. Mocking your own `PaymentGateway` interface keeps tests stable even if the underlying HTTP client library changes; mocking the HTTP client directly ties your tests to implementation details you don't control.