1. php
  2. /testing
  3. /tdd

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

  1. Better Design: Writing tests first forces you to think about the API design
  2. Higher Coverage: TDD naturally leads to higher test coverage
  3. Confidence: Comprehensive tests give confidence when making changes
  4. Documentation: Tests serve as living documentation of the code's behavior
  5. 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.