1. php
  2. /testing
  3. /code-coverage

Code Coverage in PHP Testing

Introduction to Code Coverage

Code coverage is a metric that measures how much of your source code is executed during testing. It helps identify untested code areas and provides insights into the effectiveness of your test suite.

Types of Code Coverage

Line Coverage

Measures which lines of code are executed during tests.

<?php
// Example function
function calculateDiscount($price, $discountPercent)
{
    if ($price < 0) {                    // Line 1
        throw new InvalidArgumentException(); // Line 2
    }
    
    if ($discountPercent > 100) {        // Line 3
        $discountPercent = 100;          // Line 4
    }
    
    return $price * (1 - $discountPercent / 100); // Line 5
}

// Test that achieves 80% line coverage (4 out of 5 lines)
class DiscountTest extends PHPUnit\Framework\TestCase
{
    public function testValidDiscount()
    {
        $result = calculateDiscount(100, 10); // Executes lines 3, 5
        $this->assertEquals(90, $result);
    }
    
    public function testNegativePrice()
    {
        $this->expectException(InvalidArgumentException::class);
        calculateDiscount(-10, 10); // Executes lines 1, 2
    }
    
    // Missing test for line 4 (discountPercent > 100)
}
?>

Branch Coverage

Measures which decision points (if/else, switch cases) are tested.

<?php
function getUserStatus($user)
{
    if ($user->isActive()) {
        if ($user->isPremium()) {
            return 'premium_active';     // Branch A
        } else {
            return 'regular_active';     // Branch B
        }
    } else {
        return 'inactive';               // Branch C
    }
}

// Tests for 100% branch coverage
class UserStatusTest extends PHPUnit\Framework\TestCase
{
    public function testPremiumActiveUser()
    {
        $user = $this->createMockUser(true, true);
        $this->assertEquals('premium_active', getUserStatus($user)); // Branch A
    }
    
    public function testRegularActiveUser()
    {
        $user = $this->createMockUser(true, false);
        $this->assertEquals('regular_active', getUserStatus($user)); // Branch B
    }
    
    public function testInactiveUser()
    {
        $user = $this->createMockUser(false, false);
        $this->assertEquals('inactive', getUserStatus($user)); // Branch C
    }
    
    private function createMockUser($isActive, $isPremium)
    {
        $mock = $this->createMock(User::class);
        $mock->method('isActive')->willReturn($isActive);
        $mock->method('isPremium')->willReturn($isPremium);
        return $mock;
    }
}
?>

Function Coverage

Measures which functions or methods are called during tests.

<?php
class MathUtils
{
    public function add($a, $b)          // Function 1
    {
        return $a + $b;
    }
    
    public function subtract($a, $b)     // Function 2
    {
        return $a - $b;
    }
    
    public function multiply($a, $b)     // Function 3 - Not tested
    {
        return $a * $b;
    }
}

// Test achieving 66% function coverage (2 out of 3 functions)
class MathUtilsTest extends PHPUnit\Framework\TestCase
{
    public function testAdd()
    {
        $math = new MathUtils();
        $this->assertEquals(5, $math->add(2, 3)); // Function 1 tested
    }
    
    public function testSubtract()
    {
        $math = new MathUtils();
        $this->assertEquals(1, $math->subtract(3, 2)); // Function 2 tested
    }
    
    // multiply() function not tested - reduces function coverage
}
?>

Setting Up Code Coverage

Using Xdebug

# Install Xdebug
pecl install xdebug

# Configure php.ini
zend_extension=xdebug.so
xdebug.mode=coverage

Using PCOV (Faster Alternative)

# Install PCOV
pecl install pcov

# Configure php.ini
extension=pcov.so
pcov.enabled=1

PHPUnit Configuration

<!-- phpunit.xml -->
<phpunit bootstrap="vendor/autoload.php">
    <testsuites>
        <testsuite name="Unit Tests">
            <directory>tests/Unit</directory>
        </testsuite>
    </testsuites>
    
    <coverage processUncoveredFiles="true">
        <include>
            <directory suffix=".php">src</directory>
        </include>
        <exclude>
            <directory suffix=".php">src/Config</directory>
            <file>src/bootstrap.php</file>
        </exclude>
        <report>
            <html outputDirectory="coverage-html"/>
            <text outputFile="coverage.txt"/>
            <clover outputFile="coverage.xml"/>
        </report>
    </coverage>
</phpunit>

Running Coverage Analysis

Command Line

# Generate HTML coverage report
vendor/bin/phpunit --coverage-html coverage-html

# Generate text coverage report
vendor/bin/phpunit --coverage-text

# Generate Clover XML format
vendor/bin/phpunit --coverage-clover coverage.xml

# Generate multiple formats
vendor/bin/phpunit \
  --coverage-html coverage-html \
  --coverage-text \
  --coverage-clover coverage.xml

Programmatic Coverage

<?php
use SebastianBergmann\CodeCoverage\CodeCoverage;
use SebastianBergmann\CodeCoverage\Driver\Selector;
use SebastianBergmann\CodeCoverage\Filter;
use SebastianBergmann\CodeCoverage\Report\Html\Facade as HtmlReport;

class CoverageRunner
{
    private $coverage;
    
    public function __construct()
    {
        $filter = new Filter();
        $filter->includeDirectory(__DIR__ . '/src');
        
        $this->coverage = new CodeCoverage(
            (new Selector())->forLineCoverage($filter),
            $filter
        );
    }
    
    public function startCollection($testName)
    {
        $this->coverage->start($testName);
    }
    
    public function stopCollection()
    {
        $this->coverage->stop();
    }
    
    public function generateReport($outputDir)
    {
        $htmlReport = new HtmlReport();
        $htmlReport->process($this->coverage, $outputDir);
    }
}

// Usage
$coverageRunner = new CoverageRunner();

$coverageRunner->startCollection('UserServiceTest::testCreateUser');
$userService = new UserService();
$user = $userService->createUser('[email protected]');
$coverageRunner->stopCollection();

$coverageRunner->generateReport('coverage-reports');
?>

Coverage Analysis and Interpretation

Reading Coverage Reports

<?php
class OrderProcessor
{
    public function processOrder($order)
    {
        $this->validateOrder($order);           // Line 1 - Covered
        
        if ($order->getType() === 'express') {  // Line 2 - Covered
            $this->processExpressOrder($order); // Line 3 - Not covered
        } else {
            $this->processStandardOrder($order); // Line 4 - Covered
        }
        
        $this->sendConfirmation($order);        // Line 5 - Covered
        
        return true;                            // Line 6 - Covered
    }
    
    private function validateOrder($order) { /* ... */ }
    private function processStandardOrder($order) { /* ... */ }
    private function processExpressOrder($order) { /* ... */ } // Not covered
    private function sendConfirmation($order) { /* ... */ }
}

/*
Coverage Report Analysis:
- Line Coverage: 83% (5/6 lines covered)
- Branch Coverage: 50% (1/2 branches covered)
- Function Coverage: 75% (3/4 functions covered)

Missing Coverage:
- Line 3: processExpressOrder() call
- Express order branch
- processExpressOrder() function
*/
?>

Identifying Uncovered Code

<?php
class PaymentProcessor
{
    public function processPayment($amount, $method)
    {
        if ($amount <= 0) {
            throw new InvalidArgumentException('Amount must be positive');
        }
        
        switch ($method) {
            case 'credit_card':
                return $this->processCreditCard($amount);    // Covered
            case 'paypal':
                return $this->processPayPal($amount);        // Not covered
            case 'bank_transfer':
                return $this->processBankTransfer($amount);  // Not covered
            default:
                throw new UnsupportedPaymentException();     // Not covered
        }
    }
    
    private function processCreditCard($amount) { return ['success' => true]; }
    private function processPayPal($amount) { return ['success' => true]; }     // Not covered
    private function processBankTransfer($amount) { return ['success' => true]; } // Not covered
}

// Current test only covers credit card payments
class PaymentProcessorTest extends PHPUnit\Framework\TestCase
{
    public function testCreditCardPayment()
    {
        $processor = new PaymentProcessor();
        $result = $processor->processPayment(100, 'credit_card');
        $this->assertTrue($result['success']);
    }
    
    // Add these tests to improve coverage:
    
    public function testPayPalPayment()
    {
        $processor = new PaymentProcessor();
        $result = $processor->processPayment(100, 'paypal');
        $this->assertTrue($result['success']);
    }
    
    public function testBankTransferPayment()
    {
        $processor = new PaymentProcessor();
        $result = $processor->processPayment(100, 'bank_transfer');
        $this->assertTrue($result['success']);
    }
    
    public function testUnsupportedPaymentMethod()
    {
        $processor = new PaymentProcessor();
        $this->expectException(UnsupportedPaymentException::class);
        $processor->processPayment(100, 'cryptocurrency');
    }
    
    public function testInvalidAmount()
    {
        $processor = new PaymentProcessor();
        $this->expectException(InvalidArgumentException::class);
        $processor->processPayment(-10, 'credit_card');
    }
}
?>

Coverage Tools and Integration

Coverage Badge Generation

<?php
class CoverageBadgeGenerator
{
    public function generateBadge($coveragePercentage, $outputFile)
    {
        $color = $this->getColorForCoverage($coveragePercentage);
        
        $svg = $this->createSvgBadge($coveragePercentage, $color);
        
        file_put_contents($outputFile, $svg);
    }
    
    private function getColorForCoverage($percentage)
    {
        if ($percentage >= 90) return 'brightgreen';
        if ($percentage >= 80) return 'green';
        if ($percentage >= 70) return 'yellowgreen';
        if ($percentage >= 60) return 'yellow';
        if ($percentage >= 50) return 'orange';
        return 'red';
    }
    
    private function createSvgBadge($percentage, $color)
    {
        return "
        <svg xmlns='http://www.w3.org/2000/svg' width='104' height='20'>
            <linearGradient id='b' x2='0' y2='100%'>
                <stop offset='0' stop-color='#bbb' stop-opacity='.1'/>
                <stop offset='1' stop-opacity='.1'/>
            </linearGradient>
            <mask id='a'>
                <rect width='104' height='20' rx='3' fill='#fff'/>
            </mask>
            <g mask='url(#a)'>
                <path fill='#555' d='M0 0h63v20H0z'/>
                <path fill='{$color}' d='M63 0h41v20H63z'/>
                <path fill='url(#b)' d='M0 0h104v20H0z'/>
            </g>
            <g fill='#fff' text-anchor='middle' font-family='DejaVu Sans,Verdana,Geneva,sans-serif' font-size='110'>
                <text x='325' y='150' fill='#010101' fill-opacity='.3' transform='scale(.1)' textLength='530'>coverage</text>
                <text x='325' y='140' transform='scale(.1)' textLength='530'>coverage</text>
                <text x='835' y='150' fill='#010101' fill-opacity='.3' transform='scale(.1)' textLength='310'>{$percentage}%</text>
                <text x='835' y='140' transform='scale(.1)' textLength='310'>{$percentage}%</text>
            </g>
        </svg>";
    }
}
?>

CI/CD Integration

# GitHub Actions workflow
name: Tests and Coverage

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    
    steps:
    - uses: actions/checkout@v3
    
    - name: Setup PHP
      uses: shivammathur/setup-php@v2
      with:
        php-version: '8.1'
        extensions: mbstring, dom, fileinfo, mysql
        coverage: xdebug
    
    - name: Install dependencies
      run: composer install
    
    - name: Run tests with coverage
      run: vendor/bin/phpunit --coverage-clover=coverage.xml
    
    - name: Upload coverage to Codecov
      uses: codecov/codecov-action@v3
      with:
        file: ./coverage.xml
        flags: unittests
        name: codecov-umbrella
    
    - name: Check coverage threshold
      run: |
        COVERAGE=$(php -r "
          \$xml = simplexml_load_file('coverage.xml');
          \$metrics = \$xml->project->metrics;
          \$percent = (\$metrics['coveredstatements'] / \$metrics['statements']) * 100;
          echo round(\$percent, 2);
        ")
        echo "Coverage: $COVERAGE%"
        if (( $(echo "$COVERAGE < 80" | bc -l) )); then
          echo "Coverage $COVERAGE% is below threshold 80%"
          exit 1
        fi

Automated Coverage Reporting

<?php
class CoverageReporter
{
    private $threshold;
    
    public function __construct($threshold = 80)
    {
        $this->threshold = $threshold;
    }
    
    public function analyzeCoverageReport($cloverFile)
    {
        $xml = simplexml_load_file($cloverFile);
        $metrics = $xml->project->metrics;
        
        $coverage = [
            'statements' => [
                'covered' => (int) $metrics['coveredstatements'],
                'total' => (int) $metrics['statements'],
                'percentage' => $this->calculatePercentage(
                    $metrics['coveredstatements'],
                    $metrics['statements']
                )
            ],
            'methods' => [
                'covered' => (int) $metrics['coveredmethods'],
                'total' => (int) $metrics['methods'],
                'percentage' => $this->calculatePercentage(
                    $metrics['coveredmethods'],
                    $metrics['methods']
                )
            ],
            'lines' => [
                'covered' => (int) $metrics['coveredelements'],
                'total' => (int) $metrics['elements'],
                'percentage' => $this->calculatePercentage(
                    $metrics['coveredelements'],
                    $metrics['elements']
                )
            ]
        ];
        
        return $coverage;
    }
    
    public function checkThreshold($coverage)
    {
        return $coverage['statements']['percentage'] >= $this->threshold;
    }
    
    public function generateReport($coverage)
    {
        $report = "Code Coverage Report\n";
        $report .= "===================\n\n";
        
        $report .= sprintf(
            "Statements: %d/%d (%.2f%%)\n",
            $coverage['statements']['covered'],
            $coverage['statements']['total'],
            $coverage['statements']['percentage']
        );
        
        $report .= sprintf(
            "Methods: %d/%d (%.2f%%)\n",
            $coverage['methods']['covered'],
            $coverage['methods']['total'],
            $coverage['methods']['percentage']
        );
        
        $report .= sprintf(
            "Lines: %d/%d (%.2f%%)\n",
            $coverage['lines']['covered'],
            $coverage['lines']['total'],
            $coverage['lines']['percentage']
        );
        
        $report .= "\n";
        
        if ($this->checkThreshold($coverage)) {
            $report .= "✅ Coverage threshold met ({$this->threshold}%)\n";
        } else {
            $report .= "❌ Coverage below threshold ({$this->threshold}%)\n";
        }
        
        return $report;
    }
    
    private function calculatePercentage($covered, $total)
    {
        return $total > 0 ? ($covered / $total) * 100 : 0;
    }
}

// Usage
$reporter = new CoverageReporter(80);
$coverage = $reporter->analyzeCoverageReport('coverage.xml');
echo $reporter->generateReport($coverage);

if (!$reporter->checkThreshold($coverage)) {
    exit(1); // Fail CI if coverage is below threshold
}
?>

Best Practices for Code Coverage

Meaningful Coverage Goals

<?php
// Don't just aim for 100% - focus on critical paths
class UserAuthenticator
{
    public function authenticate($username, $password)
    {
        // Critical path - must be tested
        if (empty($username) || empty($password)) {
            throw new InvalidCredentialsException();
        }
        
        $user = $this->userRepository->findByUsername($username);
        
        // Critical path - must be tested
        if (!$user || !$this->verifyPassword($password, $user->getPassword())) {
            throw new InvalidCredentialsException();
        }
        
        // Error handling - good to test but less critical
        try {
            $this->logLoginAttempt($username, true);
        } catch (LoggingException $e) {
            // Log error but don't fail authentication
            error_log('Failed to log successful login: ' . $e->getMessage());
        }
        
        return $user;
    }
    
    // Utility method - less critical to test
    private function sanitizeUsername($username)
    {
        return trim(strtolower($username));
    }
}

// Focus tests on critical functionality
class UserAuthenticatorTest extends PHPUnit\Framework\TestCase
{
    // High priority - critical authentication logic
    public function testSuccessfulAuthentication() { /* ... */ }
    public function testFailedAuthenticationInvalidPassword() { /* ... */ }
    public function testFailedAuthenticationInvalidUsername() { /* ... */ }
    public function testEmptyCredentials() { /* ... */ }
    
    // Medium priority - error handling
    public function testLoggingFailureDoesNotAffectAuthentication() { /* ... */ }
    
    // Lower priority - utility methods (can be tested indirectly)
    // sanitizeUsername() gets tested through other tests
}
?>

Excluding Code from Coverage

<?php
class DatabaseConnection
{
    public function connect()
    {
        try {
            $this->pdo = new PDO($this->dsn, $this->username, $this->password);
        } catch (PDOException $e) {
            // @codeCoverageIgnoreStart
            if ($this->environment === 'development') {
                throw new DatabaseException('Database connection failed: ' . $e->getMessage());
            } else {
                throw new DatabaseException('Database connection failed');
            }
            // @codeCoverageIgnoreEnd
        }
    }
    
    // @codeCoverageIgnore
    public function debugDump()
    {
        // Debug method only used in development
        var_dump($this->getConnectionInfo());
    }
}

// Alternative: Use configuration
class ConfigurableService
{
    public function processData($data)
    {
        $result = $this->transform($data);
        
        if ($this->isDebugMode()) {
            // Exclude debug code from coverage requirements
            $this->writeDebugLog($result);
        }
        
        return $result;
    }
    
    private function isDebugMode()
    {
        return $_ENV['APP_DEBUG'] === 'true';
    }
    
    private function writeDebugLog($data)
    {
        // Debug logging - hard to test reliably
        file_put_contents('/tmp/debug.log', print_r($data, true), FILE_APPEND);
    }
}
?>

Coverage vs. Quality

<?php
// High coverage doesn't guarantee quality
class BadTestExample extends PHPUnit\Framework\TestCase
{
    public function testCalculatePrice()
    {
        $calculator = new PriceCalculator();
        
        // Bad: Tests execution but not correctness
        $calculator->calculatePrice(100, 0.1, true);
        $calculator->calculatePrice(200, 0.2, false);
        $calculator->calculatePrice(50, 0.05, true);
        
        // This achieves high coverage but tests nothing meaningful
        $this->assertTrue(true);
    }
}

// Better: Lower coverage but meaningful tests
class GoodTestExample extends PHPUnit\Framework\TestCase
{
    public function testCalculatePriceWithDiscount()
    {
        $calculator = new PriceCalculator();
        $result = $calculator->calculatePrice(100, 0.1, true);
        
        // Tests actual behavior
        $this->assertEquals(90, $result);
    }
    
    public function testCalculatePriceWithoutDiscount()
    {
        $calculator = new PriceCalculator();
        $result = $calculator->calculatePrice(100, 0.1, false);
        
        // Tests different behavior
        $this->assertEquals(100, $result);
    }
    
    public function testCalculatePriceWithTax()
    {
        $calculator = new PriceCalculator();
        $result = $calculator->calculatePriceWithTax(100, 0.08);
        
        // Tests tax calculation
        $this->assertEquals(108, $result);
    }
}
?>

Coverage Limitations

What Coverage Doesn't Measure

  1. Test Quality: High coverage doesn't guarantee good tests
  2. Edge Cases: May miss boundary conditions
  3. Integration Issues: Focuses on unit-level coverage
  4. Performance: Doesn't measure efficiency
  5. User Experience: Doesn't test actual user workflows
<?php
// Example: 100% coverage but missing edge cases
function divide($a, $b)
{
    return $a / $b; // Division by zero not handled
}

class DivideTest extends PHPUnit\Framework\TestCase
{
    public function testDivide()
    {
        $result = divide(10, 2); // 100% line coverage
        $this->assertEquals(5, $result);
        
        // Missing: divide(10, 0) - would cause runtime error
    }
}
?>

Code coverage is a useful metric for identifying untested code, but it should be combined with other quality measures like mutation testing, integration tests, and manual testing to ensure comprehensive software quality.