Behat行为驱动开发(BDD):使用Gherkin语言编写可执行的业务需求文档

好的,我们开始今天的讲座,主题是Behat行为驱动开发(BDD):使用Gherkin语言编写可执行的业务需求文档。

引言:为什么要使用BDD和Behat?

在软件开发过程中,沟通障碍是导致项目失败的常见原因。业务人员、开发人员、测试人员对需求的理解往往存在偏差,导致最终交付的软件不符合预期。行为驱动开发(BDD)旨在弥合这一差距,通过使用通俗易懂的语言描述软件的行为,确保所有相关人员对需求达成一致。

Behat是一个流行的PHP框架,专门用于执行用Gherkin语言编写的BDD测试。Gherkin是一种简单的、类似自然语言的语法,用于描述软件的功能,它使用预定义的关键字(如Given、When、Then)来组织测试用例。Behat解释Gherkin文件,并执行相关的PHP代码,验证软件的行为是否符合预期。

Gherkin语言:编写可执行的需求文档

Gherkin文件的基本结构如下:

Feature: 描述要测试的功能

  Scenario: 描述一个特定的场景
    Given 一些前提条件
    When 执行一个动作
    Then 验证结果
  • Feature: 描述要测试的软件功能。一个Gherkin文件通常只描述一个Feature。
  • Scenario: 描述一个特定的场景,也称为测试用例。一个Feature可以包含多个Scenario。
  • Given: 定义测试的前提条件。
  • When: 描述用户执行的动作。
  • Then: 验证执行动作后的结果。

除了上述关键字,Gherkin还支持以下关键字:

  • And: 用于连接多个Given、When或Then步骤,提高可读性。
  • But: 与And类似,但强调步骤之间的对比关系。
  • Background: 定义在每个Scenario之前执行的步骤,用于设置通用的前提条件。
  • Scenario Outline: 用于定义具有多个不同数据集的Scenario。
  • Examples: 用于为Scenario Outline提供数据集。
  • @tag: 用于标记Scenario或Feature,方便分组和过滤测试。
  • """ (Doc Strings): 用于定义多行文本参数。
  • | (Data Tables): 用于定义表格数据参数。

Gherkin语法示例:一个简单的登录功能

假设我们要测试一个网站的登录功能。我们可以编写以下Gherkin文件(features/login.feature):

Feature: 用户登录

  Scenario: 使用正确的用户名和密码登录
    Given 我在登录页面
    When 我输入用户名 "user123"
    And 我输入密码 "password123"
    And 我点击 "登录" 按钮
    Then 我应该看到 "欢迎,user123!" 的消息

  Scenario: 使用错误的密码登录
    Given 我在登录页面
    When 我输入用户名 "user123"
    And 我输入密码 "wrongpassword"
    And 我点击 "登录" 按钮
    Then 我应该看到 "用户名或密码错误" 的消息

Behat的安装和配置

  1. 安装Behat:

    使用Composer安装Behat:

    composer require behat/behat
  2. 初始化Behat:

    在项目根目录下运行以下命令:

    ./vendor/bin/behat --init

    这将在项目根目录下创建以下目录和文件:

    • features/: 存放Gherkin文件。
    • features/bootstrap/FeatureContext.php: 存放Behat上下文类,用于将Gherkin步骤映射到PHP代码。
    • behat.yml: Behat的配置文件。
  3. 配置Behat:

    编辑behat.yml文件,配置Behat的行为。例如,我们可以指定Gherkin文件的路径:

    default:
        suites:
            default:
                paths:
                    - features
                contexts:
                    - FeatureContext

编写Behat上下文类:将Gherkin步骤映射到PHP代码

Behat上下文类负责将Gherkin步骤映射到PHP代码,执行实际的测试逻辑。编辑features/bootstrap/FeatureContext.php文件,添加以下代码:

<?php

use BehatBehatContextContext;
use BehatGherkinNodePyStringNode;
use BehatGherkinNodeTableNode;

/**
 * Defines application features from the specific context.
 */
class FeatureContext implements Context
{
    /**
     * @Given 我在登录页面
     */
    public function iAmOnTheLoginPage()
    {
        // 访问登录页面。这里需要根据你的应用实现。
        // 例如:
        // $this->visit('/login');
        throw new PendingException(); // 标记为待实现
    }

    /**
     * @When 我输入用户名 :username
     */
    public function iEnterUsername($username)
    {
        // 输入用户名。这里需要根据你的应用实现。
        // 例如:
        // $this->fillField('username', $username);
        throw new PendingException(); // 标记为待实现
    }

    /**
     * @When 我输入密码 :password
     */
    public function iEnterPassword($password)
    {
        // 输入密码。这里需要根据你的应用实现。
        // 例如:
        // $this->fillField('password', $password);
        throw new PendingException(); // 标记为待实现
    }

    /**
     * @When 我点击 :button 按钮
     */
    public function iClickButton($button)
    {
        // 点击按钮。这里需要根据你的应用实现。
        // 例如:
        // $this->pressButton($button);
        throw new PendingException(); // 标记为待实现
    }

    /**
     * @Then 我应该看到 :message 的消息
     */
    public function iShouldSeeMessage($message)
    {
        // 验证页面上是否显示指定的文本。这里需要根据你的应用实现。
        // 例如:
        // $this->assertPageContainsText($message);
        throw new PendingException(); // 标记为待实现
    }
}

在这个上下文中,我们定义了五个方法,分别对应于Gherkin文件中的五个步骤。每个方法都使用了@Given@When@Then注解,将方法与Gherkin步骤关联起来。方法中的代码用于执行实际的测试逻辑。throw new PendingException(); 表示该方法尚未实现,Behat会将其标记为未完成的测试。

实现Behat上下文类:连接到你的应用

上面的代码只是一个框架,你需要根据你的应用实现具体的测试逻辑。以下是一个使用Symfony框架的示例:

首先,你需要安装Behat的Symfony2扩展:

composer require behat/symfony2-extension

然后,在behat.yml文件中配置Symfony2扩展:

default:
    suites:
        default:
            paths:
                - features
            contexts:
                - FeatureContext
    extensions:
        BehatSymfony2Extension:
            kernel:
                env: test
                debug: true

现在,你可以使用Symfony2的容器来访问你的应用服务。修改features/bootstrap/FeatureContext.php文件如下:

<?php

use BehatBehatContextContext;
use BehatGherkinNodePyStringNode;
use BehatGherkinNodeTableNode;
use SymfonyComponentHttpKernelKernelInterface;
use SymfonyComponentHttpFoundationRequest;
use SymfonyComponentHttpFoundationResponse;
use SymfonyComponentRoutingRouterInterface;

/**
 * Defines application features from the specific context.
 */
class FeatureContext implements Context
{
    /**
     * @var KernelInterface
     */
    private $kernel;

    /**
     * @var RouterInterface
     */
    private $router;

    /**
     * @var Response|null
     */
    private $response;

    /**
     * Initializes context.
     *
     * Every scenario gets its own context instance.
     * You can define context arguments through the parameters:
     * - 'parameter_name' => 'parameter_value'
     *
     * @param KernelInterface $kernel
     * @param RouterInterface $router
     */
    public function __construct(KernelInterface $kernel, RouterInterface $router)
    {
        $this->kernel = $kernel;
        $this->router = $router;
    }

    /**
     * @Given 我在登录页面
     */
    public function iAmOnTheLoginPage()
    {
        $url = $this->router->generate('login_route'); // 替换为你的登录页面路由名称
        $this->response = $this->kernel->handle(Request::create($url, 'GET'));

        if ($this->response->getStatusCode() !== 200) {
            throw new Exception('Failed to load login page.');
        }
    }

    /**
     * @When 我输入用户名 :username
     */
    public function iEnterUsername($username)
    {
        $form = $this->getLoginForm(); // 获取登录表单

        $form['username'] = $username;

        $this->setLoginForm($form);
    }

    /**
     * @When 我输入密码 :password
     */
    public function iEnterPassword($password)
    {
        $form = $this->getLoginForm(); // 获取登录表单

        $form['password'] = $password;

        $this->setLoginForm($form);
    }

    /**
     * @When 我点击 :button 按钮
     */
    public function iClickButton($button)
    {
        $form = $this->getLoginForm();

        $url = $this->router->generate('login_route'); // 替换为你的登录页面路由名称
        $request = Request::create($url, 'POST', $form);

        $this->response = $this->kernel->handle($request);
    }

    /**
     * @Then 我应该看到 :message 的消息
     */
    public function iShouldSeeMessage($message)
    {
        if (strpos($this->response->getContent(), $message) === false) {
            throw new Exception(sprintf('Message "%s" not found in response.', $message));
        }
    }

    /**
     * Helper function to extract form values (simulated)
     */
    private function getLoginForm()
    {
        // Simulate form values (replace with actual form interaction logic if possible)
        if (!isset($this->session['form'])) {
            $this->session['form'] = [];
        }
        return $this->session['form'];
    }

    /**
     * Helper function to set form values (simulated)
     */
    private function setLoginForm($form)
    {
        $this->session['form'] = $form;
    }
}

解释:

  • __construct(KernelInterface $kernel, RouterInterface $router): 构造函数接收Symfony的内核和路由服务,以便访问应用的功能。
  • iAmOnTheLoginPage(): 使用路由服务生成登录页面的URL,并发送GET请求。
  • iEnterUsername()iEnterPassword(): 模拟填写表单字段。 注意: 这里使用了$this->session来临时存储表单数据,实际项目中应该根据你的表单处理方式进行修改,例如使用Symfony的Form组件。
  • iClickButton(): 模拟点击登录按钮,发送POST请求到登录页面。
  • iShouldSeeMessage(): 验证响应内容是否包含指定的文本。

运行Behat测试

在项目根目录下运行以下命令:

./vendor/bin/behat

Behat会读取features目录下的所有Gherkin文件,并执行相关的测试。它会输出测试结果,显示哪些测试通过,哪些测试失败。

Scenario Outline和Examples:参数化测试

Scenario Outline允许你使用不同的数据集运行同一个Scenario。例如,我们可以使用Scenario Outline来测试不同的用户名和密码组合:

Feature: 用户登录

  Scenario Outline: 使用不同的用户名和密码组合登录
    Given 我在登录页面
    When 我输入用户名 "<username>"
    And 我输入密码 "<password>"
    And 我点击 "登录" 按钮
    Then 我应该看到 "<message>" 的消息

    Examples:
      | username | password    | message                |
      | user123  | password123 | 欢迎,user123!       |
      | user123  | wrongpassword | 用户名或密码错误       |
      |          | password123 | 用户名不能为空          |
      | user123  |             | 密码不能为空            |

Scenario Outline中,我们使用<username><password><message>作为占位符。Examples表格提供了这些占位符的值。Behat会使用每一行数据运行一次Scenario。

修改features/bootstrap/FeatureContext.php文件,支持Scenario Outline

<?php

use BehatBehatContextContext;
use BehatGherkinNodePyStringNode;
use BehatGherkinNodeTableNode;
use SymfonyComponentHttpKernelKernelInterface;
use SymfonyComponentHttpFoundationRequest;
use SymfonyComponentHttpFoundationResponse;
use SymfonyComponentRoutingRouterInterface;

/**
 * Defines application features from the specific context.
 */
class FeatureContext implements Context
{
    /**
     * @var KernelInterface
     */
    private $kernel;

    /**
     * @var RouterInterface
     */
    private $router;

    /**
     * @var Response|null
     */
    private $response;

    private $session = []; // Used to simulate form submissions

    /**
     * Initializes context.
     *
     * Every scenario gets its own context instance.
     * You can define context arguments through the parameters:
     * - 'parameter_name' => 'parameter_value'
     *
     * @param KernelInterface $kernel
     * @param RouterInterface $router
     */
    public function __construct(KernelInterface $kernel, RouterInterface $router)
    {
        $this->kernel = $kernel;
        $this->router = $router;
    }

    /**
     * @Given 我在登录页面
     */
    public function iAmOnTheLoginPage()
    {
        $url = $this->router->generate('login_route'); // 替换为你的登录页面路由名称
        $this->response = $this->kernel->handle(Request::create($url, 'GET'));

        if ($this->response->getStatusCode() !== 200) {
            throw new Exception('Failed to load login page.');
        }
    }

    /**
     * @When 我输入用户名 :username
     */
    public function iEnterUsername($username)
    {
        $form = $this->getLoginForm(); // 获取登录表单

        $form['username'] = $username;

        $this->setLoginForm($form);
    }

    /**
     * @When 我输入密码 :password
     */
    public function iEnterPassword($password)
    {
        $form = $this->getLoginForm(); // 获取登录表单

        $form['password'] = $password;

        $this->setLoginForm($form);
    }

    /**
     * @When 我点击 :button 按钮
     */
    public function iClickButton($button)
    {
        $form = $this->getLoginForm();

        $url = $this->router->generate('login_route'); // 替换为你的登录页面路由名称
        $request = Request::create($url, 'POST', $form);

        $this->response = $this->kernel->handle($request);
    }

    /**
     * @Then 我应该看到 :message 的消息
     */
    public function iShouldSeeMessage($message)
    {
        if (strpos($this->response->getContent(), $message) === false) {
            throw new Exception(sprintf('Message "%s" not found in response.', $message));
        }
    }

    /**
     * Helper function to extract form values (simulated)
     */
    private function getLoginForm()
    {
        // Simulate form values (replace with actual form interaction logic if possible)
        if (!isset($this->session['form'])) {
            $this->session['form'] = [];
        }
        return $this->session['form'];
    }

    /**
     * Helper function to set form values (simulated)
     */
    private function setLoginForm($form)
    {
        $this->session['form'] = $form;
    }
}

Data Tables:传递表格数据

除了Scenario Outline,Gherkin还支持使用Data Tables传递表格数据。Data Tables可以用于传递更复杂的数据结构。例如:

Feature: 用户注册

  Scenario: 注册一个新用户
    Given 我在注册页面
    When 我输入以下用户信息:
      | username | password    | email             |
      | newuser  | newpassword | [email protected] |
    And 我点击 "注册" 按钮
    Then 我应该看到 "注册成功!" 的消息

修改features/bootstrap/FeatureContext.php文件,支持Data Tables

<?php

use BehatBehatContextContext;
use BehatGherkinNodePyStringNode;
use BehatGherkinNodeTableNode;
use SymfonyComponentHttpKernelKernelInterface;
use SymfonyComponentHttpFoundationRequest;
use SymfonyComponentHttpFoundationResponse;
use SymfonyComponentRoutingRouterInterface;

/**
 * Defines application features from the specific context.
 */
class FeatureContext implements Context
{
    /**
     * @var KernelInterface
     */
    private $kernel;

    /**
     * @var RouterInterface
     */
    private $router;

    /**
     * @var Response|null
     */
    private $response;

    private $session = []; // Used to simulate form submissions

    /**
     * Initializes context.
     *
     * Every scenario gets its own context instance.
     * You can define context arguments through the parameters:
     * - 'parameter_name' => 'parameter_value'
     *
     * @param KernelInterface $kernel
     * @param RouterInterface $router
     */
    public function __construct(KernelInterface $kernel, RouterInterface $router)
    {
        $this->kernel = $kernel;
        $this->router = $router;
    }

    /**
     * @Given 我在注册页面
     */
    public function iAmOnTheRegistrationPage()
    {
        $url = $this->router->generate('register_route'); // 替换为你的注册页面路由名称
        $this->response = $this->kernel->handle(Request::create($url, 'GET'));

        if ($this->response->getStatusCode() !== 200) {
            throw new Exception('Failed to load registration page.');
        }
    }

    /**
     * @When 我输入以下用户信息:
     */
    public function iEnterTheFollowingUserDetails(TableNode $table)
    {
        $formData = $table->getHash()[0];  // Get the first row of data
        $form = $this->getRegistrationForm();

        $form['username'] = $formData['username'];
        $form['password'] = $formData['password'];
        $form['email'] = $formData['email'];

        $this->setRegistrationForm($form);

    }

    /**
     * @When 我点击 :button 按钮
     */
    public function iClickButton($button)
    {
        $form = $this->getRegistrationForm();

        $url = $this->router->generate('register_route'); // 替换为你的注册页面路由名称
        $request = Request::create($url, 'POST', $form);

        $this->response = $this->kernel->handle($request);
    }

    /**
     * @Then 我应该看到 :message 的消息
     */
    public function iShouldSeeMessage($message)
    {
        if (strpos($this->response->getContent(), $message) === false) {
            throw new Exception(sprintf('Message "%s" not found in response.', $message));
        }
    }

    /**
     * Helper function to extract form values (simulated)
     */
    private function getRegistrationForm()
    {
        // Simulate form values (replace with actual form interaction logic if possible)
        if (!isset($this->session['registration_form'])) {
            $this->session['registration_form'] = [];
        }
        return $this->session['registration_form'];
    }

    /**
     * Helper function to set form values (simulated)
     */
    private function setRegistrationForm($form)
    {
        $this->session['registration_form'] = $form;
    }
}

在这个例子中,iEnterTheFollowingUserDetails方法接收一个TableNode对象,该对象包含了Data Tables中的数据。你可以使用$table->getHash()方法将Data Tables转换为一个关联数组,方便访问数据。

使用Tags:分组和过滤测试

可以使用@tag来标记Scenario或Feature。例如:

@login
Feature: 用户登录

  @smoke
  Scenario: 使用正确的用户名和密码登录
    Given 我在登录页面
    When 我输入用户名 "user123"
    And 我输入密码 "password123"
    And 我点击 "登录" 按钮
    Then 我应该看到 "欢迎,user123!" 的消息

  @regression
  Scenario: 使用错误的密码登录
    Given 我在登录页面
    When 我输入用户名 "user123"
    And 我输入密码 "wrongpassword"
    And 我点击 "登录" 按钮
    Then 我应该看到 "用户名或密码错误" 的消息

你可以使用--tags选项来过滤测试。例如,运行所有带有@login标签的测试:

./vendor/bin/behat --tags login

运行所有带有@smoke标签的测试:

./vendor/bin/behat --tags smoke

运行所有不带有@regression标签的测试:

./vendor/bin/behat --tags ~regression

Doc Strings:传递多行文本

Doc Strings允许你传递多行文本作为参数。例如:

Feature: 发送邮件

  Scenario: 发送包含HTML内容的邮件
    Given 我有一个邮件模板
    """
    <html>
      <body>
        <h1>Hello, world!</h1>
      </body>
    </html>
    """
    When 我发送邮件给 "[email protected]"
    Then 邮件应该包含 "Hello, world!"

修改features/bootstrap/FeatureContext.php文件,支持Doc Strings

<?php

use BehatBehatContextContext;
use BehatGherkinNodePyStringNode;
use BehatGherkinNodeTableNode;
use SymfonyComponentHttpKernelKernelInterface;
use SymfonyComponentHttpFoundationRequest;
use SymfonyComponentHttpFoundationResponse;
use SymfonyComponentRoutingRouterInterface;

/**
 * Defines application features from the specific context.
 */
class FeatureContext implements Context
{
     /**
     * @var KernelInterface
     */
    private $kernel;

    /**
     * @var RouterInterface
     */
    private $router;

    /**
     * @var Response|null
     */
    private $response;

    private $session = []; // Used to simulate form submissions

    private $emailContent; // Store the email content

    /**
     * Initializes context.
     *
     * Every scenario gets its own context instance.
     * You can define context arguments through the parameters:
     * - 'parameter_name' => 'parameter_value'
     *
     * @param KernelInterface $kernel
     * @param RouterInterface $router
     */
    public function __construct(KernelInterface $kernel, RouterInterface $router)
    {
        $this->kernel = $kernel;
        $this->router = $router;
    }

    /**
     * @Given 我有一个邮件模板
     */
    public function iHaveAnEmailTemplate(PyStringNode $string)
    {
        $this->emailContent = $string;
    }

    /**
     * @When 我发送邮件给 :email
     */
    public function iSendTheEmailTo($email)
    {
        // Simulate sending the email (replace with actual email sending logic)
        // For example:
        // $mailer->send($email, $this->emailContent);
        // For this example, we'll just store the email address in the session

        $this->session['email_recipient'] = $email;
        $this->session['email_content'] = $this->emailContent;

    }

    /**
     * @Then 邮件应该包含 :text
     */
    public function theEmailShouldContain($text)
    {
        if (strpos($this->session['email_content'], $text) === false) {
            throw new Exception(sprintf('Email does not contain "%s".', $text));
        }
    }
}

在这个例子中,iHaveAnEmailTemplate方法接收一个PyStringNode对象,该对象包含了Doc Strings中的文本。你可以使用$string->getRaw()方法获取文本内容。

BDD的优势和最佳实践

  • 提高沟通效率: BDD使用通俗易懂的语言描述软件的行为,促进业务人员、开发人员和测试人员之间的沟通。
  • 确保需求一致性: BDD测试用例是可执行的需求文档,确保所有相关人员对需求的理解是一致的。
  • 早期发现缺陷: BDD测试用例可以在开发早期编写和执行,帮助早期发现缺陷,降低修复成本。
  • 提高代码质量: BDD鼓励编写可测试的代码,提高代码的可维护性和可重用性。
  • 编写清晰的Gherkin文件: Gherkin文件应该简洁、清晰、易于理解。避免使用过于技术性的术语。
  • 保持测试用例的独立性: 每个测试用例应该独立运行,不依赖于其他测试用例。
  • 使用适当的测试范围: BDD适用于集成测试和验收测试,不适用于单元测试。
  • 与开发过程集成: BDD应该与开发过程紧密集成,测试用例应该随着需求的变化而更新。

表格总结Gherkin 关键字

关键字 描述
Feature 描述要测试的软件功能。一个Gherkin文件通常只描述一个Feature。
Scenario 描述一个特定的场景,也称为测试用例。一个Feature可以包含多个Scenario。
Given 定义测试的前提条件。
When 描述用户执行的动作。
Then 验证执行动作后的结果。
And 用于连接多个Given、When或Then步骤,提高可读性。
But 与And类似,但强调步骤之间的对比关系。
Background 定义在每个Scenario之前执行的步骤,用于设置通用的前提条件。
Scenario Outline 用于定义具有多个不同数据集的Scenario。
Examples 用于为Scenario Outline提供数据集。
@tag 用于标记Scenario或Feature,方便分组和过滤测试。
""" (Doc Strings) 用于定义多行文本参数。
| (Data Tables) 用于定义表格数据参数。

总结:BDD 的核心价值

BDD 和 Behat 提供了一种结构化的方式来定义、自动化和验证软件需求。通过使用 Gherkin 语言,可以创建可执行的文档,确保开发团队和业务利益相关者对软件行为有共同的理解。 这种方法有助于减少沟通误差、提高软件质量,并最终交付更符合用户期望的产品。

希望今天的讲座对你有所帮助。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注