好的,我们开始今天的讲座,主题是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的安装和配置
-
安装Behat:
使用Composer安装Behat:
composer require behat/behat -
初始化Behat:
在项目根目录下运行以下命令:
./vendor/bin/behat --init这将在项目根目录下创建以下目录和文件:
features/: 存放Gherkin文件。features/bootstrap/FeatureContext.php: 存放Behat上下文类,用于将Gherkin步骤映射到PHP代码。behat.yml: Behat的配置文件。
-
配置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 语言,可以创建可执行的文档,确保开发团队和业务利益相关者对软件行为有共同的理解。 这种方法有助于减少沟通误差、提高软件质量,并最终交付更符合用户期望的产品。
希望今天的讲座对你有所帮助。