0

Code coverage — percentage of lines/branches executed by tests

Beginner5 min read·eng-17-013
interview

Concept

Code coverage — a measurement of how much of the source code is executed when tests run. Usually expressed as a percentage of lines, branches, or statements tested.

Types of coverage:

  • Line coverage: Percentage of code lines executed by tests.
  • Branch coverage: Percentage of conditional branches taken (every if/else path tested).
  • Statement coverage: Similar to line, but counts statements (multiple per line possible).
  • Function/method coverage: Percentage of methods called by tests.
  • Path coverage: Every possible execution path — exponential complexity, rarely practical.

How to measure in PHP: Xdebug or PCOV extension + PHPUnit's --coverage-html flag. Generates an HTML report showing which lines were/weren't executed.

Misuse of coverage metrics:

  • High coverage ≠ good tests. assertEquals(true, true) gives coverage but tests nothing.
  • 100% coverage ≠ no bugs. You can test every line with wrong assertions.
  • Coverage measures WHAT was run, not THAT it was tested correctly.

What coverage IS useful for:

  • Finding untested code paths. A 0% coverage area might be dead code or a forgotten feature.
  • A baseline requirement for pull requests (e.g., "must have 80% coverage").
  • Identifying gaps after adding features.

Branch coverage is more meaningful than line coverage: Testing both if and else branches is more thorough than just testing one path.

Good targets: 80%+ line coverage is a commonly cited threshold. Critical business logic should aim for 95%+. Generated code and trivial getters can be excluded.

Code Example

bash
# Run PHPUnit with coverage report
php artisan test --coverage
# Or with specific report format:
./vendor/bin/phpunit --coverage-html coverage-report/
# Open coverage-report/index.html in browser

# Require minimum coverage (CI):
./vendor/bin/phpunit --coverage-clover coverage.xml
# Then in CI: check that lines-covered/lines-valid > 0.80
php
<?php
// HIGH COVERAGE but POOR TEST — covers the line but doesn't actually test behavior
class TaxCalculatorTest extends \PHPUnit\Framework\TestCase
{
    public function test_tax_function_runs(): void
    {
        $calc = new TaxCalculator();
        $result = $calc->calculate(100.00, 0.20);
        $this->assertIsFloat($result); // just checks it returns a float — coverage: 100%, usefulness: low
    }
}

// GOOD COVERAGE with MEANINGFUL TESTS
class TaxCalculatorTest extends \PHPUnit\Framework\TestCase
{
    private TaxCalculator $calc;
    protected function setUp(): void { $this->calc = new TaxCalculator(); }

    public function test_calculates_20_percent_tax(): void
    {
        $this->assertEquals(20.00, $this->calc->calculate(100.00, 0.20));
    }

    public function test_rounds_to_two_decimal_places(): void
    {
        $this->assertEquals(3.33, $this->calc->calculate(9.99, 0.33333));
    }

    public function test_returns_zero_for_zero_rate(): void
    {
        $this->assertEquals(0.00, $this->calc->calculate(100.00, 0.0));
    }

    public function test_throws_for_negative_rate(): void
    {
        $this->expectException(\InvalidArgumentException::class);
        $this->calc->calculate(100.00, -0.1);
    }
}

// BRANCH COVERAGE example — test both branches of an if statement
function classify(int $age): string
{
    if ($age < 18) return 'minor';       // ← branch 1
    elseif ($age < 65) return 'adult';   // ← branch 2
    else return 'senior';                 // ← branch 3
}

// Branch coverage requires testing ALL three branches:
class ClassifyTest extends \PHPUnit\Framework\TestCase
{
    public function test_minor(): void  { $this->assertEquals('minor',  classify(17)); }
    public function test_adult(): void  { $this->assertEquals('adult',  classify(30)); }
    public function test_senior(): void { $this->assertEquals('senior', classify(65)); }
}
// Line coverage might be 100% with just test_adult(), but branch coverage requires all three

// Excluding from coverage
/** @codeCoverageIgnore */
class AutoGeneratedMigration { /* boilerplate */ }

// phpunit.xml — coverage config
/*
<coverage>
  <include>
    <directory suffix=".php">./app</directory>
  </include>
  <exclude>
    <directory>./app/Console</directory>  <!-- generated Artisan commands -->
  </exclude>
  <report>
    <html outputDirectory="build/coverage"/>
  </report>
</coverage>
*/