PHPUnit mock objects — createMock, expects, with, willReturn
Concept
PHPUnit's built-in mock object system generates test doubles at runtime using PHP's reflection and code generation. Understanding its API precisely prevents the most common pitfalls: improperly chained matchers, assertions in the wrong phase, and confusion between createMock() and createStub().
createMock(ClassName::class) generates a class that implements or extends the given type, with all methods stubbed to return null by default and expecting zero calls. createStub() (PHPUnit 8+) is identical but disables automatic failure when unexpected methods are called—use it when you only need return values and don't care about call verification. Always prefer createStub() for stubs to signal intent clearly.
The matcher chain works left to right: $mock->expects(MATCHER)->method('methodName')->with(ARGS)->willReturn(VALUE). The expects() call sets the cardinality constraint. Common matchers: $this->once(), $this->any(), $this->never(), $this->exactly(3), $this->atLeastOnce(), $this->atMost(2).
The with() matcher validates arguments. Use $this->equalTo($value) for loose equality, $this->identicalTo($object) for strict identity, $this->isInstanceOf(ClassName::class) for type checking, $this->callback(fn($arg) => ...) for complex validation. When you provide multiple arguments to with(), each positional argument gets its own constraint.
willReturn($value) returns a single value every time. willReturnOnConsecutiveCalls($a, $b, $c) returns different values on successive calls. willThrowException(new \RuntimeException()) makes the method throw. willReturnArgument(0) echoes back the first argument—useful for passthrough stubs.
A common mistake is forgetting that PHPUnit verifies mock expectations at the end of the test method via verify(), which is called automatically in tearDown(). If you call $mock->save() in the wrong place or not at all, the failure appears at teardown, not at the assertion line—this can be confusing.
Code Example
<?php
declare(strict_types=1);
namespace Tests\Unit;
use App\Mail\Mailer;
use App\Repositories\UserRepositoryInterface;
use App\Services\PasswordResetService;
use App\Models\User;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class PasswordResetServiceTest extends TestCase
{
private UserRepositoryInterface $userRepo;
private Mailer $mailer;
private PasswordResetService $service;
protected function setUp(): void
{
parent::setUp();
// createStub — we only need canned data, no call assertions
$this->userRepo = $this->createStub(UserRepositoryInterface::class);
// createMock — we WILL assert on this collaborator's calls
$this->mailer = $this->createMock(Mailer::class);
$this->service = new PasswordResetService($this->userRepo, $this->mailer);
}
#[Test]
public function it_sends_reset_email_to_the_correct_user(): void
{
// Arrange: stub returns a specific user
$user = new User(id: 7, name: 'Alice', email: 'alice@example.com');
$this->userRepo
->method('findByEmail')
->with('alice@example.com')
->willReturn($user);
// Mock expectation: sendResetLink must be called exactly once
// with the user object and a string that looks like a URL
$this->mailer
->expects($this->once())
->method('sendResetLink')
->with(
$this->identicalTo($user),
$this->matchesRegularExpression('/^https?:\/\//')
);
// Act
$this->service->initiateReset('alice@example.com');
// Assert: mock expectation is verified automatically in tearDown
}
#[Test]
public function it_does_not_send_email_when_user_not_found(): void
{
$this->userRepo
->method('findByEmail')
->willReturn(null);
// Explicitly assert mailer is never called
$this->mailer
->expects($this->never())
->method('sendResetLink');
$this->service->initiateReset('ghost@example.com');
}
#[Test]
public function it_returns_different_tokens_on_consecutive_calls(): void
{
$tokenGen = $this->createMock(TokenGeneratorInterface::class);
$tokenGen
->expects($this->exactly(2))
->method('generate')
->willReturnOnConsecutiveCalls('token-abc', 'token-xyz');
$first = $tokenGen->generate();
$second = $tokenGen->generate();
$this->assertNotSame($first, $second);
$this->assertSame('token-abc', $first);
$this->assertSame('token-xyz', $second);
}
#[Test]
public function it_propagates_mailer_exceptions(): void
{
$user = new User(id: 1, name: 'Bob', email: 'bob@example.com');
$this->userRepo->method('findByEmail')->willReturn($user);
$this->mailer
->method('sendResetLink')
->willThrowException(new \RuntimeException('SMTP connection failed'));
$this->expectException(\RuntimeException::class);
$this->service->initiateReset('bob@example.com');
}
}Interview Q&A
Q: What is the difference between createMock() and createStub() in PHPUnit, and when should you use each?
createMock() produces a double where PHPUnit tracks calls and verifies expectations you configure with expects(). If you call createMock() but set no expects(), the mock will still succeed even if methods are never called—you get no automatic verification. createStub() is semantically clearer: it signals "I only need canned return values, I am not verifying calls." In practice, use createStub() for dependencies you're querying (repositories, finders) and createMock() only when you specifically want to assert that a side-effecting method (send email, save to DB) was called with specific arguments.
Q: How do you assert that a mocked method was called with a specific object argument, and what matchers are available?
Use $this->identicalTo($object) inside with() to assert the exact same object instance (by reference). Use $this->equalTo($object) if you only care about property equality. Use $this->isInstanceOf(ClassName::class) if you only need the type. Use $this->callback(fn($arg) => $arg->id === 42) for arbitrary predicate matching. Combining matchers: with($this->isInstanceOf(User::class), $this->matchesRegularExpression('/token-\w+/')) validates multiple arguments independently.
Q: Mock expectations are verified in tearDown—what problem does this create and how do you work around it?
When a mock expectation fails, the error appears at teardown rather than at the line where the action was performed, making it hard to pinpoint the failure in complex tests. Additionally, if teardown itself throws (e.g., a database cleanup error), PHPUnit may report the mock failure as a secondary error. The workaround is to call $mock->__phpunit_verify() explicitly at the end of the test body before cleanup code runs, or to restructure tests so each mock has only one expectation and the failure location is obvious. Better still: keep tests small enough that teardown failures are never ambiguous.