Run behat tests with code coverage

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

My little contribution to Symfony

Since its last update (the 2.2.0 version), Symfony has included a small piece of code of my own. To be precise, it is located in the "SensioFrameworkExtraBundle" bundle. It brings a new option to the DoctrineParamConverter object that allows to optimize the number of database requests when using the ParamConverter.

More information in the ParamConverter's doc, especially the repository_method option. It's mine :) As a bonus, the pull request on GitHub.

By the way, I have a new job, now I work for Efidev, french experts in agile software development and quality, and it's awesome! Second news, I've just migrated this website. I've left my VKS and now I have a dedicated server from kimsufi.

Tags: Symfony, OVH, Github

An alternative to CAPTCHAs with Symfony2

Another post about security, an important topic, precisely about how to fight the form spam. I didn't want to impose CAPTCHAs on you on my blog, so I looked for an alternative method to avoid bots. I've quickly found the "honeypot button" principle via this article. Here is the idea: the bots usually submit forms without clicking button, or clicking the first button that looks like a submit button. So we display several buttons, and we verify at server-side that the right button has been used.

Symfonically speaking, we have to create a custom field type to represent the buttons. We also have to add a validation to verify that the right button has been used.

Field type creation

First of all, the buttons need to have a value, so they can be identified during the validation phase. Those values are defined in the parameters.yml file, so they can be easily retrieved via the service container:

# app/config/parameters.yml
parameters:
    # anti spam buttons values
    antispam_button:
        good: xuitybzerpidvfugvo
        bad:  kuserhxvvkigyhsdsf

Then, I create my field type strictly speaking. So I create the AntispamButtonType class. It implements ContainerAwareInterface because we gonna need to use the service container. I define choice as parent of my Type, because this buttons group is used for making a choice, choosing the right button to prove that you aren't a bot. Here is the class:

// src/.../BlogBundle/Form/AntispamButtonType.php

class AntispamButtonType extends AbstractType implements ContainerAwareInterface
{
    protected $container;

    public function __construct(ContainerInterface $container)
    {
        $this->setContainer($container);
    }

    public function setContainer(ContainerInterface $container = null)
    {
        $this->container = $container;
    }

    public function setDefaultOptions(OptionsResolverInterface $resolver)
    {
        $antispamParams = $this->container->getParameter('antispam_button');

        $resolver->setDefaults(array(
            'choices'  => array(
                $antispamParams['bad']  => 'do.not.add.comment',
                $antispamParams['good'] => 'add.comment'
            ),
        ));
    }

    public function buildView(FormView $view, FormInterface $form, array $options)
    {
        parent::buildView($view, $form, $options);

        $view->vars['btn_classes'] = array('btn', 'btn btn-primary');
        $view->vars['label_prefix'] = array(null, '<i class="icon-comment icon-white"></i> ');
    }

    public function getParent()
    {
        return 'choice';
    }

    public function getName()
    {
        return 'antispambutton';
    }
}

So I use the service container to retrieve the button values, and I bind them to their label. I've also overridden the buildView method to modify some rendering stuff (CSS classes, button icon).

Let's focus on rendering precisely. Symfony needs to know how this field should be displayed, so let's define a twig template:


{# src/.../BlogBundle/Resources/views/Form/fields.html.twig #}

{% block antispambutton_widget %}
    {% for key, choice in choices %}
        <button name="{{ full_name }}" id="{{ id }}_{{ key }}" value="{{ choice.value }}" type="submit" class="{{ btn_classes[key] }}">
            {{ label_prefix[key] is not null ? label_prefix[key]|raw : '' }}{{ choice.label|trans }}
        </button>
    {% endfor %}
{% endblock %}

{% block antispambutton_label %}{% endblock %}

So I create a antispambutton_widget block ("antispambutton" must match the value returned by AntispamButtonType::getName()) for the field rendering, and an empty antispambutton_label block, to tell that I don't want any label. Now we need to inform the framework that there's a file to parse to render forms. That's in the config.yml file:

# app/config/config.yml
twig:
    form:
        resources:
            - 'MyCompanyBlogBundle:Form:fields.html.twig'

Our field type is now ready to be used! So we can instantiate it in our forms. In my case, it's CommentType, the form to add a comment. But before we need to make this class container aware (as we did for AntispamButtonType), since we gonna need the container to instantiate our field type (and we'll need it for the validation too, see next paragraphs). So the result is:

// src/.../BlogBundle/Form/CommentType.php

class CommentType extends AbstractType implements ContainerAwareInterface
{
    protected $container;

    public function __construct(ContainerInterface $container)
    {
        $this->setContainer($container);
    }

    public function setContainer(ContainerInterface $container = null)
    {
        $this->container = $container;
    }

    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            // other fields...

            ->add('button',  new AntispamButtonType($this->container), array(
                'property_path' => false,
            ))
        ;
    }
}

Don't forget the property_path option, to inform that this field isn't mapped on the Comment object. Now the buttons are well displayed, let's talk about validation now. Validation

The last thing to do is to verify that the used button value is the expected value. To do so, we just have to add few lines in CommentType::buildForm(). At first, I wanted to use the CallbackValidator class, but it's been deprecated since the 2.1 version. So we have to use the FormEvents::POST_BIND form event (as mentioned in the deprecated comment):

// src/.../BlogBundle/Form/CommentType.php

public function buildForm(FormBuilderInterface $builder, array $options)
{
    $container = $this->container;

    $builder
        // other fields...

        // antispam button + validation
        ->add('button',  new AntispamButtonType($this->container), array(
            'property_path' => false,
        ))
        ->addEventListener(FormEvents::POST_BIND, function(FormEvent $event) use($container) {
            $form = $event->getForm();
            $antispamParams = $container->getParameter('antispam_button');

            if (!$form->has('button') || $form['button']->getData() !== $antispamParams['good']) {
                $form->addError(new FormError('spam.detected'));
            }
        })
    ;
}

Et voila! Now, an error is raised if the right button is not clicked. We just need to translate the spam.detected string in the validators.XX.yml files. So we have an ergonomic and efficient antispam system! Edit (09/10/2012)

Further to the Jakub Lédl's pertinent comment, I've improved this code. Passing the service container this way is definitely not the right way. The field types aren't supposed to deal with the container. The thing to do is to declare the new type as a service, and to inject the parameters into it.

So let's start with the service declaration:

# src/.../BlogBundle/Resources/config/services.yml
services:
    form.type.antispam_button:
        class: GregoireM\Bundle\BlogBundle\Form\AntispamButtonType
        arguments:
            - %antispam_button%
        tags:
            - { name: form.type, alias: antispambutton }

Then we have to modify the AntispamButtonType class:

class AntispamButtonType extends AbstractType
{
    protected $params;

    public function __construct(array $params)
    {
        $this->params = $params;
    }

    public function setDefaultOptions(OptionsResolverInterface $resolver)
    {
        $resolver->setDefaults(array(
            'choices'  => array(
                $this->params['bad']  => 'do.not.add.comment',
                $this->params['good'] => 'add.comment'
            ),
        ));
    }

    // ...
}

Now we can use our field type through its alias: antispambutton. So here is the CommentType class, freed from its container:

// src/.../BlogBundle/Form/CommentType.php

class CommentType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            // other fields...

            // antispam button + validation
            ->add('button', 'antispambutton', array(
                'property_path' => false,
            ))
            ->addEventListener(FormEvents::POST_BIND, function(FormEvent $event) {
                $form = $event->getForm();
                $antispamParams = $form->getConfig()
                    ->getFormFactory()
                    ->getType('antispambutton')
                    ->getParams();

                if (!$form->has('button') || $form['button']->getData() !== $antispamParams['good']) {
                    $form->addError(new FormError('spam.detected'));
                }
            })
        ;
    }

  // ...
}

Note that we have to add the AntispamButtonType::getParams() method, to retrieve the array parameter to use it in the validation callback. I haven't found a better way to do it...

Tags: Symfony, ORM, security, form, antispam, captcha, honeypot