Test-Driven Development (TDD) in PHP
Introduction to Test-Driven Development
Test-Driven Development (TDD) is a software development methodology where tests are written before the actual code. TDD follows the "Red-Green-Refactor" cycle, ensuring high code quality and comprehensive test coverage.
Why TDD Matters
Design First: Writing tests first forces you to think about the interface and behavior before implementation. This leads to better API design and clearer requirements understanding.
Built-in Regression Testing: Every feature has tests from the start, creating a safety net that catches bugs immediately when code changes.
Living Documentation: Tests serve as executable documentation, showing exactly how the code should be used and what it should do.
Confidence in Refactoring: With comprehensive test coverage, you can refactor fearlessly, knowing tests will catch any breaking changes.
The Philosophy Behind TDD
TDD isn't just about testing - it's about design and development discipline. By writing tests first:
- You define the desired behavior precisely
- You create minimal, focused implementations
- You avoid over-engineering and unnecessary features
- You maintain a rapid feedback loop
The Red-Green-Refactor Cycle
The heart of TDD is a simple three-step cycle that drives development:
1. Red - Write a Failing Test
Write a test for functionality that doesn't exist yet. The test should fail.
<?php
// Step 1: Write a failing test
class CalculatorTest extends PHPUnit\Framework\TestCase
{
public function testAddition()
{
$calculator = new Calculator();
$result = $calculator->add(2, 3);
$this->assertEquals(5, $result);
}
}
// At this point, Calculator class doesn't exist, so test fails (RED)
?>
Red Phase Explained:
Purpose: Define the expected behavior through a test before any implementation exists.
Key Principles:
- Write the simplest test that could possibly fail
- Focus on one behavior at a time
- Use descriptive test names that explain the requirement
- Run the test to ensure it fails for the right reason
Common Mistakes:
- Writing too many tests at once
- Creating complex test scenarios initially
- Not running the test to verify it fails
2. Green - Write Minimal Code to Pass
Write the simplest code possible to make the test pass.
<?php
// Step 2: Write minimal implementation
class Calculator
{
public function add($a, $b)
{
return 5; // Hardcoded to make test pass
}
}
// Test now passes (GREEN)
?>
Green Phase Explained:
Purpose: Make the test pass as quickly as possible, even with imperfect code.
Why Hardcode?:
- Forces you to write more tests to drive the real implementation
- Prevents over-engineering
- Keeps focus on current requirement
- Demonstrates that tests are actually driving the code
Guidelines:
- Don't write more code than necessary
- Ignore code quality temporarily
- Focus only on making the red test green
- Resist the urge to implement the "right" solution
3. Refactor - Improve the Code
Improve the implementation while keeping tests passing.
<?php
// Step 3: Refactor to proper implementation
class Calculator
{
public function add($a, $b)
{
return $a + $b; // Proper implementation
}
}
// Test still passes, but code is now correct
?>
Refactor Phase Explained:
Purpose: Improve code quality without changing behavior.
Refactoring Opportunities:
- Remove duplication
- Improve naming
- Extract methods or classes
- Apply design patterns
- Optimize performance
Safety Net: Tests ensure refactoring doesn't break functionality. If tests fail during refactoring, you've changed behavior, not just structure.
TDD Example: Building a User Registration System
Let's build a complete feature using TDD methodology:
Iteration 1: Basic User Creation
<?php
// RED: Write failing test
class UserRegistrationTest extends PHPUnit\Framework\TestCase
{
public function testCreateUser()
{
$registration = new UserRegistration();
$user = $registration->register('[email protected]', 'password123');
$this->assertInstanceOf(User::class, $user);
$this->assertEquals('[email protected]', $user->getEmail());
}
}
// GREEN: Minimal implementation
class UserRegistration
{
public function register($email, $password)
{
return new User($email);
}
}
class User
{
private $email;
public function __construct($email)
{
$this->email = $email;
}
public function getEmail()
{
return $this->email;
}
}
?>
First Iteration Analysis:
Test Design:
- Tests the happy path first
- Verifies both object type and data
- Uses clear, specific assertions
Implementation Strategy:
- Ignores password initially (not tested yet)
- Creates minimal User class
- Implements only what tests require
Next Steps Clear: The test passes but password handling is missing, driving the next test.
Iteration 2: Email Validation
<?php
// RED: Add email validation test
public function testInvalidEmailThrowsException()
{
$registration = new UserRegistration();
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Invalid email address');
$registration->register('invalid-email', 'password123');
}
// GREEN: Add validation
class UserRegistration
{
public function register($email, $password)
{
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException('Invalid email address');
}
return new User($email);
}
}
?>
Validation Addition Process:
Test-First Benefits:
- Clear specification of error behavior
- Specific exception type and message defined
- Edge case handled before it's a bug
Implementation Evolution:
- Validation added only when test requires it
- No premature optimization
- Clear error messages from the start
Iteration 3: Password Requirements
<?php
// RED: Add password validation tests
public function testShortPasswordThrowsException()
{
$registration = new UserRegistration();
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Password must be at least 8 characters');
$registration->register('[email protected]', '123');
}
public function testPasswordWithoutNumberThrowsException()
{
$registration = new UserRegistration();
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Password must contain at least one number');
$registration->register('[email protected]', 'password');
}
// GREEN: Implement password validation
class UserRegistration
{
public function register($email, $password)
{
$this->validateEmail($email);
$this->validatePassword($password);
return new User($email, $password);
}
private function validateEmail($email)
{
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException('Invalid email address');
}
}
private function validatePassword($password)
{
if (strlen($password) < 8) {
throw new InvalidArgumentException('Password must be at least 8 characters');
}
if (!preg_match('/\d/', $password)) {
throw new InvalidArgumentException('Password must contain at least one number');
}
}
}
// REFACTOR: Update User class
class User
{
private $email;
private $passwordHash;
public function __construct($email, $password)
{
$this->email = $email;
$this->passwordHash = password_hash($password, PASSWORD_DEFAULT);
}
public function getEmail()
{
return $this->email;
}
public function verifyPassword($password)
{
return password_verify($password, $this->passwordHash);
}
}
?>
Complex Requirements Handling:
Multiple Test Cases:
- Each requirement gets its own test
- Tests are specific and focused
- Error messages are descriptive
Refactoring Emergence:
- Extract methods appear naturally
- Single Responsibility Principle emerges
- Code organization improves organically
Security by Design:
- Password hashing implemented when passwords are first handled
- Security isn't an afterthought
- Tests ensure security measures work
Iteration 4: Database Persistence
<?php
// RED: Add persistence test
public function testUserIsSavedToDatabase()
{
$mockRepository = $this->createMock(UserRepository::class);
$mockRepository->expects($this->once())
->method('save')
->with($this->isInstanceOf(User::class))
->willReturn(123);
$registration = new UserRegistration($mockRepository);
$user = $registration->register('[email protected]', 'password123');
$this->assertEquals(123, $user->getId());
}
// GREEN: Add repository dependency
class UserRegistration
{
private $repository;
public function __construct(UserRepository $repository = null)
{
$this->repository = $repository ?: new UserRepository();
}
public function register($email, $password)
{
$this->validateEmail($email);
$this->validatePassword($password);
$user = new User($email, $password);
$id = $this->repository->save($user);
$user->setId($id);
return $user;
}
// ... validation methods remain the same
}
class UserRepository
{
public function save(User $user)
{
// Database save implementation
return 123; // Mock ID for now
}
}
?>
Dependency Introduction:
Mock Usage:
- Tests remain fast (no real database)
- Behavior verification through expectations
- Clear interface definition
- Tests drive dependency design
Dependency Injection:
- Constructor injection emerges from testing needs
- Optional dependency with default
- Testability drives good design
Interface Discovery:
- Repository interface emerges from mock
- Clear separation of concerns
- Database details isolated
TDD Best Practices
Write Descriptive Test Names
<?php
class UserRegistrationTest extends PHPUnit\Framework\TestCase
{
// Good: Descriptive test names
public function testRegistrationWithValidEmailAndPasswordCreatesUser()
{
// Test implementation
}
public function testRegistrationWithInvalidEmailThrowsException()
{
// Test implementation
}
public function testRegistrationWithShortPasswordThrowsException()
{
// Test implementation
}
// Bad: Vague test names
public function testRegistration()
{
// Not clear what this tests
}
public function testUser()
{
// Too generic
}
}
?>
Naming Guidelines:
Structure: testMethodUnderTest_Scenario_ExpectedBehavior
Benefits of Good Names:
- Self-documenting tests
- Clear test failure messages
- Easier test maintenance
- Better test organization
Common Patterns:
test[Method]With[Input]Returns[Output]
test[Method]When[Condition]Throws[Exception]
test[Method]For[Scenario]Should[Behavior]
One Assertion Per Test
<?php
// Good: Single assertion
public function testUserEmailIsSetCorrectly()
{
$user = new User('[email protected]', 'password123');
$this->assertEquals('[email protected]', $user->getEmail());
}
public function testUserPasswordIsHashed()
{
$user = new User('[email protected]', 'password123');
$this->assertTrue($user->verifyPassword('password123'));
}
// Acceptable: Related assertions
public function testUserConstructorSetsProperties()
{
$user = new User('[email protected]', 'password123');
$this->assertEquals('[email protected]', $user->getEmail());
$this->assertNotNull($user->getPasswordHash());
}
// Bad: Multiple unrelated assertions
public function testUserFunctionality()
{
$user = new User('[email protected]', 'password123');
$this->assertEquals('[email protected]', $user->getEmail()); // Email test
$this->assertTrue($user->verifyPassword('password123')); // Password test
$this->assertNull($user->getLastLogin()); // Login test
// If first assertion fails, we don't know about the others
}
?>
Single Assertion Principle:
Why One Assertion?:
- Clear failure messages
- Pinpoints exact problem
- Faster debugging
- Better test isolation
Exceptions to the Rule:
- Testing object construction
- Verifying related state changes
- Complex assertions that logically belong together
Refactoring Multiple Assertions:
- Extract to helper methods
- Create custom assertions
- Split into multiple tests
Test Edge Cases
<?php
class StringUtilsTest extends PHPUnit\Framework\TestCase
{
public function testTruncateWithNormalString()
{
$utils = new StringUtils();
$result = $utils->truncate('Hello World', 5);
$this->assertEquals('Hello...', $result);
}
public function testTruncateWithEmptyString()
{
$utils = new StringUtils();
$result = $utils->truncate('', 5);
$this->assertEquals('', $result);
}
public function testTruncateWithNullString()
{
$utils = new StringUtils();
$result = $utils->truncate(null, 5);
$this->assertEquals('', $result);
}
public function testTruncateWithStringLongerThanLimit()
{
$utils = new StringUtils();
$result = $utils->truncate('This is a very long string', 10);
$this->assertEquals('This is a ...', $result);
}
public function testTruncateWithStringShorterThanLimit()
{
$utils = new StringUtils();
$result = $utils->truncate('Short', 10);
$this->assertEquals('Short', $result);
}
public function testTruncateWithZeroLimit()
{
$utils = new StringUtils();
$result = $utils->truncate('Hello', 0);
$this->assertEquals('...', $result);
}
public function testTruncateWithNegativeLimit()
{
$utils = new StringUtils();
$this->expectException(InvalidArgumentException::class);
$utils->truncate('Hello', -1);
}
}
?>
Edge Case Categories:
Boundary Values:
- Zero, negative numbers
- Empty strings, null values
- Maximum/minimum values
- Off-by-one scenarios
Invalid Inputs:
- Wrong types
- Malformed data
- Missing required values
- Security attack vectors
Special Cases:
- Unicode/multibyte strings
- Special characters
- Timezone boundaries
- Locale-specific behavior
TDD with Mocks and Dependencies
Shopping Cart Example
<?php
// RED: Write test for shopping cart
class ShoppingCartTest extends PHPUnit\Framework\TestCase
{
public function testAddItemIncreasesTotalPrice()
{
$mockPriceCalculator = $this->createMock(PriceCalculator::class);
$mockPriceCalculator->method('getPrice')
->willReturn(29.99);
$cart = new ShoppingCart($mockPriceCalculator);
$cart->addItem('PRODUCT001', 2);
$this->assertEquals(59.98, $cart->getTotal());
}
public function testAddItemWithDiscountAppliesDiscount()
{
$mockPriceCalculator = $this->createMock(PriceCalculator::class);
$mockPriceCalculator->method('getPrice')
->willReturn(100.00);
$mockDiscountService = $this->createMock(DiscountService::class);
$mockDiscountService->method('calculateDiscount')
->willReturn(10.00);
$cart = new ShoppingCart($mockPriceCalculator, $mockDiscountService);
$cart->addItem('PRODUCT001', 1);
$cart->applyDiscount('SAVE10');
$this->assertEquals(90.00, $cart->getTotal());
}
}
// GREEN: Implement shopping cart
class ShoppingCart
{
private $items = [];
private $priceCalculator;
private $discountService;
private $discountAmount = 0;
public function __construct(PriceCalculator $priceCalculator, DiscountService $discountService = null)
{
$this->priceCalculator = $priceCalculator;
$this->discountService = $discountService;
}
public function addItem($productId, $quantity)
{
if (isset($this->items[$productId])) {
$this->items[$productId] += $quantity;
} else {
$this->items[$productId] = $quantity;
}
}
public function getTotal()
{
$total = 0;
foreach ($this->items as $productId => $quantity) {
$price = $this->priceCalculator->getPrice($productId);
$total += $price * $quantity;
}
return $total - $this->discountAmount;
}
public function applyDiscount($discountCode)
{
if ($this->discountService) {
$this->discountAmount = $this->discountService->calculateDiscount($discountCode, $this->getSubtotal());
}
}
private function getSubtotal()
{
$total = 0;
foreach ($this->items as $productId => $quantity) {
$price = $this->priceCalculator->getPrice($productId);
$total += $price * $quantity;
}
return $total;
}
}
?>
Mocking in TDD:
Mock Benefits:
- Isolate unit under test
- Control external dependencies
- Verify interactions
- Keep tests fast
Design Improvements from Mocking:
- Clear interfaces emerge
- Dependencies become explicit
- Coupling is reduced
- Single responsibility enforced
Mock vs Stub:
- Mocks verify behavior (expectations)
- Stubs provide canned responses
- Use mocks for important interactions
- Use stubs for supporting data
TDD Workflow Tips
Start Small
<?php
// Start with the simplest possible test
class MathUtilsTest extends PHPUnit\Framework\TestCase
{
public function testAdd()
{
$math = new MathUtils();
$result = $math->add(1, 1);
$this->assertEquals(2, $result);
}
}
// Minimal implementation
class MathUtils
{
public function add($a, $b)
{
return $a + $b;
}
}
// Then gradually add complexity
public function testAddWithNegativeNumbers()
{
$math = new MathUtils();
$result = $math->add(-1, 1);
$this->assertEquals(0, $result);
}
public function testAddWithFloats()
{
$math = new MathUtils();
$result = $math->add(1.5, 2.5);
$this->assertEquals(4.0, $result);
}
?>
Refactor Continuously
<?php
// Before refactoring
class OrderProcessor
{
public function processOrder($orderData)
{
// Validate email
if (!filter_var($orderData['email'], FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException('Invalid email');
}
// Validate items
if (empty($orderData['items'])) {
throw new InvalidArgumentException('Order must have items');
}
// Calculate total
$total = 0;
foreach ($orderData['items'] as $item) {
$total += $item['price'] * $item['quantity'];
}
// Save order
$order = new Order($orderData['email'], $orderData['items'], $total);
$this->repository->save($order);
return $order;
}
}
// After refactoring (tests still pass)
class OrderProcessor
{
public function processOrder($orderData)
{
$this->validateOrderData($orderData);
$total = $this->calculateTotal($orderData['items']);
$order = new Order($orderData['email'], $orderData['items'], $total);
$this->repository->save($order);
return $order;
}
private function validateOrderData($orderData)
{
$this->validateEmail($orderData['email']);
$this->validateItems($orderData['items']);
}
private function validateEmail($email)
{
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException('Invalid email');
}
}
private function validateItems($items)
{
if (empty($items)) {
throw new InvalidArgumentException('Order must have items');
}
}
private function calculateTotal($items)
{
$total = 0;
foreach ($items as $item) {
$total += $item['price'] * $item['quantity'];
}
return $total;
}
}
?>
Common TDD Pitfalls
Don't Write Too Much at Once
<?php
// Bad: Writing multiple features at once
public function testCompleteUserManagement()
{
$userManager = new UserManager();
// Too many responsibilities in one test
$user = $userManager->createUser('[email protected]', 'password');
$this->assertInstanceOf(User::class, $user);
$userManager->updateUser($user->getId(), ['name' => 'John Doe']);
$updatedUser = $userManager->getUser($user->getId());
$this->assertEquals('John Doe', $updatedUser->getName());
$userManager->deleteUser($user->getId());
$this->assertNull($userManager->getUser($user->getId()));
}
// Good: One feature at a time
public function testCreateUser()
{
$userManager = new UserManager();
$user = $userManager->createUser('[email protected]', 'password');
$this->assertInstanceOf(User::class, $user);
}
public function testUpdateUser()
{
$userManager = new UserManager();
$user = $userManager->createUser('[email protected]', 'password');
$userManager->updateUser($user->getId(), ['name' => 'John Doe']);
$updatedUser = $userManager->getUser($user->getId());
$this->assertEquals('John Doe', $updatedUser->getName());
}
?>
Don't Skip the Red Phase
<?php
// Always make sure your test fails first
public function testDivisionByZero()
{
$calculator = new Calculator();
$this->expectException(DivisionByZeroException::class);
$calculator->divide(10, 0);
}
// Run this test before implementing the feature to ensure it fails
// If it passes without implementation, the test is not testing what you think
?>
TDD Benefits
- Better Design: Writing tests first forces you to think about the API design
- Higher Coverage: TDD naturally leads to higher test coverage
- Confidence: Comprehensive tests give confidence when making changes
- Documentation: Tests serve as living documentation of the code's behavior
- Regression Prevention: Tests catch bugs when making changes
TDD is a powerful methodology that leads to well-tested, well-designed code. The key is to start small, follow the red-green-refactor cycle religiously, and refactor continuously while keeping tests passing.