BDD with Behat

The first of the tools we will introduce is Behat. Behat is a PHP framework that can transform behavioral scenarios into acceptance tests and then run them, providing feedback similar to PHPUnit. The idea is to match each of the steps in English with the scenarios in a PHP function that performs some action or asserts some results.

In this section, we will try to add some acceptance tests to our application. The application will be a simple database migration script that will allow us to keep track of the changes that we will add to our schema. The idea is that each time that you want to change your database, you will write the changes on a migration file and then execute the script. The application will check what was the last migration executed and will perform new ones. We will first write the acceptance tests and then introduce the code progressively as BDD suggests.

In order to install Behat on your development environment, you can use Composer. The command is as follows:

$ composer require behat/behat

Behat actually does not come with any set of assertion functions, so you will have to either implement your own by writing conditionals and throwing exceptions or you could integrate any library that provides them. Developers usually choose PHPUnit for this as they are already used to its assertions. Add it, then, to your project via the following:

$ composer require phpunit/phpunit

As with PHPUnit, Behat needs to know where your test suite is located. You can either have a configuration file stating this and other configuration options, which is similar to the phpunit.xml configuration file for PHPUnit, or you could follow the conventions that Behat sets and skip the configuration step. If you choose the second option, you can let Behat create the folder structure and PHP test class for you with the following command:

$ ./vendor/bin/behat --init

After running this command, you should have a features/bootstrap/FeatureContext.php file, which is where you need to add the steps of the PHP functions' matching scenarios. More on this shortly, but first, let's find out how to write behavioral specifications so that Behat can understand them.

Introducing the Gherkin language

Gherkin is the language, or rather the format, that behavioral specifications have to follow. Using Gherkin naming, each behavioral specification is a feature. Each feature is added to the features directory and should have the .feature extension. Feature files should start with the Feature keyword followed by the title and the narrative in the same format that we already mentioned before—that is, the In order to–As a–I need to structure. In fact, Gherkin will only print these lines, but keeping it consistent will help your developers and business know what they are trying to achieve.

Our application will have two features: one for the setup of our database to allow the migrations tool to work, and the other one for the correct behavior when adding migrations to the database. Add the following content to the features/setup.feature file:

Feature: Setup
  In order to run database migrations
  As a developer
  I need to be able to create the empty schema and migrations table.

Then, add the following feature definition to the features/migrations.feature file:

Feature: Migrations
  In order to add changes to my database schema
  As a developer
  I need to be able to run the migrations script

Defining scenarios

The title and narrative of features does not really do anything more than give information to the person who runs the tests. The real work is done in scenarios, which are specific use cases with a set of steps to take and some assertions. You can add as many scenarios as you need to each feature file as long as they represent different use cases of the same feature. For example, for setup.feature, we can add a couple of scenarios: one where it is the first time that the user runs the script, so the application will have to set up the database, and one where the user already executed the script previously, so the application does not need to go through the setup process.

As Behat needs to be able to translate the scenarios written in plain English to PHP functions, you will have to follow some conventions. In fact, you will see that they are very similar to the ones that we already mentioned in the behavioral specifications section.

Writing Given-When-Then test cases

A scenario must start with the Scenario keyword followed by a short description of what use case the scenario covers. Then, you need to add the list of steps and assertions. Gherkin allows you to use four keywords for this: Given, When, Then, and And. In fact, they all have the same meaning when it comes to code, but they add a lot of semantic value to your scenarios. Let's consider an example; add the following scenario at the end of your setup.feature file:

Scenario: Schema does not exist and I do not have migrations
  Given I do not have the "bdd_db_test" schema
  And I do not have migration files
  When I run the migrations script
  Then I should have an empty migrations table
  And I should get:
    """
    Latest version applied is 0.
    """

This scenario tests what happens when we do not have any schema information and run the migrations script. First, it describes the state of the scenario: Given I do not have the bdd_db_test schema And I do not have migration files. These two lines will be translated to one method each, which will remove the schema and all migration files. Then, the scenario describes what the user will do: When I run the migrations script. Finally, we set the expectations for this scenario: Then I should have an empty migrations table And I should get Latest version applied is 0..

In general, the same step will always start by the same keyword—that is, I run the migrations script will always be preceded by When. The And keyword is a special one as it matches all the three keywords; its only purpose is to have steps as English-friendly as possible; although if you prefer, you could write Given I do not have migration files.

Another thing to note in this example is the use of arguments as part of the step. The line And I should get is followed by a string enclosed by """. The PHP function will get this string as an argument, so you can have one unique step definition—that is, the function—for a wide variety of situations just using different strings.

Reusing parts of scenarios

It is quite common that for a given feature, you always start from the same scenario. For example, setup.feature has a scenario in which we can run the migrations for the first time without any migration file, but we will also add another scenario in which we want to run the migrations script for the first time with some migration files to make sure that it will apply all of them. Both scenarios have in common one thing: they do not have the database set up.

Gherkin allows you to define some steps that will be applied to all the scenarios of the feature. You can use the Background keyword and a list of steps, usually Given. Add these two lines between the feature narrative and scenario definition:

Background:
  Given I do not have the "bdd_db_test" schema

Now, you can remove the first step from the existing scenario as Background will take care of it.

Writing step definitions

So far, we have written features using the Gherkin language, but we still have not considered how any of the steps in each scenario is translated to actual code. The easiest way to note this is by asking Behat to run the acceptance tests; as the steps are not defined anywhere, Behat will print out all the functions that you need to add to your FeatureContext class. To run the tests, just execute the following command:

$ ./vendor/bin/behat

The following screenshot shows the output that you should get if you have no step definitions:

Writing step definitions

As you can note, Behat complained about some missing steps and then printed in yellow the methods that you could use in order to implement them. Copy and paste them into your autogenerated features/bootstrap/FeatureContext.php file. The following FeatureContext class has already implemented all of them:

<?php

use BehatBehatContextContext;
use BehatBehatContextSnippetAcceptingContext;
use BehatGherkinNodePyStringNode;

require_once __DIR__ . '/../../vendor/phpunit/phpunit/src/Framework/Assert/Functions.php';

class FeatureContext implements Context, SnippetAcceptingContext
{
    private $db;
    private $config;
    private $output;

    public function __construct() {
        $configFileContent = file_get_contents(
            __DIR__ . '/../../config/app.json'
        );
        $this->config = json_decode($configFileContent, true);
    }

    private function getDb(): PDO {
        if ($this->db === null) {
            $this->db = new PDO(
                "mysql:host={$this->config['host']}; "
                    . "dbname=bdd_db_test",
                $this->config['user'],
                $this->config['password']
            );
        }

        return $this->db;
    }

    /**
     * @Given I do not have the "bdd_db_test" schema
     */
    public function iDoNotHaveTheSchema()
    {
        $this->executeQuery('DROP SCHEMA IF EXISTS bdd_db_test');
    }

    /**
     * @Given I do not have migration files
     */
    public function iDoNotHaveMigrationFiles()
    {
        exec('rm db/migrations/*.sql > /dev/null 2>&1');
    }

    /**
     * @When I run the migrations script
     */
    public function iRunTheMigrationsScript()
    {
        exec('php migrate.php', $this->output);
    }

    /**
     * @Then I should have an empty migrations table
     */
    public function iShouldHaveAnEmptyMigrationsTable()
    {
        $migrations = $this->getDb()
            ->query('SELECT * FROM migrations')
            ->fetch();
        assertEmpty($migrations);
    }

    private function executeQuery(string $query)
    {
        $removeSchemaCommand = sprintf(
            'mysql -u %s %s -h %s -e "%s"',
            $this->config['user'],
            empty($this->config['password'])
                ? '' : "-p{$this->config['password']}",
            $this->config['host'],
            $query
        );

        exec($removeSchemaCommand);
    }
}

As you can note, we read the configuration from the config/app.json file. This is the same configuration file that the application will use, and it contains the database's credentials. We also instantiated a PDO object to access the database so that we could add or remove tables or take a look at what the script did.

Step definitions are a set of methods with a comment on each of them. This comment is an annotation as it starts with @ and is basically a regular expression matching the plain English step defined in the feature. Each of them has its implementation: either removing a database or migration files, executing the migrations script, or checking what the migrations table contains.

The parameterization of steps

In the previous FeatureContext class, we intentionally missed the iShouldGet method. As you might recall, this step has a string argument identified by a string enclosed between """. The implementation for this method looks as follows:

/**
 * @Then I should get:
 */
public function iShouldGet(PyStringNode $string)
{
    assertEquals(implode("
", $this->output), $string);
}

Note how the regular expression does not contain the string. This happens when using long strings with """. Also, the argument is an instance of PyStringNode, which is a bit more complex than a normal string. However, fear not; when you compare it with a string, PHP will look for the __toString method, which just prints the content of the string.

Running feature tests

In the previous sections, we wrote acceptance tests using Behat, but we have not written a single line of code yet. Before running them, though, add the config/app.json configuration file with the credentials of your database user so that the FeatureContext constructor can find it, as follows:

{
  "host": "127.0.0.1",
  "schema": "bdd_db_test",
  "user": "root",
  "password": ""
}

Now, let's run the acceptance tests, expecting them to fail; otherwise, our tests will not be valid at all. The output should be something similar to this:

Running feature tests

As expected, the Then steps failed. Let's implement the minimum code necessary in order to make the tests pass. For starters, add the autoloader into your composer.json file and run composer update:

"autoload": {
    "psr-4": {
        "Migrations\": "src/"
    }
}

We would like to implement a Schema class that contains the helpers necessary to set up a database, run migrations, and so on. Right now, the feature is only concerned about the setup of the database—that is, creating the database, adding the empty migrations table to keep track of all the migrations added, and the ability to get the latest migration registered as successful. Add the following code as src/Schema.php:

<?php

namespace Migrations;

use Exception;
use PDO;

class Schema {

    const SETUP_FILE = __DIR__ . '/../db/setup.sql';
    const MIGRATIONS_DIR = __DIR__ . '/../db/migrations/';

    private $config;
    private $connection;

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

    

    private function getConnection(): PDO
    {
        if ($this->connection === null) {
            $this->connection = new PDO(
                "mysql:host={$this->config['host']};"
                    . "dbname={$this->config['schema']}",
                $this->config['user'],
                $this->config['password']
            );
        }

        return $this->connection;
    }
}

Even though the focus of this chapter is to write acceptance tests, let's go through the different implemented methods:

  • The constructor and getConnection just read the configuration file in config/app.json and instantiated the PDO object.
  • The createSchema executed CREATE SCHEMA IF NOT EXISTS, so if the schema already exists, it will do nothing. We executed the command with exec instead of PDO as PDO always needs to use an existing database.
  • The getLatestMigration will first check whether the migrations table exists; if not, we will create it using setup.sql and then fetch the last successful migration.

We also need to add the migrations/setup.sql file with the query to create the migrations table, as follows:

CREATE TABLE IF NOT EXISTS migrations(
  version INT UNSIGNED NOT NULL,
  `time` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  status ENUM('success', 'error'),
  PRIMARY KEY (version, status)
);

Finally, we need to add the migrate.php file, which is the one that the user will execute. This file will get the configuration, instantiate the Schema class, set up the database, and retrieve the last migration applied. Run the following code:

<?php

require_once __DIR__ . '/vendor/autoload.php';

$configFileContent = file_get_contents(__DIR__ . '/config/app.json');
$config = json_decode($configFileContent, true);

$schema = new MigrationsSchema($config);

$schema->createSchema();

$version = $schema->getLatestMigration();
echo "Latest version applied is $version.
";

You are now good to run the tests again. This time, the output should be similar to this screenshot, where all the steps are in green:

Running feature tests

Now that our acceptance test is passing, we need to add the rest of the tests. To make things quicker, we will add all the scenarios, and then we will implement the necessary code to make them pass, but it would be better if you add one scenario at a time. The second scenario of setup.feature could look as follows (remember that the feature contains a Background section, in which we clean the database):

Scenario: Schema does not exists and I have migrations
  Given I have migration file 1:
    """
    CREATE TABLE test1(id INT);
    """
  And I have migration file 2:
    """
    CREATE TABLE test2(id INT);
    """
  When I run the migrations script
  Then I should only have the following tables:
    | migrations |
    | test1      |
    | test2      |
  And I should have the following migrations:
    | 1 | success |
    | 2 | success |
  And I should get:
    """
    Latest version applied is 0.
    Applied migration 1 successfully.
    Applied migration 2 successfully.
    """

This scenario is important as it used parameters inside the step definitions. For example, the I have migration file step is presented twice, each time with a different migration file number. The implementation of this step is as follows:

/**
 * @Given I have migration file :version:
 */
public function iHaveMigrationFile(
    string $version,
    PyStringNode $file
) {
    $filePath = __DIR__ . "/../../db/migrations/$version.sql";
    file_put_contents($filePath, $file->getRaw());
}

The annotation of this method, which is a regular expression, used :version as a wildcard. Any step that starts with Given I have migration file followed by something else will match this step definition, and the "something else" bit will be received as the $version argument as a string.

Here, we introduced yet another type of argument: tables. The Then I should only have the following tables step defined a table of two rows of one column each, and the Then I should have the following migrations bit sent a table of two rows of two columns each. The implementation for the new steps is as follows:

/**
 * @Then I should only have the following tables:
 */
public function iShouldOnlyHaveTheFollowingTables(TableNode $tables) {
    $tablesInDb = $this->getDb()
        ->query('SHOW TABLES')
        ->fetchAll(PDO::FETCH_NUM);

    assertEquals($tablesInDb, array_values($tables->getRows()));
}

/**
 * @Then I should have the following migrations:
 */
public function iShouldHaveTheFollowingMigrations(
    TableNode $migrations
) {
    $query = 'SELECT version, status FROM migrations';
    $migrationsInDb = $this->getDb()
        ->query($query)
        ->fetchAll(PDO::FETCH_NUM);

    assertEquals($migrations->getRows(), $migrationsInDb);
}

The tables are received as TableNode arguments. This class contains a getRows method that returns an array with the rows defined in the feature file.

The other feature that we would like to add is features/migrations.feature. This feature will assume that the user already has the database set up, so we will add a Background section with this step. We will add one scenario in which the migration file numbers are not consecutive, in which case the application should stop at the last consecutive migration file. The other scenario will make sure that when there is an error, the application does not continue the migration process. The feature should look similar to the following:

Feature: Migrations
  In order to add changes to my database schema
  As a developer
  I need to be able to run the migrations script

  Background:
    Given I have the bdd_db_test

  Scenario: Migrations are not consecutive
    Given I have migration 3
    And I have migration file 4:
      """
      CREATE TABLE test4(id INT);
      """
    And I have migration file 6:
      """
      CREATE TABLE test6(id INT);
      """
    When I run the migrations script
    Then I should only have the following tables:
      | migrations |
      | test4      |
    And I should have the following migrations:
      | 3 | success |
      | 4 | success |
    And I should get:
      """
      Latest version applied is 3.
      Applied migration 4 successfully.
      """

  Scenario: A migration throws an error
    Given I have migration file 1:
      """
      CREATE TABLE test1(id INT);
      """
    And I have migration file 2:
      """
      CREATE TABLE test1(id INT);
      """
    And I have migration file 3:
      """
      CREATE TABLE test3(id INT);
      """
    When I run the migrations script
    Then I should only have the following tables:
      | migrations |
      | test1      |
    And I should have the following migrations:
      | 1 | success |
      | 2 | error   |
    And I should get:
      """
      Latest version applied is 0.
      Applied migration 1 successfully.
      Error applying migration 2: Table 'test1' already exists.
      """

There aren't any new Gherkin features. The two new step implementations look as follows:

/**
* @Given I have the bdd_db_test
*/
public function iHaveTheBddDbTest()
{
    $this->executeQuery('CREATE SCHEMA bdd_db_test');
}

/**
 * @Given I have migration :version
 */
public function iHaveMigration(string $version)
{
    $this->getDb()->exec(
        file_get_contents(__DIR__ . '/../../db/setup.sql')
    );

    $query = <<<SQL
INSERT INTO migrations (version, status)
VALUES(:version, 'success')
SQL;
    $this->getDb()
        ->prepare($query)
        ->execute(['version' => $version]);
}

Now, it is time to add the needed implementation to make the tests pass. There are only two changes needed. The first one is an applyMigrationsFrom method in the Schema class that, given a version number, will try to apply the migration file for this number. If the migration is successful, it will add a row in the migrations table, with the new version added successfully. If the migration failed, we would add the record in the migrations table as a failure and then throw an exception so that the script is aware of it. Finally, if the migration file does not exist, the returning value will be false. Add this code to the Schema class:

public function applyMigrationsFrom(int $version): bool
{
    $filePath = self::MIGRATIONS_DIR . "$version.sql";

    if (!file_exists($filePath)) {
        return false;
    }

    $connection = $this->getConnection();
    if ($connection->exec(file_get_contents($filePath)) === false) {
        $error = $connection->errorInfo()[2];
        $this->registerMigration($version, 'error');
        throw new Exception($error);
    }

    $this->registerMigration($version, 'success');
    return true;
}

private function registerMigration(int $version, string $status)
{
    $query = <<<SQL
INSERT INTO migrations (version, status)
VALUES(:version, :status)
SQL;
    $params = ['version' => $version, 'status' => $status];

    $this->getConnection()->prepare($query)->execute($params);
}

The other bit missing is in the migrate.php script. We need to call the newly created applyMigrationsFrom method with consecutive versions starting from the latest one, until we get either a false value or an exception. We also want to print out information about what is going on so that the user is aware of what migrations were added. Add the following code at the end of the migrate.php script:

do {
    $version++;

    try {
        $result = $schema->applyMigrationsFrom($version);
        if ($result) {
            echo "Applied migration $version successfully.
";
        }
    } catch (Exception $e) {
        $error = $e->getMessage();
        echo "Error applying migration $version: $error.
";
        exit(1);
    }
} while ($result);

Now, run the tests and voilà! They all pass. You now have a library that manages database migrations, and you are 100% sure that it works thanks to your acceptance tests.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.149.253.210