PHP Test Doubles and Dependency Injection

Mastering isolation techniques for effective PHP testing

Introduction to Test Doubles

Test doubles are objects that stand in for real dependencies during testing. They allow you to isolate the code you're testing from its dependencies, making tests more focused and reliable.

mindmap root((Test Doubles)) Types Dummy Stub Spy Mock Fake Purposes Isolation Control Verification Speed When to Use External Services Database Operations Complex Logic Slow Operations

Types of Test Doubles

Real-world analogy: Think of test doubles like stand-ins or stunt doubles in movies. A stunt double looks like the actor but is used for specific scenes. Similarly, test doubles look like real dependencies but are used for specific testing scenarios.

Dependency Injection

Dependency injection is a design pattern that makes code more testable by providing dependencies to a class rather than having the class create them internally.

Without Dependency Injection

class UserService {
    private $userRepository;
    
    public function __construct() {
        // Hard dependency that can't be easily replaced in tests
        $this->userRepository = new UserRepository();
    }
    
    public function getUserById($id) {
        return $this->userRepository->findById($id);
    }
}

With Dependency Injection

class UserService {
    private $userRepository;
    
    // Dependency is injected via constructor
    public function __construct(UserRepositoryInterface $userRepository) {
        $this->userRepository = $userRepository;
    }
    
    public function getUserById($id) {
        return $this->userRepository->findById($id);
    }
}

Dependency Injection Methods

graph TD A[Hard Dependencies] -->|Hard to Test| B[Tightly Coupled Code] C[Dependency Injection] -->|Easy to Test| D[Loosely Coupled Code] E[Class Under Test] -->|Uses| F[Interface] F -->|Implemented by| G[Real Implementation] F -->|Implemented by| H[Test Double]

Benefits of Dependency Injection:

Creating Test Doubles Manually

Before diving into testing frameworks, it's instructive to understand how test doubles can be created manually.

Manual Dummy

interface Logger {
    public function log($message);
}

// Dummy implementation that does nothing
class DummyLogger implements Logger {
    public function log($message) {
        // Do nothing
    }
}

Manual Stub

interface UserRepository {
    public function findById($id);
}

// Stub that returns predefined data
class StubUserRepository implements UserRepository {
    private $userData;
    
    public function __construct($userData = null) {
        $this->userData = $userData;
    }
    
    public function findById($id) {
        return $this->userData;
    }
}

Manual Spy

// Spy that records calls
class SpyEmailSender implements EmailSender {
    public $sentEmails = [];
    
    public function send($to, $subject, $body) {
        $this->sentEmails[] = [
            'to' => $to,
            'subject' => $subject,
            'body' => $body
        ];
        
        return true;
    }
}

Manual Mock

// Mock with expectations
class MockPaymentGateway implements PaymentGateway {
    private $expectedAmount;
    private $expectedCreditCard;
    private $wasCalled = false;
    
    public function __construct($expectedAmount, $expectedCreditCard) {
        $this->expectedAmount = $expectedAmount;
        $this->expectedCreditCard = $expectedCreditCard;
    }
    
    public function processPayment($amount, $creditCard) {
        $this->wasCalled = true;
        
        if ($amount !== $this->expectedAmount) {
            throw new \Exception("Expected amount {$this->expectedAmount}, got {$amount}");
        }
        
        if ($creditCard !== $this->expectedCreditCard) {
            throw new \Exception("Unexpected credit card");
        }
        
        return true;
    }
    
    public function verify() {
        if (!$this->wasCalled) {
            throw new \Exception("Expected processPayment to be called, but it wasn't");
        }
    }
}

Manual Fake

// Fake implementation
class FakeUserRepository implements UserRepository {
    private $users = [];
    
    public function save(User $user) {
        $this->users[$user->getId()] = $user;
    }
    
    public function findById($id) {
        return $this->users[$id] ?? null;
    }
    
    public function findAll() {
        return array_values($this->users);
    }
}

Using PHPUnit for Test Doubles

PHPUnit provides built-in support for creating test doubles, making it easier than manually creating them.

Creating a Stub with PHPUnit

public function testGetUserById() {
    // Create a stub for the UserRepository interface
    $stub = $this->createStub(UserRepository::class);
    
    // Configure the stub to return a specific user when findById is called
    $user = new User(1, 'John Doe');
    $stub->method('findById')
         ->willReturn($user);
    
    // Create the UserService with the stub
    $service = new UserService($stub);
    
    // Call the method under test
    $result = $service->getUserById(1);
    
    // Make assertions about the result
    $this->assertSame($user, $result);
}

Method Return Values

// Return a specific value
$stub->method('findById')->willReturn($user);

// Return different values on consecutive calls
$stub->method('findById')
     ->willReturnOnConsecutiveCalls($user1, $user2, null);
     
// Return a value based on the arguments
$stub->method('findById')
     ->willReturnCallback(function ($id) {
         if ($id === 1) {
             return new User(1, 'John');
         } else {
             return null;
         }
     });
     
// Throw an exception
$stub->method('findById')
     ->willThrowException(new NotFoundException('User not found'));
     
// Return one of the arguments
$stub->method('save')
     ->willReturnArgument(0);

Creating a Mock with PHPUnit

public function testDeleteUserSendsNotification() {
    // Create a mock for the UserRepository
    $userRepo = $this->createStub(UserRepository::class);
    $userRepo->method('findById')->willReturn(new User(1, 'John Doe'));
    $userRepo->method('delete')->willReturn(true);
    
    // Create a mock for the NotificationService
    $notificationService = $this->createMock(NotificationService::class);
    
    // Set up expectations on the mock
    $notificationService->expects($this->once())
                        ->method('sendDeletionNotification')
                        ->with($this->equalTo(1));
    
    // Create the service with the mocks
    $userService = new UserService($userRepo, $notificationService);
    
    // Call the method under test
    $result = $userService->deleteUser(1);
    
    // Assert the result
    $this->assertTrue($result);
}

Expectation Constraints

// Call count expectations
$mock->expects($this->once())->method('someMethod');
$mock->expects($this->exactly(3))->method('someMethod');
$mock->expects($this->atLeastOnce())->method('someMethod');
$mock->expects($this->atMost(5))->method('someMethod');
$mock->expects($this->never())->method('someMethod');

// Argument constraints
$mock->expects($this->once())
     ->method('someMethod')
     ->with(
         $this->equalTo('first arg'),
         $this->greaterThan(10),
         $this->stringContains('substring'),
         $this->callback(function($arg) {
             return $arg instanceof SomeClass;
         })
     );

Real-world example: The Laravel framework, one of PHP's most popular frameworks, uses test doubles extensively in its own test suite. For instance, they mock the filesystem, database connections, and HTTP clients to ensure that framework components can be tested in isolation.

Using Mockery for Enhanced Mocking

Mockery is a popular mocking library for PHP that offers more advanced features than PHPUnit's built-in mocking capabilities.

Setting Up Mockery

// Install via Composer
composer require --dev mockery/mockery

// In your test class
use Mockery;

class UserServiceTest extends \PHPUnit\Framework\TestCase {
    protected function tearDown(): void {
        Mockery::close();
    }
    
    // Test methods...
}

Creating Mocks with Mockery

public function testGetUserById() {
    // Create a mock for UserRepository
    $repository = Mockery::mock(UserRepository::class);
    
    // Define expectations
    $repository->shouldReceive('findById')
               ->once()
               ->with(1)
               ->andReturn(new User(1, 'John Doe'));
    
    // Create the service with the mock
    $service = new UserService($repository);
    
    // Call the method under test
    $result = $service->getUserById(1);
    
    // Make assertions
    $this->assertEquals('John Doe', $result->getName());
}

Advanced Mockery Features

// Partial mocks - mock only specific methods
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('isAdmin')->andReturn(true);

// Spies - verify interactions after they happen
$repository = Mockery::spy(UserRepository::class);
$service = new UserService($repository);
$service->createUser('John', 'john@example.com');
$repository->shouldHaveReceived('save')->once();

// Method argument constraints
$emailSender->shouldReceive('send')
            ->with(
                'john@example.com',
                Mockery::type('string'),
                Mockery::pattern('/welcome/i')
            );

// Multiple expectations with different arguments
$calculator->shouldReceive('add')
           ->with(1, 2)->andReturn(3)
           ->with(4, 5)->andReturn(9);

// Allowing any method to be called
$mock = Mockery::mock('ClassName')->shouldIgnoreMissing();

// Mocking interfaces and classes that don't exist yet
$mock = Mockery::mock('NonExistentClass');
$mock = Mockery::mock('MyInterface, MyClass');

Mockery Expectation Ordering

// Ordered expectations
$mock = Mockery::mock(ProcessHandler::class);

$mock->shouldReceive('start')->once()->ordered();
$mock->shouldReceive('process')->once()->ordered();
$mock->shouldReceive('finish')->once()->ordered();

// Multiple ordered groups
$mock = Mockery::mock(DB::class);

$mock->shouldReceive('connection')->once()->ordered('connect');
$mock->shouldReceive('select')->once()->ordered('connect');
$mock->shouldReceive('update')->once()->ordered('update');
$mock->shouldReceive('connection')->once()->ordered('reconnect');
$mock->shouldReceive('select')->once()->ordered('reconnect');

Dependency Injection Containers

Dependency injection containers help manage dependencies and their instantiation, making it easier to use dependency injection in large applications.

PHP-DI Container

// Install PHP-DI
composer require php-di/php-di

// Basic usage
use DI\ContainerBuilder;

$containerBuilder = new ContainerBuilder();
$container = $containerBuilder->build();

// Define dependencies
$container->set('database.host', 'localhost');
$container->set('database.user', 'root');
$container->set('database.password', 'secret');

// Define a service using a factory function
$container->set(Database::class, function($container) {
    return new Database(
        $container->get('database.host'),
        $container->get('database.user'),
        $container->get('database.password')
    );
});

// Get an instance
$database = $container->get(Database::class);

Configuration with PHP-DI

// Using a configuration array
$containerBuilder = new ContainerBuilder();
$containerBuilder->addDefinitions([
    'database.host' => 'localhost',
    'database.user' => 'root',
    'database.password' => 'secret',
    
    Database::class => DI\create()
        ->constructor(
            DI\get('database.host'),
            DI\get('database.user'),
            DI\get('database.password')
        ),
    
    UserRepository::class => DI\create()
        ->constructor(DI\get(Database::class)),
    
    UserService::class => DI\create()
        ->constructor(
            DI\get(UserRepository::class),
            DI\get(EmailService::class)
        ),
    
    EmailService::class => DI\autowire()
]);

$container = $containerBuilder->build();

Autowiring

Autowiring automatically resolves dependencies based on type hints:

class UserService {
    private $repository;
    private $emailService;
    
    public function __construct(
        UserRepository $repository,
        EmailService $emailService
    ) {
        $this->repository = $repository;
        $this->emailService = $emailService;
    }
}

// With autowiring, PHP-DI can instantiate this without explicit configuration
$userService = $container->get(UserService::class);

Using Containers in Tests

class UserServiceTest extends TestCase {
    private $container;
    
    protected function setUp(): void {
        parent::setUp();
        
        // Create a test container
        $containerBuilder = new ContainerBuilder();
        $containerBuilder->addDefinitions([
            // Mock dependencies
            UserRepository::class => function() {
                return $this->createMock(UserRepository::class);
            },
            EmailService::class => function() {
                return $this->createMock(EmailService::class);
            }
        ]);
        
        $this->container = $containerBuilder->build();
    }
    
    public function testCreateUser() {
        // Get the mocks from the container
        $repository = $this->container->get(UserRepository::class);
        $emailService = $this->container->get(EmailService::class);
        
        // Configure the mocks
        $repository->method('save')->willReturnArgument(0);
        $emailService->expects($this->once())->method('sendWelcomeEmail');
        
        // Get the service from the container
        $service = $this->container->get(UserService::class);
        
        // Call the method under test
        $user = $service->createUser('John Doe', 'john@example.com');
        
        // Make assertions
        $this->assertEquals('John Doe', $user->getName());
    }
}

Real-world example: Symfony, a major PHP framework, uses a powerful dependency injection container that manages services across the entire application. In testing, developers can easily replace real services with test doubles by modifying the container configuration.

Combining PHPUnit and Dependency Injection

Let's see a complete example of testing a service class with dependency injection and test doubles.

The Code Under Test

// OrderService.php
namespace App\Service;

use App\Repository\ProductRepository;
use App\Repository\OrderRepository;
use App\Entity\Order;
use App\Entity\OrderItem;
use App\Service\PaymentGateway;
use App\Service\EmailService;

class OrderService {
    private $productRepository;
    private $orderRepository;
    private $paymentGateway;
    private $emailService;
    
    public function __construct(
        ProductRepository $productRepository,
        OrderRepository $orderRepository,
        PaymentGateway $paymentGateway,
        EmailService $emailService
    ) {
        $this->productRepository = $productRepository;
        $this->orderRepository = $orderRepository;
        $this->paymentGateway = $paymentGateway;
        $this->emailService = $emailService;
    }
    
    public function placeOrder(int $userId, array $items, array $paymentDetails): Order {
        // Create a new order
        $order = new Order();
        $order->setUserId($userId);
        $order->setStatus('pending');
        
        $totalAmount = 0;
        
        // Add items to order
        foreach ($items as $item) {
            $productId = $item['product_id'];
            $quantity = $item['quantity'];
            
            $product = $this->productRepository->findById($productId);
            if (!$product) {
                throw new \InvalidArgumentException("Product not found: {$productId}");
            }
            
            if ($product->getStockQuantity() < $quantity) {
                throw new \RuntimeException("Insufficient stock for product: {$productId}");
            }
            
            $orderItem = new OrderItem();
            $orderItem->setProductId($productId);
            $orderItem->setQuantity($quantity);
            $orderItem->setPrice($product->getPrice());
            
            $order->addItem($orderItem);
            
            $totalAmount += $product->getPrice() * $quantity;
            
            // Update product stock
            $product->setStockQuantity($product->getStockQuantity() - $quantity);
            $this->productRepository->save($product);
        }
        
        $order->setTotalAmount($totalAmount);
        
        // Process payment
        $paymentResult = $this->paymentGateway->processPayment(
            $paymentDetails['card_number'],
            $paymentDetails['expiry'],
            $paymentDetails['cvv'],
            $totalAmount
        );
        
        if (!$paymentResult['success']) {
            throw new \RuntimeException("Payment failed: " . $paymentResult['message']);
        }
        
        $order->setPaymentId($paymentResult['transaction_id']);
        $order->setStatus('paid');
        
        // Save order
        $this->orderRepository->save($order);
        
        // Send confirmation email
        $this->emailService->sendOrderConfirmation($userId, $order);
        
        return $order;
    }
    
    public function getOrder(int $orderId): ?Order {
        return $this->orderRepository->findById($orderId);
    }
    
    public function cancelOrder(int $orderId): bool {
        $order = $this->orderRepository->findById($orderId);
        
        if (!$order) {
            return false;
        }
        
        if ($order->getStatus() !== 'paid') {
            return false;
        }
        
        // Refund payment
        $refundResult = $this->paymentGateway->refundPayment($order->getPaymentId());
        
        if (!$refundResult['success']) {
            throw new \RuntimeException("Refund failed: " . $refundResult['message']);
        }
        
        // Update order status
        $order->setStatus('cancelled');
        $this->orderRepository->save($order);
        
        // Restore stock
        foreach ($order->getItems() as $item) {
            $product = $this->productRepository->findById($item->getProductId());
            $product->setStockQuantity($product->getStockQuantity() + $item->getQuantity());
            $this->productRepository->save($product);
        }
        
        // Send cancellation email
        $this->emailService->sendOrderCancellation($order->getUserId(), $order);
        
        return true;
    }
}

The Test Class

namespace Tests\Service;

use PHPUnit\Framework\TestCase;
use App\Service\OrderService;
use App\Repository\ProductRepository;
use App\Repository\OrderRepository;
use App\Service\PaymentGateway;
use App\Service\EmailService;
use App\Entity\Product;
use App\Entity\Order;
use App\Entity\OrderItem;

class OrderServiceTest extends TestCase {
    private $productRepository;
    private $orderRepository;
    private $paymentGateway;
    private $emailService;
    private $orderService;
    
    protected function setUp(): void {
        // Create mocks for all dependencies
        $this->productRepository = $this->createMock(ProductRepository::class);
        $this->orderRepository = $this->createMock(OrderRepository::class);
        $this->paymentGateway = $this->createMock(PaymentGateway::class);
        $this->emailService = $this->createMock(EmailService::class);
        
        // Create service with mocked dependencies
        $this->orderService = new OrderService(
            $this->productRepository,
            $this->orderRepository,
            $this->paymentGateway,
            $this->emailService
        );
    }
    
    public function testPlaceOrderSuccessfully() {
        // Create test data
        $userId = 1;
        $items = [
            ['product_id' => 101, 'quantity' => 2],
            ['product_id' => 102, 'quantity' => 1]
        ];
        $paymentDetails = [
            'card_number' => '4111111111111111',
            'expiry' => '12/25',
            'cvv' => '123'
        ];
        
        // Create test products
        $product1 = new Product();
        $product1->setId(101);
        $product1->setName('Product 1');
        $product1->setPrice(10.99);
        $product1->setStockQuantity(5);
        
        $product2 = new Product();
        $product2->setId(102);
        $product2->setName('Product 2');
        $product2->setPrice(24.99);
        $product2->setStockQuantity(3);
        
        // Configure the product repository mock
        $this->productRepository->expects($this->exactly(2))
            ->method('findById')
            ->willReturnMap([
                [101, $product1],
                [102, $product2]
            ]);
        
        // Product repository should save the updated products
        $this->productRepository->expects($this->exactly(2))
            ->method('save')
            ->withConsecutive(
                [$this->callback(function($product) {
                    return $product->getId() === 101 && $product->getStockQuantity() === 3;
                })],
                [$this->callback(function($product) {
                    return $product->getId() === 102 && $product->getStockQuantity() === 2;
                })]
            );
        
        // Payment gateway should process payment once
        $this->paymentGateway->expects($this->once())
            ->method('processPayment')
            ->with(
                $this->equalTo('4111111111111111'),
                $this->equalTo('12/25'),
                $this->equalTo('123'),
                $this->equalTo(46.97) // 10.99*2 + 24.99
            )
            ->willReturn([
                'success' => true,
                'transaction_id' => 'txn_123456'
            ]);
        
        // Order repository should save the order
        $this->orderRepository->expects($this->once())
            ->method('save')
            ->with($this->callback(function($order) {
                return $order instanceof Order
                    && $order->getUserId() === 1
                    && $order->getStatus() === 'paid'
                    && $order->getTotalAmount() === 46.97
                    && $order->getPaymentId() === 'txn_123456'
                    && count($order->getItems()) === 2;
            }))
            ->willReturnCallback(function($order) {
                $order->setId(1);
                return $order;
            });
        
        // Email service should send confirmation
        $this->emailService->expects($this->once())
            ->method('sendOrderConfirmation')
            ->with(
                $this->equalTo(1),
                $this->isInstanceOf(Order::class)
            );
        
        // Call the method under test
        $order = $this->orderService->placeOrder($userId, $items, $paymentDetails);
        
        // Make assertions about the result
        $this->assertInstanceOf(Order::class, $order);
        $this->assertEquals(1, $order->getUserId());
        $this->assertEquals('paid', $order->getStatus());
        $this->assertEquals(46.97, $order->getTotalAmount());
        $this->assertEquals('txn_123456', $order->getPaymentId());
        $this->assertCount(2, $order->getItems());
    }
    
    public function testPlaceOrderWithInsufficientStock() {
        // Create test data
        $userId = 1;
        $items = [
            ['product_id' => 101, 'quantity' => 10] // Requesting 10, but only 5 in stock
        ];
        $paymentDetails = [
            'card_number' => '4111111111111111',
            'expiry' => '12/25',
            'cvv' => '123'
        ];
        
        // Create test product with limited stock
        $product = new Product();
        $product->setId(101);
        $product->setName('Product 1');
        $product->setPrice(10.99);
        $product->setStockQuantity(5); // Only 5 in stock
        
        // Configure the product repository mock
        $this->productRepository->expects($this->once())
            ->method('findById')
            ->with($this->equalTo(101))
            ->willReturn($product);
        
        // Payment gateway should not be called
        $this->paymentGateway->expects($this->never())
            ->method('processPayment');
        
        // Order repository should not be called
        $this->orderRepository->expects($this->never())
            ->method('save');
        
        // Email service should not be called
        $this->emailService->expects($this->never())
            ->method('sendOrderConfirmation');
        
        // Expect an exception
        $this->expectException(\RuntimeException::class);
        $this->expectExceptionMessage('Insufficient stock for product: 101');
        
        // Call the method under test
        $this->orderService->placeOrder($userId, $items, $paymentDetails);
    }
    
    public function testPlaceOrderWithPaymentFailure() {
        // Create test data
        $userId = 1;
        $items = [
            ['product_id' => 101, 'quantity' => 2]
        ];
        $paymentDetails = [
            'card_number' => '4111111111111111',
            'expiry' => '12/25',
            'cvv' => '123'
        ];
        
        // Create test product
        $product = new Product();
        $product->setId(101);
        $product->setName('Product 1');
        $product->setPrice(10.99);
        $product->setStockQuantity(5);
        
        // Configure the product repository mock
        $this->productRepository->expects($this->once())
            ->method('findById')
            ->with($this->equalTo(101))
            ->willReturn($product);
        
        // Product repository should save the updated product
        $this->productRepository->expects($this->once())
            ->method('save')
            ->with($this->callback(function($product) {
                return $product->getId() === 101 && $product->getStockQuantity() === 3;
            }));
        
        // Payment gateway should fail
        $this->paymentGateway->expects($this->once())
            ->method('processPayment')
            ->willReturn([
                'success' => false,
                'message' => 'Insufficient funds'
            ]);
        
        // Order repository should not be called
        $this->orderRepository->expects($this->never())
            ->method('save');
        
        // Email service should not be called
        $this->emailService->expects($this->never())
            ->method('sendOrderConfirmation');
        
        // Expect an exception
        $this->expectException(\RuntimeException::class);
        $this->expectExceptionMessage('Payment failed: Insufficient funds');
        
        // Call the method under test
        $this->orderService->placeOrder($userId, $items, $paymentDetails);
    }
    
    public function testCancelOrder() {
        // Create a test order
        $orderId = 1;
        $order = new Order();
        $order->setId($orderId);
        $order->setUserId(1);
        $order->setStatus('paid');
        $order->setPaymentId('txn_123456');
        $order->setTotalAmount(46.97);
        
        // Add items to order
        $item1 = new OrderItem();
        $item1->setProductId(101);
        $item1->setQuantity(2);
        $item1->setPrice(10.99);
        
        $item2 = new OrderItem();
        $item2->setProductId(102);
        $item2->setQuantity(1);
        $item2->setPrice(24.99);
        
        $order->addItem($item1);
        $order->addItem($item2);
        
        // Create products for the test
        $product1 = new Product();
        $product1->setId(101);
        $product1->setStockQuantity(3);
        
        $product2 = new Product();
        $product2->setId(102);
        $product2->setStockQuantity(2);
        
        // Configure repository to return the order
        $this->orderRepository->expects($this->once())
            ->method('findById')
            ->with($this->equalTo($orderId))
            ->willReturn($order);
        
        // Configure payment gateway to refund
        $this->paymentGateway->expects($this->once())
            ->method('refundPayment')
            ->with($this->equalTo('txn_123456'))
            ->willReturn([
                'success' => true
            ]);
        
        // Order should be saved with cancelled status
        $this->orderRepository->expects($this->once())
            ->method('save')
            ->with($this->callback(function($order) {
                return $order->getStatus() === 'cancelled';
            }));
        
        // Configure product repository for stock updates
        $this->productRepository->expects($this->exactly(2))
            ->method('findById')
            ->willReturnMap([
                [101, $product1],
                [102, $product2]
            ]);
        
        // Product repository should save updated products
        $this->productRepository->expects($this->exactly(2))
            ->method('save')
            ->withConsecutive(
                [$this->callback(function($product) {
                    return $product->getId() === 101 && $product->getStockQuantity() === 5;
                })],
                [$this->callback(function($product) {
                    return $product->getId() === 102 && $product->getStockQuantity() === 3;
                })]
            );
        
        // Email service should send cancellation
        $this->emailService->expects($this->once())
            ->method('sendOrderCancellation')
            ->with(
                $this->equalTo(1),
                $this->isInstanceOf(Order::class)
            );
        
        // Call the method under test
        $result = $this->orderService->cancelOrder($orderId);
        
        // Assert the result
        $this->assertTrue($result);
    }
}

Best Practices for Test Doubles

General Best Practices

Anti-Patterns to Avoid

Testing Pyramid

classDiagram class "End-to-End Tests" { Few Slow Realistic } class "Integration Tests" { Some Medium Speed Some Real Dependencies } class "Unit Tests" { Many Fast Isolated with Test Doubles } "End-to-End Tests" --|> "Integration Tests" "Integration Tests" --|> "Unit Tests"

The testing pyramid emphasizes having many fast unit tests (often using test doubles), fewer integration tests (with some real dependencies), and a small number of end-to-end tests (with mostly real dependencies).

Real-world example: The Symfony framework uses this approach in its testing strategy. Their test suite includes thousands of unit tests with mocked dependencies, hundreds of integration tests that test multiple components together, and a smaller number of functional tests that test entire request/response cycles.

Adapting to Legacy Code

Working with legacy code that wasn't designed for testing presents unique challenges.

Identifying Seams

Seams are places where you can alter behavior in your program without editing the code. They're crucial for testing legacy code.

// Original code with no seams
class OrderProcessor {
    public function processOrder($orderId) {
        $orderData = Database::getInstance()->query("SELECT * FROM orders WHERE id = $orderId");
        $customerData = Database::getInstance()->query("SELECT * FROM customers WHERE id = {$orderData['customer_id']}");
        
        $emailSender = new EmailSender();
        $emailSender->sendOrderConfirmation($customerData['email'], $orderData);
        
        return true;
    }
}

Adding Seams for Testing

// Adding seams through dependency injection
class OrderProcessor {
    private $database;
    private $emailSender;
    
    public function __construct($database = null, $emailSender = null) {
        $this->database = $database ?: Database::getInstance();
        $this->emailSender = $emailSender ?: new EmailSender();
    }
    
    public function processOrder($orderId) {
        $orderData = $this->database->query("SELECT * FROM orders WHERE id = $orderId");
        $customerData = $this->database->query("SELECT * FROM customers WHERE id = {$orderData['customer_id']}");
        
        $this->emailSender->sendOrderConfirmation($customerData['email'], $orderData);
        
        return true;
    }
}

Extracting Interfaces

// Define interfaces for dependencies
interface DatabaseInterface {
    public function query($sql);
}

interface EmailSenderInterface {
    public function sendOrderConfirmation($email, $orderData);
}

// Implement adapter for legacy code
class DatabaseAdapter implements DatabaseInterface {
    private $database;
    
    public function __construct() {
        $this->database = Database::getInstance();
    }
    
    public function query($sql) {
        return $this->database->query($sql);
    }
}

// Update class to use interfaces
class OrderProcessor {
    private $database;
    private $emailSender;
    
    public function __construct(
        DatabaseInterface $database,
        EmailSenderInterface $emailSender
    ) {
        $this->database = $database;
        $this->emailSender = $emailSender;
    }
    
    // Method implementation...
}

Testing Legacy Code

class OrderProcessorTest extends TestCase {
    public function testProcessOrder() {
        // Create mocks
        $database = $this->createMock(DatabaseInterface::class);
        $emailSender = $this->createMock(EmailSenderInterface::class);
        
        // Configure database mock
        $database->expects($this->exactly(2))
            ->method('query')
            ->willReturnOnConsecutiveCalls(
                ['id' => 1, 'customer_id' => 42, 'total' => 99.99],
                ['id' => 42, 'email' => 'customer@example.com', 'name' => 'John Doe']
            );
        
        // Configure email sender mock
        $emailSender->expects($this->once())
            ->method('sendOrderConfirmation')
            ->with(
                $this->equalTo('customer@example.com'),
                $this->equalTo(['id' => 1, 'customer_id' => 42, 'total' => 99.99])
            );
        
        // Create processor with mocks
        $processor = new OrderProcessor($database, $emailSender);
        
        // Call the method
        $result = $processor->processOrder(1);
        
        // Verify result
        $this->assertTrue($result);
    }
}

Practical Exercise

Exercise: Testing a Payment Processor

Let's practice by testing a payment processor that interacts with a payment gateway API and other dependencies:

// PaymentProcessor.php
namespace App\Service;

use App\Repository\OrderRepository;
use App\Repository\PaymentRepository;
use App\Entity\Payment;
use App\Gateway\PaymentGatewayInterface;
use App\Service\NotificationService;
use App\Exception\PaymentException;

class PaymentProcessor {
    private $orderRepository;
    private $paymentRepository;
    private $paymentGateway;
    private $notificationService;
    
    public function __construct(
        OrderRepository $orderRepository,
        PaymentRepository $paymentRepository,
        PaymentGatewayInterface $paymentGateway,
        NotificationService $notificationService
    ) {
        $this->orderRepository = $orderRepository;
        $this->paymentRepository = $paymentRepository;
        $this->paymentGateway = $paymentGateway;
        $this->notificationService = $notificationService;
    }
    
    public function processPayment(int $orderId, array $paymentData): Payment {
        // Get order from repository
        $order = $this->orderRepository->findById($orderId);
        if (!$order) {
            throw new PaymentException("Order not found: $orderId");
        }
        
        // Verify order is in correct state
        if ($order->getStatus() !== 'pending') {
            throw new PaymentException("Order is not in pending state");
        }
        
        // Validate payment data
        if (empty($paymentData['card_number']) || strlen($paymentData['card_number']) < 13) {
            throw new PaymentException("Invalid card number");
        }
        
        if (empty($paymentData['card_holder']) || strlen($paymentData['card_holder']) < 3) {
            throw new PaymentException("Invalid card holder name");
        }
        
        if (empty($paymentData['expiry']) || !preg_match('/^\d{2}\/\d{2}$/', $paymentData['expiry'])) {
            throw new PaymentException("Invalid expiry date");
        }
        
        if (empty($paymentData['cvv']) || !preg_match('/^\d{3,4}$/', $paymentData['cvv'])) {
            throw new PaymentException("Invalid CVV");
        }
        
        // Create payment record
        $payment = new Payment();
        $payment->setOrderId($orderId);
        $payment->setAmount($order->getTotalAmount());
        $payment->setStatus('processing');
        $payment->setCreatedAt(new \DateTime());
        
        // Save initial payment record
        $this->paymentRepository->save($payment);
        
        try {
            // Process payment through gateway
            $gatewayResponse = $this->paymentGateway->chargePayment(
                $paymentData['card_number'],
                $paymentData['card_holder'],
                $paymentData['expiry'],
                $paymentData['cvv'],
                $order->getTotalAmount()
            );
            
            if (!$gatewayResponse['success']) {
                // Payment failed
                $payment->setStatus('failed');
                $payment->setErrorMessage($gatewayResponse['message']);
                $this->paymentRepository->save($payment);
                
                // Notify about payment failure
                $this->notificationService->sendPaymentFailedNotification(
                    $order->getCustomerEmail(),
                    $order->getId(),
                    $gatewayResponse['message']
                );
                
                throw new PaymentException("Payment failed: " . $gatewayResponse['message']);
            }
            
            // Payment succeeded
            $payment->setStatus('completed');
            $payment->setTransactionId($gatewayResponse['transaction_id']);
            $payment->setCompletedAt(new \DateTime());
            $this->paymentRepository->save($payment);
            
            // Update order status
            $order->setStatus('paid');
            $order->setPaymentId($payment->getId());
            $this->orderRepository->save($order);
            
            // Send confirmation notification
            $this->notificationService->sendPaymentConfirmationNotification(
                $order->getCustomerEmail(),
                $order->getId(),
                $payment->getId()
            );
            
            return $payment;
        } catch (\Exception $e) {
            if ($payment->getStatus() !== 'failed') {
                // Update payment status if not already marked as failed
                $payment->setStatus('error');
                $payment->setErrorMessage($e->getMessage());
                $this->paymentRepository->save($payment);
            }
            
            throw new PaymentException("Payment processing error: " . $e->getMessage());
        }
    }
    
    public function getPayment(int $paymentId): ?Payment {
        return $this->paymentRepository->findById($paymentId);
    }
    
    public function refundPayment(int $paymentId): bool {
        $payment = $this->paymentRepository->findById($paymentId);
        
        if (!$payment) {
            throw new PaymentException("Payment not found: $paymentId");
        }
        
        if ($payment->getStatus() !== 'completed') {
            throw new PaymentException("Payment is not in completed state");
        }
        
        $transactionId = $payment->getTransactionId();
        
        try {
            // Process refund through gateway
            $refundResponse = $this->paymentGateway->refundPayment($transactionId);
            
            if (!$refundResponse['success']) {
                throw new PaymentException("Refund failed: " . $refundResponse['message']);
            }
            
            // Update payment status
            $payment->setStatus('refunded');
            $payment->setRefundedAt(new \DateTime());
            $this->paymentRepository->save($payment);
            
            // Get the order
            $order = $this->orderRepository->findById($payment->getOrderId());
            
            // Update order status
            $order->setStatus('refunded');
            $this->orderRepository->save($order);
            
            // Send refund notification
            $this->notificationService->sendRefundNotification(
                $order->getCustomerEmail(),
                $order->getId(),
                $payment->getId()
            );
            
            return true;
        } catch (\Exception $e) {
            throw new PaymentException("Refund processing error: " . $e->getMessage());
        }
    }
}

Your task:

  1. Create a comprehensive test suite for the PaymentProcessor class
  2. Use mocks for all dependencies (repositories, gateway, notification service)
  3. Test both success and failure scenarios for payment processing
  4. Test refund functionality
  5. Verify appropriate interactions with dependencies

Summary

Remember: Test doubles are tools to help you write better tests, not ends in themselves. Use them judiciously to create focused, reliable tests that verify behavior without being overly coupled to implementation details.

Assignment

Design and implement a complete e-commerce order processing system with the following components:

  1. Core classes:
    • Product (id, name, price, description, inventory count)
    • Customer (id, name, email, address)
    • Order (id, customer, items, totals, status)
    • OrderItem (product, quantity, price)
    • Payment (order, amount, status, transaction details)
  2. Services:
    • CartService (manages shopping cart)
    • OrderService (creates and manages orders)
    • PaymentService (processes payments)
    • InventoryService (manages product inventory)
    • NotificationService (sends emails and notifications)
  3. Repositories:
    • ProductRepository
    • CustomerRepository
    • OrderRepository
    • PaymentRepository
  4. External interfaces:
    • PaymentGateway
    • ShippingProvider
    • EmailProvider
  5. Write a comprehensive test suite that:
    • Uses dependency injection for all services
    • Uses appropriate test doubles for all dependencies
    • Tests both success and failure scenarios
    • Verifies the correct integration between components
    • Tests the entire order flow from cart to completion

Bonus challenge: Implement a dependency injection container to manage service instantiation and demonstrate how it can be used in both production and testing contexts.