Utiliser Behat pour écrire les tests fonctionnels d'une commande Symfony

Behat est un framework de test bien connu pour écrire des tests fonctionnels et est utilisé sur beaucoup de projet. Je l'utilise la plus part du temps pour valider le comportement d'une API ou d'une page web. Sur un projet j'ai du écrire une commande Symfony très critique car une grande partie du financement de l'application en dépendait. Bien sûr j'avais écris des tests unitaires mais je n'étais pas totalement confiant car il me manquait des tests fonctionnel sur la logique de la commande. Connaissant déjà Behat, j'ai donc entrepris d'écrire des tests sur cette commande en utilisant Behat.

Pour des raisons évidentes de confidentialité, je vais utiliser une autre commande dans cet article.

La commande Symfony qui me sert d'exemple est issue de mon projet personnel pour gérer ma collection de disque. Quand j'ajoute un disque, je renseigne la date et le prix d'achat. Etant donnée que j’achète des disques dans le monde entier, j'ai des prix dans différentes devises en bdd. Je souhaite ajouter quelques statistiques sur le coût de la collection, pour cela il faut que tous les prix soient dans la même devises et pour être réaliste, j'ai besoin d'utiliser le taux de change à la date d'achat. C'est l'objectif de la commande que je vais tester dans cet article, se connecter à une API pour obtenir le taux de change à une date et convertir le prix.

Configurer Behat

La première chose à faire est d'ajouter Behat au projet. Ayant déjà eu de mauvaise expérience avec les extensions Behat de la communauté quand il s'agit de mettre à jour Symfony, je n'en utilise plus à moins d'en avoir vraiment besoin.
Utiliser Behat seul est très simple, ça aide à comprendre la séparation entre les Behat et le projet et ça nécessite assez peu de code au final.
L'ajout de Behat se fait via composer:

composer install --dev behat/behat webmozart/assert

Ensuite j'ajoute le fichier de config behat.yml à la racine du projet.

default:
    gherkin:
        cache: '%paths.base%/var/cache/test/behat_gherkin_cache'
    testers:
        rerun_cache: '%paths.base%/var/cache/test/behat_rerun_cache'
    translation:
        locale: en
    autoload:
        - '%paths.base%/tests/Functional/Context/'
    suites:
        default:
            paths: ['%paths.base%/tests/Functional/Features/']
            contexts:[]
 

Dans mon organisation de code, j'ai choisi de mettre Behat dans le dossier tests/Functional. Les classes de Context iront dans le dossier Context et les fichier Gherkin dans le dossier Features.

Écriture du scénario de test

J'ai plusieurs conditions que je souhaite tester: ne pas convertir un prix dans le futur ou antérieur à 2009 (non dispo sur l'API que j'utilise), ne pas convertir de prix sans devise...
Je commence donc par écrire ma Feature, le fichier Gherkin ressemblant donc à cela avec un seul scénario.

Feature: Run collector:convert-price command

  Scenario Outline: execute convert-price command
    When I run convert-price command
    Then price <pricename> should be <status>

    Examples:
    | pricename                                 | status      |
    | FXT_PRICE_WITH_FUTUR_DATE                 | unconverted |
    | FXT_PRICE_WITH_DATE_PREV_2009             | unconverted |
    | FXT_PRICE_WITHOUT_CURRENCY                | unconverted |
    | FXT_PRICE_WITH_DEFAULT_CURRENCY           | unconverted |
    | FXT_PRICE_WITH_KNOWN_RATE                 | converted   |
    | FXT_PRICE_WITH_UNKNOWN_RATE               | converted   |
    | FXT_PRICE_CONVERTED_FROM_DEFAULT_CURRENCY | rollback    |

Il n'y a rien de spécifique, ça ressemble à un fichier Gherkin comme on en trouve dans tous les projets.

Création des contextes

Maintenant que j'ai mon scénario de test, j'ai besoin d'écrire les classes de Context pour exécuter chaque étape. Pour simplifier la compréhension de l’article je vais tout regrouper dans une seule classe Context. Bien sûr je ne vous recommande pas de faire de même et de respecter le principe de responsabilité unique (SRP), cela rendra vos tests plus facilement maintenable. Croyez moi, quand vous avez plus de 1000 scénario et plus de 200 étapes différentes, la maintenance des tests prend un temps très incertain avec des Context mal écrits et du code spaghetti.

Mon premier besoin est de récupérer le Kernel de Symfony pour créer une instance de Application. Application est la classe utilisée dans bin/console de Symfony.

<?php
declare(strict_types=1);
 
namespace App\Tests\Functional\Context;
 
use App\Kernel;
use Behat\Behat\Context\Context;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Component\Console\Output\BufferedOutput;
use Symfony\Component\Console\Output\OutputInterface;
 
class ExampleContext implements Context
{
    private Application $application;
    private OutputInterface $output;
 
    public function __construct()
    {
        require dirname(__DIR__).'/../../config/bootstrap.php';
        $kernel = new Kernel(getenv('APP_ENV'), getenv('APP_DEBUG'));
        $kernel->boot();
        $this->application = new Application($kernel);
        $this->output = new BufferedOutput(OutputInterface::VERBOSITY_VERBOSE);
    }
}
 

J'ai également créé une instance de BufferedOutput, j'en parlerai plus tard.
Ma première étape est d’exécuter la commande, pour cela j'ai juste besoin d'une instance d'Input puis de demander a Application de lancer la commande.

    /**
     * @When I run convert-price command
     */
    public function iRunConvertPriceCommand(): void
    {
        $input = new ArrayInput(
            [
                'command' => 'record:convert-price',
                '--env' => 'test',
            ]
        );
 
        $resultCode = $this->application->doRun($input, $this->output);
        Assert::equals($resultcode, Command::SUCCESS);
    }

S'il y a des arguments dans la commande, il faut les ajouter à l'instance d'ArrayInput. Le retour de la méthode doRun() est celui de la méthode execute() de la commande. Il est donc important d'utiliser un code de retour approprié pour donner du sens au test.

Dans le cas de ma commande, je n'ai pas de message dans la console, mais si j'en avais et que je souhaitais ajouter des tests dessus, je peux le récupérer via BufferedOutput comme ceci:

$output = explode("\n", $this->output->fetch());
Assert::inArray($expectedSentence, $output);
 

On peut utiliser le niveau de verbosité de Output pour afficher plus ou moins d'information en fonction du mode verbeux et ainsi avoir plus de contenu à valider lors des tests qui pourrait aussi servir au debug.

La deuxième étape du scénario est de vérifier le statut du prix. Pour y arriver j'ai besoin de récupérer des services du container, il me faut la connexion à la bdd et le serializer. Depuis la version 5 de Symfony, tous les services sont privés par défaut et ne peuvent donc pas être accédés par la méthode get() du container.
La solution la plus facile (et pour moi la meilleure) est d'accéder aux services par l'utilisation d'un Service Locator. Il me permet d'obtenir du container uniquement ce que j'ai besoin et en le déclarant dans le fichier services_test.yaml je suis sûr de ne pas polluer mon environnement de production.

<?php
declare(strict_types=1);
 
namespace App\Tests\ServiceLocator;
 
use Doctrine\DBAL\Connection;
use Psr\Container\ContainerInterface;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Contracts\Service\ServiceSubscriberInterface;
 
class PriceConverterServiceLocator implements ServiceSubscriberInterface
{
    public function __construct(private ContainerInterface $locator)
    {
    }
 
    public static function getSubscribedServices(): array
    {
        return [
            'serializer' => SerializerInterface::class,
            Connection::class
        ];
    }
 
    public function getSerializer(): SerializerInterface
    {
        return $this->locator->get('serializer');
    }
 
    public function getConnection(): Connection
    {
        return $this->locator->get(Connection::class);
    }
}

Bien entendu il ne faut pas oublier de déclarer ce service comme étant public dans le fichier services_test.yaml.

    App\Tests\ServiceLocator\PriceConverterServiceLocator:
        public: true

J'ai ajouté cette ligne dans le constructeur de mon Context pour récuperer le service locator.

$this->serviceLoc = $kernel->getContainer()->get(PriceConverterServiceLocator::class);

Alternative au Service Locator: Si vous ne souhaitez pas créer de Service Locator pour chaque cas de test/context ou que vous avez un chemin de migration complexe, il est possible d'utiliser le Service Locator mis à disposition par Symfony qui contient l'intégralité du container. Il est disponible à partir du moment où vous démarrez le Kernel dans un environnement avec la configuration framework.test = true. Si vous souhaitez en savoir plus à ce sujet, vous pouvez regarder la classe  KernelTestCase. Pour l'utiliser il suffit de le récupérer depuis le container ainsi:

$this->serviceLoc = $kernel->getContainer()->get('test.service_container');

Maintenant je peux ajouter ma dernière étape et faire les validations nécessaire pour assurer la bonne exécution de ma commande Symfony.

    /**
     * @Then price :pricename should be :status
     */
    public function priceShouldBe(string $pricename, string $status): void
    {
        $buyPrice = $this->retreivePrices($pricename);
 
        switch ($status) {
            case 'unconverted':
                Assert::false($buyPrice->wasConverted());
                break;
            case 'rollback':
                Assert::false($buyPrice->wasConverted());
                Assert::eq($buyPrice->getCurrency(), PriceConverterFixtures::DEFAULT_CURRENCY);
                break;
            case 'converted':
                Assert::true($buyPrice->wasConverted());
                Assert::eq($buyPrice->getCurrency(), PriceConverterFixtures::DEFAULT_CURRENCY);
                break;
            default:
                throw new \RangeException(
                    sprintf('Unknown status: "%s", only "unconverted", "converted" and "rollback" is allowed', $status)
                );
        }
    }
 
 
    private function retreivePrices(string $priceName): Price
    {
        $id = $this->getFixtureIdByName($priceName);
        $result = $this->serviceLoc->getConnection()->executeQuery(
            'SELECT buy_price FROM record WHERE id = :id',
            ['id' => $id],
            ['id' => \PDO::PARAM_INT]
        )
            ->fetchAssociative();
 
        return $this->serviceLoc->getSerializer()->deserialize($result['buy_price'] ?? [], Price::class, 'json');
    }
 
    private function getFixtureIdByName(string $fixtureName): int
    {
        $fxt = [
            1 => 'FXT_PRICE_WITH_FUTUR_DATE',
            2 => 'FXT_PRICE_WITH_DATE_PREV_2009',
            3 => 'FXT_PRICE_WITHOUT_CURRENCY',
            4 => 'FXT_PRICE_WITH_DEFAULT_CURRENCY',
            5 => 'FXT_PRICE_WITH_KNOWN_RATE',
            6 => 'FXT_PRICE_WITH_UNKNOWN_RATE',
            7 => 'FXT_PRICE_CONVERTED_FROM_DEFAULT_CURRENCY',
        ];
 
        return \array_search($fixtureName, $fxt);
    }

La dernière chose à faire est d'ajouter la classe de Context dans la configuration de Behat.

    suites:
        default:
            paths: ['%paths.base%/tests/Functional/Features/']
            contexts:
                - App\Tests\Functional\Context\ExampleContext 


Depuis la première commande Symfony que j'ai testé avec Behat, j'ai répété cette opération énormément de fois. Cron, Consumer asynchrone, commande manuelle sont souvent oublié dans la phase d'écriture de tests fonctionnels et pourtant c'est vraiment simple à tester.


Ajouter un commentaire