1. php
  2. /testing
  3. /mocking

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.