Using Behat to write functional test of a Symfony command

Behat is a well known test behavior framework, used on many project. I always used it to validate webpage or API behavior. Once I had to write a critical Symfony command. Critical because a big part of the business relied on it. I felt unit test wasn't enough and I wanted to add functionnal test. So why not use Behat for that, as this tools was used a lot on other project.

For privacy reason, I'll use another command as example in this post.

The command I will use is from a personnal project that help me manage my records collection. When I add a new record, I register the price and date I bought it. But because I buy most of my collection outside Europe I have a lot of different currencies in the database. I want to add some financial statistics to the project, so I need to convert all prices in the same currency. To be realistic I need the currency exchange rate at the buying date. I already have a lot of them in the database, and I use an API to fetch rates I missed.

Setting-up Behat

First I need to set Behat in the project. Because I had bad experience with behat community's extensions when I want to upgrade Symfony, unless I need it badly, I always use Behat without extensions. It works fine, it helps me understand separation between Behat and project and didn't need a lot of extra boilerplate code.
First I need to add Behat and assert library, we all know the composer command:

composer install --dev behat/behat webmozart/assert

Then I add some configurations in behat.yml file. this file is at the root directory of the project.

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:[]
 

I choose to have behat test in test/Functional directory. All the contexts class will go in Context dir and Gherkin file on Features dir.

What to test

I have several conditions I like to test: don't convert price in future, don't convert if date is previous 2009 (because the provider I use don't do it), don't convert price without currency...
The Gherkin file is very simple with only one scenario.

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    |

Nothing special here it's the same kind of Gherkin file we find in every project that use Behat.

Create Context

Now that I have the Feature written, I need to create context that execute each step. To simplify this post, I'll put everything in one Context class. I don't recommand to do that, having distinct Context and respecting SRP is the best way for maintenance. Believe me when you have +1000 scenarios and +200 differents step, maintenance is very hard with messy context.
So my first need is to start a Symfony Kernel to create an Application instance. Application is what is used inside bin/console of 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);
    }
}
 

I also create an instance of BufferedOutput, I'll talk about it later. My first step to run the command is very easy, I only need to create an Input and ask Application to run.

    /**
     * @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);
    }

If I had argument to my command line, I should add them to ArrayInput instance. DoRun() method call return what execute() method of the command return. It's important to use the appropriate code to add meaning on this test.

In the case of this command, I don't have output, but if there is some and I would like to assert the content of the output I could get it through BufferedOutput instance like this:

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

I could use the verbosity level to add more output to the console and get more things to assert during the test. It's also could be usefull for debugging.

The second step of my test is to verify the state of the price. To achieve this, I must retrieve services from the container, I need connection to database and serializer. Since Symfony version 5 all services are private by default and unless to explicitely make them public they can't be access through the container with its get() method.
The easiest (and probably the best) way to access services from the container is to use a Service Locator. It allows to get only what I need and by setting it only in services_test.yaml I ensure to not pollute the production environment of my application.

<?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);
    }
}

Of course, don't forget to declare this service as public in services_test.yaml.

    App\Tests\ServiceLocator\PriceConverterServiceLocator:
        public: true

I added a line in the constructor() of the context to retrieve my service locator.

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

ServiceLocator alternative: if you don't want to create a ServiceLocator for each case or having a tedious migration path, you can use the ServiceLocator built-in by Symfony in test environement. It means you must boot the Kernel in an environment where framework.test = true in the config. If you want to know more, look at the class KernelTestCase. Then you can use it like this:

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

And now I can add my last step definition to make some asserts and validate the behavior of my Symfony command.

    /**
     * @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);
    }

The last thing is to add the context in Behat configuration file.

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

Since the first Symfony command I tested with behat, I did it a lot. Cron, manual command line or worker are often forgotten concerning functional/behavior test, but they are an important part of the application and finally it's easy to test.


Add a comment