BDD 使用 Behat

我们要介绍的第一个工具是 Behat。Behat 是一个 PHP 框架,可以将行为场景转化为验收测试,然后运行这些测试,并提供与 PHPUnit 类似的反馈。我们的想法是将英语中的每个步骤与执行某些操作或断言某些结果的 PHP 函数中的情景相匹配。

在本节中,我们将尝试为应用程序添加一些验收测试。该应用程序将是一个简单的数据库迁移脚本,它将允许我们跟踪将添加到模式中的更改。我们的想法是,每次要更改数据库时,先将更改写入迁移文件,然后执行脚本。应用程序会检查上次执行的迁移,并执行新的迁移。我们将首先编写验收测试,然后按照 BDD 的建议逐步引入代码。

要在开发环境中安装 Behat,可以使用 Composer。命令如下

$ composer require behat/behat

Behat 实际上没有自带任何断言函数集,因此您必须通过编写条件和抛出异常来实现自己的断言函数,或者集成任何提供断言函数的库。开发人员通常会选择 PHPUnit,因为他们已经习惯了它的断言功能。通过以下方法将其添加到项目中:

$ composer require phpunit/phpunit

与 PHPUnit 一样,Behat 也需要知道测试套件的位置。您可以在配置文件中说明这一点和其他配置选项(类似于 PHPUnit 的 phpunit.xml 配置文件),也可以遵循 Behat 设置的惯例,跳过配置步骤。如果选择第二种方法,可以使用下面的命令让 Behat 为你创建文件夹结构和 PHP 测试类:

$ ./vendor/bin/behat --init

运行此命令后,您应该有一个 features/bootstrap/FeatureContext.php 文件,您需要在其中添加 PHP 函数的匹配场景步骤。稍后会有更多介绍,但首先,让我们来了解一下如何编写行为规范,以便 Behat 可以理解它们。

Gherkin 语言简介

Gherkin 是行为规范必须遵循的语言,或者说是格式。使用 Gherkin 命名,每个行为规范都是一个特性。每个特性都被添加到特性目录中,扩展名为 .feature。特征文件应以特征关键字开头,然后是标题和叙述,格式与我们之前提到的相同,即 In order to-As a-I need to 结构。事实上,Gherkin 只会打印这几行,但保持一致将有助于开发人员和业务人员了解他们要实现的目标。

我们的应用程序将有两个功能:一个用于设置数据库以允许迁移工具工作,另一个用于在数据库中添加迁移时的正确行为。在 features/setup.feature 文件中添加以下内容:

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

然后,将以下功能定义添加到 features/migrations.feature 文件中:

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

定义场景

除了向运行测试的人员提供信息外,功能的标题和叙述实际上没有任何作用。真正的工作是在场景中完成的,场景是带有一系列步骤和一些断言的具体用例。您可以根据需要在每个特性文件中添加任意多个场景,只要它们代表的是同一特性的不同用例即可。例如,对于 setup.feature,我们可以添加几种情况:一种情况是用户第一次运行脚本,因此应用程序必须设置数据库;另一种情况是用户之前已经执行过脚本,因此应用程序不需要进行设置过程。

由于 Behat 需要将用普通英语编写的方案转换为 PHP 函数,因此您必须遵循一些约定。事实上,这些约定与我们在行为规范部分提到的约定非常相似。

写 Given-When-Then 测试用例

场景必须以 Scenario 关键字开头,然后简短描述场景涵盖的用例。然后,您需要添加步骤和断言列表。为此,Gherkin 允许您使用四个关键字:GivenWhenThenAnd: 事实上,这些关键字在代码中的含义都是一样的,但它们能为您的情景添加很多语义价值。让我们举个例子:在 setup.feature 文件末尾添加以下场景:

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.
        """

本场景测试在没有任何模式信息的情况下运行迁移脚本会发生什么。首先,它描述了场景的状态:鉴于我没有 bdd_db_test 模式,也没有迁移文件。这两行将分别转化为一个方法,删除模式和所有迁移文件。然后,情景描述了用户要做的事情:当我运行迁移脚本时。最后,我们为这一场景设定期望值:然后,我应该得到一个空的迁移表,并且我应该得到 最新应用的版本是 0…​。

一般来说,同一个步骤总是以同一个关键字开始的,也就是说,我运行迁移脚本的时候,前面总是会有一个 "When"。And 关键字是一个特殊的关键字,因为它可以匹配所有三个关键字;它的唯一目的是使步骤尽可能英语化;当然,如果你愿意,也可以写成 Given I do not have migration files(鉴于我没有迁移文件)。

本例中另一个值得注意的地方是使用参数作为步骤的一部分。And I should get 这行后面是一个用 """ 括起来的字符串。PHP 函数会获取这个字符串作为参数,因此,只需使用不同的字符串,就可以在各种情况下使用一个唯一的步骤定义(即函数)。

重用部分场景

通常情况下,对于给定的功能,我们总是从相同的场景开始。例如,setup.feature 有一个场景,在这个场景中,我们可以在没有任何迁移文件的情况下首次运行迁移,但我们还将添加另一个场景,在这个场景中,我们希望首次运行带有一些迁移文件的迁移脚本,以确保它能应用所有的迁移文件。这两种情况有一个共同点:都没有建立数据库。

Gherkin 允许你定义一些将应用于所有功能场景的步骤。您可以使用 Background 关键字和步骤列表(通常是 Given)。在功能叙述和场景定义之间添加这两行:

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

现在,您可以从现有场景中删除第一步,因为 Background 将处理它。

编写每部的定义

到目前为止,我们已经使用 Gherkin 语言编写了功能,但我们仍未考虑如何将每个场景中的任何步骤转化为实际代码。要注意这一点,最简单的方法就是让 Behat 运行验收测试;由于这些步骤没有在任何地方定义,Behat 会打印出你需要添加到 FeatureContext 类中的所有函数。要运行测试,只需执行以下命令即可:

$ ./vendor/bin/behat

下面的截图显示了在没有步骤定义的情况下应该得到的输出结果:

image 2023 11 04 14 25 19 563

正如您所注意到的,Behat 抱怨缺少了一些步骤,然后用黄色打印出了您可以用来实现这些步骤的方法。将它们复制并粘贴到自动生成的 features/bootstrap/FeatureContext.php 文件中。下面的 FeatureContext 类已经实现了所有这些方法:

<?php

use Behat\Behat\Context\Context;
use Behat\Behat\Context\SnippetAcceptingContext;
use Behat\Gherkin\Node\PyStringNode;

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

正如你所注意到的,我们从 config/app.json 文件中读取配置。这是应用程序将使用的同一个配置文件,其中包含数据库的凭据。我们还实例化了一个 PDO 对象来访问数据库,这样就可以添加或删除表,或查看脚本的操作。

步骤定义是一组方法,每个方法上都有一个注释。注释以 @ 开头,基本上是一个与功能中定义的纯英文步骤相匹配的正则表达式。每个步骤都有自己的实现方式:删除数据库或迁移文件、执行迁移脚本或检查迁移表的内容。

步骤参数化

在之前的 FeatureContext 类中,我们有意遗漏了 iShouldGet 方法。您可能还记得,这一步有一个字符串参数,由 """ 之间的字符串标识。该方法的实现如下:

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

注意正则表达式不包含字符串。当使用带 """ 的长字符串时,就会出现这种情况。另外,参数是 PyStringNode 的实例,比普通字符串复杂一些。不过,不用担心;当您将它与字符串比较时,PHP 将查找 __toString 方法,该方法只是打印字符串的内容。

运行功能测试

在前面的章节中,我们使用 Behat 编写了验收测试,但还没有编写任何代码。不过在运行之前,请在 config/app.json 配置文件中添加数据库用户的凭据,以便 FeatureContext 构造函数能找到它,如下所示:

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

现在,让我们运行验收测试,预计它们会失败;否则,我们的测试将根本无效。输出应该与此类似:

image 2023 11 04 16 00 53 483

不出所料,Then 的步骤失败了。为了使测试通过,让我们执行所需的最少代码。首先,将自动加载器添加到你的 composer.json 文件中,然后运行 composer update

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

我们希望实现一个模式类,其中包含建立数据库、运行迁移等所需的助手。目前,该功能只关注数据库的设置,即创建数据库、添加空的迁移表以跟踪所有已添加的迁移,以及获取最新的成功迁移注册信息。添加以下代码到 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;
    }
}

尽管本章的重点是编写验收测试,但让我们来看看不同的实现方法:

  • 构造函数和 getConnection 只是读取 config/app.json 中的配置文件并实例化 PDO 对象。

  • createSchema 执行的是 CREATE SCHEMA IF NOT EXISTS,因此如果模式已经存在,它将什么也不做。我们使用 exec 而不是 PDO 执行命令,因为 PDO 总是需要使用现有数据库。

  • getLatestMigration 将首先检查迁移表是否存在;如果不存在,我们将使用 setup.sql 创建迁移表,然后获取上次成功迁移的数据。

我们还需要在 migrations/setup.sql 文件中添加查询,以创建表的查询,如下所示:

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

最后,我们需要添加用户将执行的 migrate.php 文件。该文件将获取配置、实例化模式类、设置数据库并检索上次应用的迁移。运行以下代码:

<?php

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

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

$schema = new Migrations\Schema($config);

$schema->createSchema();

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

现在可以再次运行测试了。这一次,输出结果应与此截图类似,其中所有步骤均为绿色:

image 2023 11 04 16 16 24 767

现在验收测试已经通过,我们需要添加其余的测试。为了加快工作进度,我们将添加所有场景,然后执行必要的代码使其通过,但最好一次只添加一个场景。setup.feature 的第二种情况如下(请记住,该功能包含一个 Background 部分,我们将在其中清理数据库):

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.
        """

这种情况非常重要,因为它使用了步骤定义中的参数。例如,"我有迁移文件" 步骤会出现两次,每次都有不同的迁移文件编号。该步骤的实现过程如下:

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

该方法的注释是一个正则表达式,使用 :version 作为通配符。任何以 Given I have migration file 开头、后跟其他内容的步骤都将与此步骤定义相匹配,而 "其他内容" 将作为 $version 参数以字符串形式接收。

在这里,我们引入了另一种类型的参数:表格。Then I should only have the following tables 步骤定义了一个两行一列的表格,而 Then I should have the following migrations bit 则发送了一个两行两列的表格。新步骤的实现如下:

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

表格作为 TableNode 参数接收。该类包含一个 getRows 方法,可返回一个数组,其中包含在特征文件中定义的行。

我们要添加的另一个功能是 features/migrations.feature。该功能将假定用户已经建立了数据库,因此我们将在此步骤中添加一个背景部分。我们将添加一种情况,即迁移文件的编号不是连续的,在这种情况下,应用程序应停在最后一个连续的迁移文件上。另一种情况将确保在出现错误时,应用程序不会继续迁移过程。该功能应类似于下面的内容:

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.
"""

Gherkin 没有任何新功能。两个新步骤的实现如下所示:

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

现在,我们需要添加必要的实现,以使测试通过。只需要做两处修改。第一个是模式类中的 applyMigrationsFrom 方法,在给定版本号后,该方法将尝试应用该版本号的迁移文件。如果迁移成功,就会在迁移表中添加一行,并成功添加新版本。如果迁移失败,我们会在迁移表中添加一条失败记录,然后抛出一个异常,以便脚本知道。最后,如果迁移文件不存在,返回值将是 false。将此代码添加到 Schema 类中:

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

缺少的另一点在 migrate.php 脚本中。我们需要调用新创建的 applyMigrationsFrom 方法,从最新版本开始连续调用,直到出现假值或异常为止。我们还需要打印出相关信息,以便用户知道添加了哪些迁移。在 migrate.php 脚本末尾添加以下代码:

do {
    $version++;

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

现在,运行测试,瞧!全部通过。现在您有了一个可以管理数据库迁移的库,而且由于进行了验收测试,您可以 100% 确定它可以正常工作。