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
- Test Quality: High coverage doesn't guarantee good tests
- Edge Cases: May miss boundary conditions
- Integration Issues: Focuses on unit-level coverage
- Performance: Doesn't measure efficiency
- 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.