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.
Types of Test Doubles
- Dummy - Objects that are passed around but never actually used
- Stub - Objects that provide predefined responses to method calls
- Spy - Objects that record how they were used for later verification
- Mock - Objects with pre-programmed expectations about how they should be used
- Fake - Objects with working implementations but unsuitable for production
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
- Constructor Injection - Dependencies are provided through the constructor
- Setter Injection - Dependencies are set through setter methods
- Method Injection - Dependencies are provided to specific methods that need them
Benefits of Dependency Injection:
- Makes code more testable by allowing dependencies to be replaced
- Improves flexibility by supporting different implementations
- Makes dependencies explicit rather than hidden
- Supports the Single Responsibility and Dependency Inversion principles
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
- Only mock what you need - Don't mock classes under test or value objects
- Keep test doubles simple - Use the simplest type of test double that meets your needs
- Avoid excessive mocking - Too many mocks can make tests brittle and hard to understand
- Use interfaces - Mock interfaces rather than concrete classes when possible
- Test behaviors, not implementations - Focus on verifying the behavior, not the internal details
Anti-Patterns to Avoid
- Mock-happy testing - Mocking everything leads to tests that don't test much at all
- Implementation testing - Testing how something is implemented rather than what it does
- Testing too many interactions - Verifying every method call makes tests brittle
- Mocking concrete classes - Prefer interfaces or abstract classes for test doubles
- Mocking value objects - Use real value objects instead of mocking them
Testing Pyramid
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:
- Create a comprehensive test suite for the PaymentProcessor class
- Use mocks for all dependencies (repositories, gateway, notification service)
- Test both success and failure scenarios for payment processing
- Test refund functionality
- Verify appropriate interactions with dependencies
Summary
- Test doubles (dummies, stubs, spies, mocks, fakes) help isolate code under test
- Dependency injection improves testability by making dependencies explicit
- PHPUnit provides built-in support for creating test doubles
- Mockery offers enhanced mocking capabilities for more complex scenarios
- Dependency injection containers help manage dependencies in larger applications
- Best practices include mocking only what you need and focusing on behavior over implementation
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:
- 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)
- Services:
- CartService (manages shopping cart)
- OrderService (creates and manages orders)
- PaymentService (processes payments)
- InventoryService (manages product inventory)
- NotificationService (sends emails and notifications)
- Repositories:
- ProductRepository
- CustomerRepository
- OrderRepository
- PaymentRepository
- External interfaces:
- PaymentGateway
- ShippingProvider
- EmailProvider
- 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.