Skip to content
advanced Phase 12 · Testing with PHPUnit

Mocking Dependencies

Isolate the unit under test from slow collaborators.

50m
0 problems
Topic Progress 0%

createMock: The Collaborator That Records Instead of Working

Your SignupHandler takes a Mailer, and the first test you write for it tries to connect to SMTP:

Connection could not be established with host "smtp.internal:587": stream_socket_client(): ...

A unit test has no business reaching a network. It also has no business waiting on a real queue, reading a real clock, or spinning up a database to prove five lines of branching logic. The unit under test should be exercised with everything else replaced by a stand-in that records what it was asked to do.

interface Mailer
{
    public function send(Email $email): void;
}

public function testRegistrationSendsWelcomeMail(): void
{
    $mailer = $this->createMock(Mailer::class);
    $users  = new InMemoryUserRepository();

    (new SignupHandler($mailer, $users))->register('ada@example.com');

    $mailer->expects($this->once())->method('send');
}

What createMock(Mailer::class) hands back:

  • Every method is replaced. The body never executes; the call is recorded, and the return value is generated from the declared return type (a bool returns false, a string returns '', an object type returns another stub). You opt into specific values with willReturn().
  • The constructor does not run. Nothing validates arguments, opens sockets, or touches $this->state — so a class with a required constructor argument is still mockable.
  • Unstubbed calls are permissive. A mock with no expects() accepts any number of calls with any arguments. It records; it does not judge. The judgment comes from expectations, which is the next chapter.

Where mocking stops working, you should know before you fight it: final and readonly classes cannot be doubled, and final methods inside a mockable class keep their real body. That is not a PHPUnit limitation to work around — it is the language telling you the design has no seam there. Depend on the interface instead of the concrete class; that single habit is what makes the test above possible at all.

Two siblings of createMock are worth distinguishing:

Builder Use it for
createMock(X) the strictest double — record calls, no real code
createStub(X) a quiet collaborator you only need return values from; expectations on it are rejected
getMockBuilder(X)->onlyMethods(['charge'])->disableOriginalConstructor()->getMock() a partial double of a concrete class: one method replaced, the rest real

Partial doubles are a last resort. When you stub one method of a class that itself has logic, the test starts depending on which lines of that class survived the stubbing — a subtle source of green tests over broken code.

Two convenience builders cover the common cases before you reach for getMockBuilder():

$clock = $this->createConfiguredMock(Clock::class, [
    'now' => new DateTimeImmutable('2026-01-01 00:00:00'),
]);

$gate = $this->createMockForIntersectionOfInterfaces([Auditable::class, Cacheable::class]);

createConfiguredMock() is shorthand for a full mock with its willReturn() values already attached — one line instead of three when the return value never changes during the test. createMockForIntersectionOfInterfaces() (PHPUnit 10+) handles a collaborator typed as Auditable&Cacheable, which is how narrow seams are usually declared. The manual lists getMockBuilder() last on purpose: it exists for edge cases those two helpers do not cover.

What no PHPUnit API can double: static methods, private methods, and final classes. A static call never routes through the instance, so there is no recorder to insert. If your seam is a static Mailer::send(), the mock cannot be built — wrap the static behind an injectable class, or accept that this path belongs to an integration test rather than a unit test.

The speed claim is real and worth stating in an interview: with the mailer mocked, the test runs in sub-millisecond time, never touches DNS, and produces identical results on every machine — deterministic, offline, parallel-safe. That is the whole argument for mocking external services.

Recording is step one. Deciding how many times, with which arguments is where mocks earn their keep.

expects($this->once()): Encoding How the Collaborator Must Be Called

createMock() alone proves nothing. A test that calls $mailer->send(...) and never states an expectation will pass even if send is never invoked. The expectation is the assertion.

$mailer->expects($this->once())
       ->method('send')
       ->with($this->callback(fn (Email $e): bool => $e->to() === 'ada@example.com'))
       ->willReturn(true);

The three clauses answer three separate questions:

How many times? — expects() takes an invocation matcher:

Matcher Contract
$this->never() must not be called at all (perfect for Mailer on the "duplicate signup" path)
$this->once() exactly one call
$this->exactly(2) exactly two
$this->atLeastOnce() one or more
$this->any() no constraint — usually a sign you did not need expects()

Verification happens at the end of the test, in teardown, not at the call site. When the count does not match, PHPUnit fails with the method name and the arithmetic — Expectation failed for method "App\Mailer::send" which is expected exactly once. Failed asserting that 0 times exactly 1 matches the expected count — which is a far better CI artifact than a null-pointer error three frames deep.

With what? — with() takes argument matchers, not raw values. Raw values are wrapped in equalTo() (loose) by default; the explicit forms give you control:

->with(
    $this->identicalTo($email),                    // === on the object
    $this->stringContains('Welcome', $subject),    // substring on a string arg
    $this->anything(),                             // ignore this argument
    $this->logicalAnd(
        $this->stringStartsWith('INV-'),
        $this->stringEndsWith('.pdf')
    )
);

Keep with() to arguments that carry meaning. Asserting every field of every argument makes the test a snapshot of the call site: refactor a parameter order and the suite screams, while the actual business rule sits unasserted.

What does it return? — the stubbing chain decides control flow:

->willReturn(true);                                  // one value
->willReturnMap([[ 'ada@example.com', false ]]);     // per-argument table
->willReturnOnConsecutiveCalls(true, false);         // first call true, second false
->willThrowException(new RateLimitedException());    // simulate the failure branch
->willReturnCallback(fn () => usleep(1));            // custom behaviour

willThrowException is how you test your own error handling without a flaky dependency: Mailer failing mid-signup must roll back the user row, and you can prove it on demand.

One removal to know cold: withConsecutive() is gone as of PHPUnit 10. The old shape asserted arguments across call 1 and call 2 in one chain; the supported replacement captures arguments with a callback:

$calls = [];
$mailer->expects($this->exactly(2))
       ->method('send')
       ->willReturnCallback(function (Email $e) use (&$calls): void {
           $calls[] = $e->to();
       });

$this->assertSame(['first@example.com', 'second@example.com'], $calls);

You get the same guarantee — order and content of both calls — with an assertion you can read, instead of a positional DSL that vanished from the framework.

Two refinements keep expectations honest. First, with() is evaluated per invocation: with exactly(2) configured, a second call carrying unexpected arguments fails as an unexpected invocation with those parameters, not as a count mismatch — the message names the argument that did not match. That is why loose matchers such as $this->anything() belong on arguments you genuinely do not care about and nowhere else; over-matching is how a strict-looking test becomes decorative. Second, willReturnCallback() does double duty — it can produce the return value and record what arrived, so one expectation both steers control flow and collects evidence for your own assertions.

If you find yourself wanting "the third call had these arguments, the fourth had those", resist rebuilding withConsecutive() from matcher parts. At that point the invocation sequence is application state, and application state deserves a plain assertion: have the double append each call to an array (or use an in-memory fake), then compare the collected sequence with assertSame, where the failure output is a diff you can read in one glance.

So far the double came from PHPUnit's own API. Parameterized Tests with dataProvider taught you to consolidate inputs; the same consolidation question applies to test doubles, because two other libraries offer the same service with different syntax.

Prophecy, Mockery, and When Not to Mock at All

Open a legacy PHP repo and you will meet $this->prophesize(...). It is not part of PHPUnit any more: built-in Prophecy support was deprecated in PHPUnit 9 and removed in PHPUnit 10, so on a modern install prophesize() simply does not exist until you add composer require --dev phpspec/prophecy-phpunit and use ProphecyTrait in the test class.

Same test, three dialects:

// PHPUnit
$mailer = $this->createMock(Mailer::class);
$mailer->expects($this->once())->method('send')->willReturn(true);

// Prophecy (needs the prophecy-phpunit bridge)
$mailer = $this->prophesize(Mailer::class);
$mailer->send(Argument::type(Email::class))->shouldBeCalled()->willReturn(true);
$handler = new SignupHandler($mailer->reveal(), $users);   // note: reveal()

// Mockery (separate package, needs MockeryPHPUnitIntegration or Mockery::close())
$mailer = \Mockery::mock(Mailer::class);
$mailer->shouldReceive('send')->once()->andReturn(true);
PHPUnit doubles Prophecy Mockery
Ships with PHPUnit core separate package + bridge separate package
Object handed to SUT the mock itself $mock->reveal() the mock itself
Expectations verified in teardown, via PHPUnit events at verification of the prophecy in Mockery::close()
Failure messages PHPUnit's diff/event format its own its own

For new code, use PHPUnit's API: one fewer dependency, failure output consistent with your assertions, and no reveal() for reviewers to explain. Reach for Mockery only when joining a codebase that already standardized on it — mixing all three in one repository makes every test harder to read.

Now the harder question interviews actually care about: what should you not mock?

  • Value objects and plain arrays. new Money(1200) costs nothing. A mock of Money proves your understanding of the mock, not the arithmetic.
  • Your own domain logic. Mocking a concrete service whose method bodies contain the rules under test means the test asserts the call pattern, and the production method can break freely. If the class has branching logic, test it directly.
  • Collaborators you can afford for real. An InMemoryUserRepository implements UserRepository is ten lines, executes in microseconds, and behaves like the real thing — a fake gives you state to assert on, which a mock cannot:
$repo = new InMemoryUserRepository();
$handler = new SignupHandler($mailer, $repo);
$handler->register('ada@example.com');
$this->assertTrue($repo->exists('ada@example.com'));   // state, not call shape

Mockery carries one extra ritual — exactly the kind of detail that erodes when a team mixes libraries. Every class using it must call Mockery::close() or pull in the integration trait, so expectations are verified and containers are reset:

use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;

final class SignupHandlerTest extends TestCase
{
    use MockeryPHPUnitIntegration;
    // ...
}

If a legacy codebase still runs Prophecy, do not convert tests one by one out of personal preference — add the phpspec/prophecy-phpunit bridge so the suite stays coherent, then migrate module by module behind green builds. Consistency inside a repository beats preference across repositories.

There is also a floor beneath all of this: doubles cannot tell you whether two libraries actually agree. One integration test that pushes a real message through a local SMTP catcher catches TLS and authentication misconfigurations that fifty mocks wave through. That test belongs in the integration suite — slower, separate, still run in CI — while the mocked unit suite stays the fast gate you run on every save.

Mock the seams: things that are slow, external, or nondeterministic — SMTP, HTTP APIs, the clock, randomness, the filesystem. Inject them behind an interface and double that interface. This is also why mocking buys speed: a suite that mocks every boundary runs in milliseconds, offline, on any machine, which is precisely what makes a coverage run affordable — and coverage is where this phase goes next: Coverage Reports and Thresholds.

Quiz

1. You call $this->createMock(Mailer::class) and invoke $mailer->send($email) without any expects(). What is guaranteed?

Question 1 options

2. A test must prove the mailer is never contacted when signup validation fails. Which expectation expresses that?

Question 2 options

3. Your suite uses withConsecutive() to assert arguments of two successive send() calls and the upgrade to PHPUnit 10 removed it. What is the correct replacement?

Question 3 options

4. Which collaborator is the best candidate for a hand-written fake instead of a mock?

Question 4 options

Flashcards

Question

What does createMock() change about the class it doubles?

Answer

All methods are replaced with recorders whose bodies never run, the original constructor is not invoked, and returns are generated from declared return types unless stubbed. Consequences: final classes/methods cannot be doubled, and the double records calls but enforces nothing until expectations are added.

Question

What do the three clauses of a mock expectation each answer?

Answer

expects($this->once()) sets the invocation count (never/once/exactly/atLeastOnce), with() sets argument matchers (identicalTo, stringContains, callback, logicalAnd, anything), and willReturn* sets the returned value or thrown exception. Count and arguments are verified in teardown, after the test body runs.

Question

withConsecutive() was removed in PHPUnit 10. What replaces it?

Answer

A willReturnCallback() closure that appends each invocation's arguments to an array you own, followed by your own assertSame on the collected list — plus willReturnOnConsecutiveCalls() or willReturnMap() when only the return sequence matters. This keeps order and content assertions without the positional DSL.

Question

How do PHPUnit doubles, Prophecy, and Mockery differ in practice?

Answer

Prophecy's prophesize() was removed from PHPUnit 10 (restore with phpspec/prophecy-phpunit + ProphecyTrait) and requires ->reveal() before injecting; Mockery is a separate package verified via Mockery::close(); PHPUnit's createMock ships in core, needs no reveal(), and reports failures through the same event/diff machinery as your assertions.

Question

What should NOT be mocked, and what should?

Answer

Do not mock value objects, plain data, or your own domain logic — build real ones, or use an in-memory fake when you need to assert state. Do mock seams: slow, external, nondeterministic things (SMTP, HTTP, clock, randomness, filesystem) behind an interface, which keeps unit tests fast, offline, and deterministic.

Revision Notes

Key Takeaways

  • 1. createMock replaces method bodies with recorders and skips the constructor; it enforces nothing until an expectation is attached, so a call that never happens still passes
  • 2. An expectation has three independent clauses — count (never/once/exactly), arguments (with matchers), and return behavior (willReturn/willThrowException) — verified in teardown and reported like an assertion failure
  • 3. withConsecutive() was removed in PHPUnit 10; capture arguments with willReturnCallback and assert the collected list yourself, which reads better than the positional DSL it replaced
  • 4. Prophecy's built-in support was removed in PHPUnit 10 and Mockery is a separate package — for new code, PHPUnit's own doubles keep dependencies and failure messages consistent
  • 5. Mock the seams (network, clock, randomness) with doubles; assert state with in-memory fakes; build value objects for real — mocking your own domain logic tests your misunderstanding, not the code

Interview Tips

  • • "How do you decide what to mock?" — anything slow, external, or nondeterministic behind an interface; value objects and domain logic stay real; mention that the goal is a suite that is fast, offline, and deterministic, not maximal isolation
  • • "What does expects($this->once()) actually verify?" — the invocation count is checked in teardown against every recorded call, so the failure is an assertion-grade message naming the method and the arithmetic, not a runtime error three frames later
  • • "Your mock-based test is green but production is broken. Why?" — the mock encodes your assumption about the collaborator; if you stubbed a method of a class that contains the rules under test, nothing executed the real logic; prefer real objects for domain code and fakes for state
  • • "Prophecy or PHPUnit doubles?" — PHPUnit core: no extra dependency, no reveal(), consistent failure output; Prophecy needs the phpspec/prophecy-phpunit bridge since PHPUnit 10 removed built-in support — knowing the removal shows you have migrated a suite before

Cheat Sheet

Mocking Dependencies

Build the double:

$mailer = $this->createMock(Mailer::class);      // recorder, no real code, no constructor
$quiet  = $this->createStub(Mailer::class);      // return values only, expectations rejected
$part   = $this->getMockBuilder(Mailer::class)   // partial: one method replaced
    ->onlyMethods(['send'])
    ->disableOriginalConstructor()
    ->getMock();

final / readonly classes and final methods are not mockable → depend on an interface.

Expectation anatomy:

$mailer->expects($this->once())            // never | once | exactly(n) | atLeastOnce | any
       ->method('send')                    // which method
       ->with($this->callback(...))        // identicalTo | stringContains | anything | logicalAnd
       ->willReturn(true);                 // willReturn | willReturnMap | willReturnOnConsecutiveCalls
                                           // willThrowException | willReturnCallback

No expects() → any number of calls with any arguments passes. Verification runs in teardown.

Removed in PHPUnit 10:

Old Replacement
->withConsecutive([$a], [$b]) willReturnCallback() collecting args + your own assertSame
$this->prophesize(X::class) createMock(), or add phpspec/prophecy-phpunit + ProphecyTrait

Mock vs fake vs real:
| Seam (slow/external/random) | mock the interface |
| State you must assert (repositories, carts) | hand-written in-memory fake |
| Value objects, domain logic, plain arrays | real objects, tested directly |

Why it matters: mocked boundaries run in milliseconds, offline, deterministic — the precondition for an affordable coverage run.

Continue Learning