Run behat tests with code coverage August 2020

The thing I like about code coverage is that it shows which portions of code are certainly not tested! In my team, we use PhpUnit for unit tests, and Behat for functional tests. Code coverage with PhpUnit is easy-peasy, whereas code coverage with Behat is not really straightforward.

First, I googled something like "behat test coverage" and was happy to find a library that does it: leanphp/behat-code-coverage. So I tested it on one of our projects (a project using Symfony 4), and it worked as expected! Then I tried it on another project (using Symfony 5): argh, the composer require fails. The lib depends on Symfony 4 components, and isn't compatible with a Symfony 5 project... Unfortunately, this lib last commit is more than 2 years old. It looks abandoned. Well... What can we do now?

Back to basics: how does this lib or PhpUnit calculate coverage? First, they rely on a php extension: Xdebug in my case. Could I use Xdebug coverage functions directly and build my own reports? Yes, but I'm too lazy for that, there must be an easier way! Let's have a look at phpunit/phpunit package dependencies. Oh... "phpunit/php-code-coverage", could this be what I'm looking for?

Here's what its doc shows:

<?php declare(strict_types=1);
use SebastianBergmann\CodeCoverage\CodeCoverage;
use SebastianBergmann\CodeCoverage\Driver\Driver;
use SebastianBergmann\CodeCoverage\Filter;
use SebastianBergmann\CodeCoverage\Report\Html\Facade as HtmlReport;

$filter = new Filter;
$filter->includeDirectory('/path/to/directory');

$driver = Driver::forLineCoverage($filter);

$coverage = new CodeCoverage($driver, $filter);

$coverage->start('<name of test>');

// ...

$coverage->stop();


(new HtmlReport)->process($coverage, '/tmp/code-coverage-report');

So, basically, initialize stuff, start test coverage, run test, stop coverage, repeat, generate a report. I like that! If you know about Behat hooks, I guess you don't have to read the following part! Let's write a Behat context that initializes coverage before the test suite begins, starts the coverage before each scenario, stops it after each scenario, and finally write reports when the test suite is over.

<?php

namespace App\Tests\Behat;

use Behat\Behat\Context\Context;
use Behat\Behat\Hook\Scope\BeforeScenarioScope;
use SebastianBergmann\CodeCoverage\CodeCoverage;

class CoverageContext implements Context
{
    /**
     * @var bool
     */
    private static $enabled = true;

    /**
     * @var CodeCoverage
     */
    private static $coverage;

    public function __construct(bool $enabled)
    {
        self::$enabled = $enabled;
    }

    /**
     * @BeforeSuite
     */
    public static function initializeCoverage(): void
    {
        if (!self::$enabled) {
            return;
        }

        if (!\function_exists('xdebug_start_code_coverage')) {
            throw new \RuntimeException('Behat coverage is enabled, but Xdebug is missing. Please enable Xdebug or disable coverage');
        }

        self::$coverage = new CodeCoverage();

        // Specify the directories you want/not want to be covered
        self::$coverage->filter()->addDirectoryToWhitelist(__DIR__.'/../../src');
        self::$coverage->filter()->removeDirectoryFromWhitelist(__DIR__.'/../../src/Tests');
        self::$coverage->filter()->removeDirectoryFromWhitelist(__DIR__.'/../../src/Migrations');
    }

    /**
     * @BeforeScenario
     */
    public function startCoverage(BeforeScenarioScope $scope): void
    {
        if (!self::$enabled) {
            return;
        }

        self::$coverage->start($scope->getFeature()->getFile().':'.$scope->getScenario()->getLine());
    }

    /**
     * @AfterScenario
     */
    public function stopCoverage(): void
    {
        if (!self::$enabled) {
            return;
        }

        self::$coverage->stop();
    }

    /**
     * @AfterSuite
     */
    public static function processCoverage(): void
    {
        if (!self::$enabled) {
            return;
        }

        // HTML report
        $writer = new \SebastianBergmann\CodeCoverage\Report\Html\Facade();
        $writer->process(self::$coverage, '/somewhere/behat-coverage/html');

        // CLover report
        $writer = new \SebastianBergmann\CodeCoverage\Report\Clover();
        $writer->process(self::$coverage, '/somewhere/behat-coverage/clover.xml');

        // Text report
        $writer = new \SebastianBergmann\CodeCoverage\Report\Text();
        echo $writer->process(self::$coverage, true);
    }
}

Well, this works as expected! It shows which portions of code your test suite covers, and, most important to me, which lines are never executed! Now it's up to you to improve your tests, to make sure you can deploy with confidence when your tests are all green!

NB: I've added a way to enable/disable it, since Xdebug and coverage have a huge impact on test duration.

Tags: behat, test, coverage