Mocking and Stubs in PHP Testing
Introduction to Test Doubles
Test doubles are objects that replace production dependencies in tests, allowing you to test units in isolation. The main types of test doubles are stubs, mocks, spies, fakes, and dummies.
Types of Test Doubles
Dummy Objects
Objects passed around but never actually used, usually just to fill parameter lists.
<?php
class DummyLogger implements LoggerInterface
{
public function log($level, $message, array $context = array())
{
// Does nothing - just a dummy
}
public function emergency($message, array $context = array()) {}
public function alert($message, array $context = array()) {}
public function critical($message, array $context = array()) {}
public function error($message, array $context = array()) {}
public function warning($message, array $context = array()) {}
public function notice($message, array $context = array()) {}
public function info($message, array $context = array()) {}
public function debug($message, array $context = array()) {}
}
class UserServiceTest extends PHPUnit\Framework\TestCase
{
public function testUserCreation()
{
$dummyLogger = new DummyLogger();
$userService = new UserService($dummyLogger); // Logger is never used in this test
$user = $userService->createUser('[email protected]');
$this->assertInstanceOf(User::class, $user);
}
}
?>
Stubs
Objects that return predefined responses to method calls.
<?php
class StubEmailService implements EmailServiceInterface
{
private $shouldSucceed;
public function __construct($shouldSucceed = true)
{
$this->shouldSucceed = $shouldSucceed;
}
public function sendEmail($to, $subject, $body)
{
if ($this->shouldSucceed) {
return ['status' => 'sent', 'id' => 'stub_12345'];
} else {
throw new EmailException('Stubbed failure');
}
}
}
class NotificationServiceTest extends PHPUnit\Framework\TestCase
{
public function testSuccessfulNotification()
{
$stubEmailService = new StubEmailService(true);
$notificationService = new NotificationService($stubEmailService);
$result = $notificationService->notifyUser('[email protected]', 'Test message');
$this->assertTrue($result);
}
public function testFailedNotification()
{
$stubEmailService = new StubEmailService(false);
$notificationService = new NotificationService($stubEmailService);
$result = $notificationService->notifyUser('[email protected]', 'Test message');
$this->assertFalse($result);
}
}
?>
Mocks
Objects that have expectations about how they will be called and can verify those expectations.
<?php
class OrderServiceTest extends PHPUnit\Framework\TestCase
{
public function testOrderProcessingCallsPaymentService()
{
$mockPaymentService = $this->createMock(PaymentServiceInterface::class);
$mockPaymentService->expects($this->once())
->method('processPayment')
->with(
$this->equalTo(100.00),
$this->equalTo('credit_card')
)
->willReturn(['success' => true, 'transaction_id' => 'txn_123']);
$orderService = new OrderService($mockPaymentService);
$result = $orderService->processOrder(['total' => 100.00, 'payment_method' => 'credit_card']);
$this->assertTrue($result['success']);
}
public function testOrderProcessingWithPaymentFailure()
{
$mockPaymentService = $this->createMock(PaymentServiceInterface::class);
$mockPaymentService->expects($this->once())
->method('processPayment')
->willThrowException(new PaymentException('Payment failed'));
$orderService = new OrderService($mockPaymentService);
$this->expectException(OrderProcessingException::class);
$orderService->processOrder(['total' => 100.00, 'payment_method' => 'credit_card']);
}
}
?>
PHPUnit Mocking
Basic Mock Creation
<?php
class UserRepositoryTest extends PHPUnit\Framework\TestCase
{
public function testFindUserById()
{
// Create mock
$mockRepository = $this->createMock(UserRepositoryInterface::class);
// Set up expectations
$mockRepository->method('findById')
->with(123)
->willReturn(new User(123, '[email protected]'));
$userService = new UserService($mockRepository);
$user = $userService->getUser(123);
$this->assertEquals('[email protected]', $user->getEmail());
}
public function testUserNotFound()
{
$mockRepository = $this->createMock(UserRepositoryInterface::class);
$mockRepository->method('findById')
->with(999)
->willReturn(null);
$userService = new UserService($mockRepository);
$this->expectException(UserNotFoundException::class);
$userService->getUser(999);
}
}
?>
Advanced Mock Configuration
<?php
class EmailServiceTest extends PHPUnit\Framework\TestCase
{
public function testEmailSendingWithRetries()
{
$mockEmailProvider = $this->createMock(EmailProviderInterface::class);
// Set up consecutive calls
$mockEmailProvider->expects($this->exactly(3))
->method('send')
->willReturnOnConsecutiveCalls(
$this->throwException(new NetworkException('Temporary failure')),
$this->throwException(new NetworkException('Still failing')),
['status' => 'sent', 'id' => 'email_123']
);
$emailService = new EmailService($mockEmailProvider);
$result = $emailService->sendWithRetries('[email protected]', 'Subject', 'Body');
$this->assertEquals('email_123', $result['id']);
}
public function testEmailSendingWithCallback()
{
$mockEmailProvider = $this->createMock(EmailProviderInterface::class);
$mockEmailProvider->method('send')
->willReturnCallback(function($to, $subject, $body) {
if (strpos($to, '@') === false) {
throw new InvalidEmailException('Invalid email format');
}
return ['status' => 'sent', 'to' => $to];
});
$emailService = new EmailService($mockEmailProvider);
// Valid email
$result = $emailService->send('[email protected]', 'Subject', 'Body');
$this->assertEquals('[email protected]', $result['to']);
// Invalid email
$this->expectException(InvalidEmailException::class);
$emailService->send('invalid-email', 'Subject', 'Body');
}
}
?>
Partial Mocks
<?php
class FileProcessorTest extends PHPUnit\Framework\TestCase
{
public function testProcessFileWithMockedDependency()
{
// Create partial mock - only mock specific methods
$fileProcessor = $this->getMockBuilder(FileProcessor::class)
->setMethods(['readFile'])
->getMock();
$fileProcessor->method('readFile')
->willReturn('mocked file content');
// Real method will be called, but readFile is mocked
$result = $fileProcessor->processFile('/path/to/file.txt');
$this->assertStringContainsString('mocked file content', $result);
}
public function testAbstractClassMock()
{
$mockAbstractProcessor = $this->getMockBuilder(AbstractProcessor::class)
->setMethods(['process'])
->getMock();
$mockAbstractProcessor->method('process')
->willReturn('processed data');
$result = $mockAbstractProcessor->process('input data');
$this->assertEquals('processed data', $result);
}
}
?>
Prophecy Framework
Basic Prophecy Usage
<?php
use Prophecy\PhpUnit\ProphecyTrait;
class UserServiceTest extends PHPUnit\Framework\TestCase
{
use ProphecyTrait;
public function testUserCreationWithProphecy()
{
// Create prophecy
$emailService = $this->prophesize(EmailServiceInterface::class);
$userRepository = $this->prophesize(UserRepositoryInterface::class);
// Set up expectations
$userRepository->save(Argument::type(User::class))
->shouldBeCalled()
->willReturn(123);
$emailService->sendWelcomeEmail(Argument::that(function($user) {
return $user instanceof User && $user->getEmail() === '[email protected]';
}))->shouldBeCalled();
// Test the service
$userService = new UserService(
$userRepository->reveal(),
$emailService->reveal()
);
$user = $userService->createUser('[email protected]', 'password');
$this->assertEquals(123, $user->getId());
}
public function testAdvancedProphecyMatchers()
{
$paymentProcessor = $this->prophesize(PaymentProcessorInterface::class);
// Argument matchers
$paymentProcessor->processPayment(
Argument::that(function($amount) {
return $amount >= 0 && $amount <= 10000;
}),
Argument::exact('USD'),
Argument::containingString('card_')
)->willReturn(['success' => true]);
$orderService = new OrderService($paymentProcessor->reveal());
$result = $orderService->processPayment(100.00, 'USD', 'card_123456');
$this->assertTrue($result['success']);
}
}
?>
Prophecy Argument Matching
<?php
class AdvancedProphecyTest extends PHPUnit\Framework\TestCase
{
use ProphecyTrait;
public function testComplexArgumentMatching()
{
$logger = $this->prophesize(LoggerInterface::class);
// Multiple argument types
$logger->log(
Argument::exact('error'),
Argument::containingString('Payment failed'),
Argument::that(function($context) {
return isset($context['user_id']) && is_numeric($context['user_id']);
})
)->shouldBeCalled();
$service = new PaymentService($logger->reveal());
$service->logPaymentError('Payment failed for user 123', ['user_id' => 123]);
}
public function testMethodCallCounts()
{
$cache = $this->prophesize(CacheInterface::class);
// Should be called exactly 3 times
$cache->get(Argument::type('string'))
->shouldBeCalledTimes(3)
->willReturn(null);
$cache->set(Argument::type('string'), Argument::any())
->shouldBeCalledTimes(3);
$service = new CachedDataService($cache->reveal());
// These operations should trigger cache get/set
$service->getData('key1');
$service->getData('key2');
$service->getData('key3');
}
}
?>
Advanced Mocking Patterns
Mock Builder Pattern
<?php
class MockBuilderTest extends PHPUnit\Framework\TestCase
{
public function testCustomMockBuilder()
{
$mockBuilder = new DatabaseMockBuilder();
$mockDatabase = $mockBuilder
->withTable('users')
->withRecords([
['id' => 1, 'name' => 'John'],
['id' => 2, 'name' => 'Jane']
])
->build();
$userService = new UserService($mockDatabase);
$users = $userService->getAllUsers();
$this->assertCount(2, $users);
}
}
class DatabaseMockBuilder
{
private $tables = [];
public function withTable($tableName)
{
$this->tables[$tableName] = [];
return $this;
}
public function withRecords(array $records)
{
$lastTable = array_key_last($this->tables);
$this->tables[$lastTable] = $records;
return $this;
}
public function build()
{
$mock = $this->createMock(DatabaseInterface::class);
$mock->method('query')
->willReturnCallback(function($sql) {
// Simple query parsing for tests
if (preg_match('/FROM (\w+)/', $sql, $matches)) {
$table = $matches[1];
return $this->tables[$table] ?? [];
}
return [];
});
return $mock;
}
}
?>
Test Data Builders
<?php
class UserBuilder
{
private $id = 1;
private $email = '[email protected]';
private $name = 'Test User';
private $active = true;
public static function create()
{
return new self();
}
public function withId($id)
{
$this->id = $id;
return $this;
}
public function withEmail($email)
{
$this->email = $email;
return $this;
}
public function withName($name)
{
$this->name = $name;
return $this;
}
public function inactive()
{
$this->active = false;
return $this;
}
public function build()
{
return new User($this->id, $this->email, $this->name, $this->active);
}
public function buildMock()
{
$mock = $this->createMock(User::class);
$mock->method('getId')->willReturn($this->id);
$mock->method('getEmail')->willReturn($this->email);
$mock->method('getName')->willReturn($this->name);
$mock->method('isActive')->willReturn($this->active);
return $mock;
}
}
class UserServiceTest extends PHPUnit\Framework\TestCase
{
public function testActiveUsersOnly()
{
$users = [
UserBuilder::create()->withId(1)->withName('Active User')->build(),
UserBuilder::create()->withId(2)->withName('Inactive User')->inactive()->build()
];
$mockRepository = $this->createMock(UserRepository::class);
$mockRepository->method('findAll')->willReturn($users);
$userService = new UserService($mockRepository);
$activeUsers = $userService->getActiveUsers();
$this->assertCount(1, $activeUsers);
$this->assertEquals('Active User', $activeUsers[0]->getName());
}
}
?>
Testing Interactions
Verifying Method Calls
<?php
class AuditServiceTest extends PHPUnit\Framework\TestCase
{
public function testAuditLogging()
{
$mockAuditLogger = $this->createMock(AuditLoggerInterface::class);
// Verify specific method calls
$mockAuditLogger->expects($this->once())
->method('logUserAction')
->with(
$this->equalTo('user_login'),
$this->equalTo(123),
$this->callback(function($data) {
return isset($data['ip']) && isset($data['timestamp']);
})
);
$authService = new AuthService($mockAuditLogger);
$authService->loginUser(123, '192.168.1.1');
}
public function testMethodCallOrder()
{
$mockEmailService = $this->createMock(EmailServiceInterface::class);
// Verify call order
$mockEmailService->expects($this->at(0))
->method('validateEmail')
->with('[email protected]');
$mockEmailService->expects($this->at(1))
->method('sendEmail')
->with('[email protected]', 'Welcome', 'Welcome message');
$userService = new UserService($mockEmailService);
$userService->sendWelcomeEmail('[email protected]');
}
}
?>
Spy Objects
<?php
class SpyEmailService implements EmailServiceInterface
{
private $sentEmails = [];
public function sendEmail($to, $subject, $body)
{
$this->sentEmails[] = [
'to' => $to,
'subject' => $subject,
'body' => $body,
'timestamp' => time()
];
return ['status' => 'sent', 'id' => uniqid()];
}
public function getSentEmails()
{
return $this->sentEmails;
}
public function getEmailCount()
{
return count($this->sentEmails);
}
public function wasEmailSentTo($email)
{
foreach ($this->sentEmails as $sentEmail) {
if ($sentEmail['to'] === $email) {
return true;
}
}
return false;
}
}
class NotificationServiceTest extends PHPUnit\Framework\TestCase
{
public function testBulkNotifications()
{
$spyEmailService = new SpyEmailService();
$notificationService = new NotificationService($spyEmailService);
$users = ['[email protected]', '[email protected]', '[email protected]'];
$notificationService->notifyUsers($users, 'Important Update', 'Message body');
// Verify interactions
$this->assertEquals(3, $spyEmailService->getEmailCount());
$this->assertTrue($spyEmailService->wasEmailSentTo('[email protected]'));
$this->assertTrue($spyEmailService->wasEmailSentTo('[email protected]'));
$this->assertTrue($spyEmailService->wasEmailSentTo('[email protected]'));
$sentEmails = $spyEmailService->getSentEmails();
foreach ($sentEmails as $email) {
$this->assertEquals('Important Update', $email['subject']);
$this->assertEquals('Message body', $email['body']);
}
}
}
?>
Mock Best Practices
Don't Over-Mock
<?php
// Bad: Over-mocking simple objects
class BadTest extends PHPUnit\Framework\TestCase
{
public function testUserAge()
{
$mockUser = $this->createMock(User::class);
$mockUser->method('getBirthDate')->willReturn(new DateTime('1990-01-01'));
$ageCalculator = new AgeCalculator();
$age = $ageCalculator->calculateAge($mockUser);
$this->assertEquals(33, $age);
}
}
// Good: Use real objects when appropriate
class GoodTest extends PHPUnit\Framework\TestCase
{
public function testUserAge()
{
$user = new User();
$user->setBirthDate(new DateTime('1990-01-01'));
$ageCalculator = new AgeCalculator();
$age = $ageCalculator->calculateAge($user);
$this->assertEquals(33, $age);
}
}
?>
Mock Interfaces, Not Classes
<?php
// Good: Mock interfaces
interface PaymentProcessorInterface
{
public function processPayment($amount, $currency);
}
class OrderServiceTest extends PHPUnit\Framework\TestCase
{
public function testOrderProcessing()
{
$mockPaymentProcessor = $this->createMock(PaymentProcessorInterface::class);
$mockPaymentProcessor->method('processPayment')->willReturn(['success' => true]);
$orderService = new OrderService($mockPaymentProcessor);
// Test implementation...
}
}
// Avoid: Mocking concrete classes when possible
class ConcretePaymentProcessor
{
public function processPayment($amount, $currency)
{
// Complex implementation with dependencies
}
}
?>
Use Realistic Test Data
<?php
class UserServiceTest extends PHPUnit\Framework\TestCase
{
public function testUserValidation()
{
$mockValidator = $this->createMock(ValidatorInterface::class);
// Good: Realistic test data
$mockValidator->method('validate')
->with([
'email' => '[email protected]',
'age' => 28,
'country' => 'US'
])
->willReturn(['valid' => true]);
// Bad: Unrealistic test data
$mockValidator->method('validate')
->with([
'email' => '[email protected]',
'age' => 1,
'country' => 'X'
])
->willReturn(['valid' => true]);
}
}
?>
Testing Legacy Code with Mocks
Working with Static Methods
<?php
// Legacy class with static dependencies
class LegacyUserService
{
public function createUser($email, $password)
{
if (!EmailValidator::isValid($email)) {
throw new InvalidArgumentException('Invalid email');
}
$hashedPassword = PasswordHasher::hash($password);
return Database::insert('users', [
'email' => $email,
'password' => $hashedPassword
]);
}
}
// Test using wrapper approach
class UserServiceTest extends PHPUnit\Framework\TestCase
{
public function testCreateUserWithWrapper()
{
$mockWrapper = $this->createMock(LegacyServiceWrapper::class);
$mockWrapper->method('validateEmail')->willReturn(true);
$mockWrapper->method('hashPassword')->willReturn('hashed_password');
$mockWrapper->method('insertUser')->willReturn(123);
$userService = new RefactoredUserService($mockWrapper);
$userId = $userService->createUser('[email protected]', 'password');
$this->assertEquals(123, $userId);
}
}
// Wrapper for static dependencies
class LegacyServiceWrapper
{
public function validateEmail($email)
{
return EmailValidator::isValid($email);
}
public function hashPassword($password)
{
return PasswordHasher::hash($password);
}
public function insertUser($data)
{
return Database::insert('users', $data);
}
}
?>
Mocking and stubbing are essential techniques for writing isolated, fast, and reliable unit tests. They allow you to test your code in isolation by replacing dependencies with controlled test doubles that behave predictably.