| name | symfony:test-doubles-mocking |
| description | Create test doubles with PHPUnit mocks and Prophecy for isolated unit testing in Symfony |
Test Doubles and Mocking
Types of Test Doubles
| Type | Purpose |
|---|---|
| Dummy | Passed but never used |
| Stub | Returns predetermined values |
| Mock | Verifies interactions |
| Spy | Records calls for later verification |
| Fake | Working implementation (simplified) |
PHPUnit Mocks
Basic Mock
<?php
use App\Service\PaymentGateway;
use App\Service\OrderService;
use PHPUnit\Framework\TestCase;
class OrderServiceTest extends TestCase
{
public function testProcessPayment(): void
{
// Create mock
$gateway = $this->createMock(PaymentGateway::class);
// Configure return value
$gateway->method('charge')
->willReturn(new PaymentResult(success: true, transactionId: 'tx_123'));
$service = new OrderService($gateway);
$result = $service->processPayment(1000, 'EUR');
$this->assertTrue($result->isSuccessful());
}
}
Mock with Expectations
public function testChargesCorrectAmount(): void
{
$gateway = $this->createMock(PaymentGateway::class);
// Expect specific call
$gateway->expects($this->once())
->method('charge')
->with(
$this->equalTo(1000),
$this->equalTo('EUR')
)
->willReturn(new PaymentResult(success: true));
$service = new OrderService($gateway);
$service->processPayment(1000, 'EUR');
}
Consecutive Returns
public function testRetriesOnFailure(): void
{
$gateway = $this->createMock(PaymentGateway::class);
$gateway->expects($this->exactly(2))
->method('charge')
->willReturnOnConsecutiveCalls(
new PaymentResult(success: false), // First call fails
new PaymentResult(success: true) // Second succeeds
);
$service = new OrderService($gateway);
$result = $service->processPaymentWithRetry(1000, 'EUR');
$this->assertTrue($result->isSuccessful());
}
Throwing Exceptions
public function testHandlesGatewayError(): void
{
$gateway = $this->createMock(PaymentGateway::class);
$gateway->method('charge')
->willThrowException(new GatewayException('Connection timeout'));
$service = new OrderService($gateway);
$this->expectException(PaymentFailedException::class);
$service->processPayment(1000, 'EUR');
}
Callback for Complex Logic
public function testDynamicResponse(): void
{
$repository = $this->createMock(ProductRepository::class);
$repository->method('find')
->willReturnCallback(function (int $id) {
if ($id === 1) {
return new Product(id: 1, name: 'Product 1');
}
return null;
});
$service = new ProductService($repository);
$this->assertNotNull($service->getProduct(1));
$this->assertNull($service->getProduct(999));
}
Prophecy (Alternative)
Prophecy provides a different syntax, often considered more readable.
<?php
use Prophecy\PhpUnit\ProphecyTrait;
class OrderServiceTest extends TestCase
{
use ProphecyTrait;
public function testProcessPayment(): void
{
// Create prophecy
$gateway = $this->prophesize(PaymentGateway::class);
// Stub method
$gateway->charge(1000, 'EUR')
->willReturn(new PaymentResult(success: true));
// Reveal to get actual mock
$service = new OrderService($gateway->reveal());
$result = $service->processPayment(1000, 'EUR');
$this->assertTrue($result->isSuccessful());
}
public function testCallsGatewayOnce(): void
{
$gateway = $this->prophesize(PaymentGateway::class);
// Expect call
$gateway->charge(1000, 'EUR')
->shouldBeCalledOnce()
->willReturn(new PaymentResult(success: true));
$service = new OrderService($gateway->reveal());
$service->processPayment(1000, 'EUR');
}
}
Mocking Symfony Services
EntityManager
public function testPersistsEntity(): void
{
$em = $this->createMock(EntityManagerInterface::class);
$em->expects($this->once())
->method('persist')
->with($this->isInstanceOf(User::class));
$em->expects($this->once())
->method('flush');
$service = new UserService($em);
$service->createUser('test@example.com');
}
Repository
public function testFindsUser(): void
{
$user = new User();
$user->setEmail('test@example.com');
$repository = $this->createMock(UserRepository::class);
$repository->method('findOneByEmail')
->with('test@example.com')
->willReturn($user);
$service = new UserService($repository);
$found = $service->findByEmail('test@example.com');
$this->assertSame($user, $found);
}
MessageBus
public function testDispatchesMessage(): void
{
$bus = $this->createMock(MessageBusInterface::class);
$bus->expects($this->once())
->method('dispatch')
->with($this->callback(function ($message) {
return $message instanceof SendWelcomeEmail
&& $message->userId === 123;
}))
->willReturn(new Envelope(new \stdClass()));
$service = new RegistrationService($bus);
$service->register(123, 'test@example.com');
}
Partial Mocks
Mock only some methods:
public function testPartialMock(): void
{
$service = $this->getMockBuilder(OrderService::class)
->setConstructorArgs([$this->gateway])
->onlyMethods(['sendNotification']) // Only mock this
->getMock();
$service->method('sendNotification')
->willReturn(true);
// Real processPayment, mocked sendNotification
$service->processPayment(1000, 'EUR');
}
Fakes (Working Implementations)
<?php
// tests/Fake/InMemoryUserRepository.php
class InMemoryUserRepository implements UserRepositoryInterface
{
private array $users = [];
public function save(User $user): void
{
$this->users[$user->getId()] = $user;
}
public function find(int $id): ?User
{
return $this->users[$id] ?? null;
}
public function findByEmail(string $email): ?User
{
foreach ($this->users as $user) {
if ($user->getEmail() === $email) {
return $user;
}
}
return null;
}
}
Usage:
public function testCreatesUser(): void
{
$repository = new InMemoryUserRepository();
$service = new UserService($repository);
$user = $service->createUser('test@example.com');
$this->assertNotNull($repository->findByEmail('test@example.com'));
}
Best Practices
- Mock dependencies, not the SUT: Don't mock the class you're testing
- Use interfaces: Mock interfaces, not concrete classes
- One mock assertion per test: Keep tests focused
- Prefer stubs over mocks: Only verify when behavior matters
- Use fakes for repositories: More realistic tests
- Don't over-mock: Integration tests have value too