数据点

用 SpecFlow 进行行为驱动设计

Julie Lerman

下载代码示例

到目前为止,您已熟悉我的偏好,即邀请开发人员到我在 Vermont 主持的用户组发表我感兴趣的话题。因此,产生了有关 Knockout.js 和 Breeze.js 等主题的专栏。还有更多主题,如命令查询职责分离 (CQRS),我已仔细研读了一段时间。但最近架构师兼测试人员 Dennis Doire 谈到 SpecFlow 和 Selenium,测试人员可使用这两个工具进行行为驱动开发 (BDD)。我又一次睁大了眼睛,开始在心里寻找玩耍这些工具的理由。话虽如此,我真正关注的还是 BDD。虽然我是数据驱动方面的工作人员,但我从数据库向上开发应用程序的日子已经一去不返,我对关注这个领域开始感兴趣。

BDD 是测试驱动开发 (TDD) 的一种变化形式,它关注用户情景以及围绕这些情景建立逻辑和测试。您不是要满足一条规则,而是要满足多组活动。它非常整体化,而我喜欢这一点,因此这个方面让我产生了浓厚的兴趣。这个理念在于,虽然典型的单元测试有可能确保客户对象的一个事件工作正常,而 BDD 关注的则是作为用户的我在使用为我建立的系统时所预期的更为广泛的行为情景。BDD 经常在与客户讨论期间用于定义验收条件。例如,当我坐在计算机前,填写“新建客户”窗体,然后按“保存”按钮时,系统应存储客户信息,然后向我显示一条消息,表示已成功存储该客户。

或者,可能当我激活软件的“客户管理”部分时,该部分应自动打开我在上一个会话中处理的最新客户。

从这些用户情景中可发现,BDD 可能是一种面向 UI 的方法,用于设计自动测试,但在设计 UI 之前将编写许多方案。并且通过 Selenium (docs.seleniumhq.org) 和 WatiN (watin.org) 等工具,可在浏览器中自动进行测试。但 BDD 不仅是描述用户交互。若要详细了解 BDD 图片,请查看 InfoQ 上的小组讨论、BDD 和 TDD 的某些官方机构以及 bit.ly/10jp6ve 上的 Specification by Example。

我想脱离对按钮单击等问题的困扰,少量地重新定义用户情景。我可从情景中删除依赖于 UI 的元素,而关注流程中不依赖于屏幕的部分。当然,我感兴趣的是与数据访问相关的情景。

构建用于测试满足特定行为的逻辑可能比较繁琐。Doire 在他的演示中介绍的一个工具是 SpecFlow (specflow.org)。此工具与 Visual Studio 集成,可通过此工具的简单规则定义用户情景(称为方案)。然后,它自动创建和执行某些方法(有些方法进行测试,有些方法不进行测试)。目标是验证满足情景的规则。

我将带领您创建一些行为以引起您的兴趣,如果您要详细了解,可在本文结尾找到一些资源。

首先,需要将 SpecFlow 装入 Visual Studio,可从 Visual Studio 的“扩展和更新管理器”中执行此操作。由于 BDD 的点是通过描述行为开始开发项目,因此解决方案中的第一个项目是一个测试项目,将在该项目中描述这些行为。解决方案的其余部分将从该点继续。

使用“单元测试项目”模板新建一个项目。项目需要引用 TechTalk.SpecFlow.dll,可使用 NuGet 安装该文件。然后,在此项目中创建一个名为 Features 的文件夹。

我的第一项功能将以有关添加新客户的用户情景为基础,因此在 Features 文件夹中,我创建了另一个名为 Add 的文件夹(见图 1)。我将在此处定义方案并让 SpecFlow 帮助我。


图 1:具有 Features 和 Add 子文件夹的测试项目

SpecFlow 遵照一种依赖关键字的特定模式,这些关键字帮助描述要定义其行为的功能。这些关键字来自一种名为 Gherkin(没错,就是泡菜里的小黄瓜)的语言,而所有这些都起源于一种名为 Cucumber (cukes.info) 的工具。其中一些关键字为 Given、And、When 和 Then,并且您可使用这些关键字生成方案。例如,下面是一个简单的方案,封装在“添加新客户”功能中:

 

Given a user has entered information about a customer
When she completes entering more information
Then that customer should be stored in the system

可更详细地描述,例如:

Given a user has entered information about a customer
And she has provided a first name and a last name as required
When she completes entering more information
Then that customer should be stored in the system

我将在这最后一个语句执行一些数据持久性。 SpecFlow 并不关心其中进行任何一个过程的方式。 目标是编写方案以证明结果是并且一直是成功的。 该方案将驱动一组测试,而这些测试将帮助您充实域逻辑:

Given that you have used the proper keywords
When you trigger SpecFlow
Then a set of steps will be generated for you to populate with code
And a class file will be generated that will automate the execution of these steps on your behalf

我们来看一下这是如何工作的。

右键单击 Add 文件夹以添加一个新项。 如果已安装 SpecFlow,则可通过搜索 specflow,找到三个与 SpecFlow 相关的项。 选择 SpecFlow Feature File 项,然后向其提供一个名称。 我将自己的该项命名为 AddCustomer.feature。

功能文件以示例(普遍存在的数学功能)开头。 注意,在顶部描述 Feature,在底部使用 Given、And、When 和 Then 描述 Scenario(表示功能的主要示例)。 SpecFlow 加载项确保文本经过彩色编码,因此可轻松分辨步骤词与您自己的语句。

我将用我自己的功能和步骤替换已有的功能和步骤:

Feature: Add Customer
Allow users to create and store new customers
As long as the new customers have a first and last name

Scenario: HappyPath
Given a user has entered information about a customer
And she has provided a first name and a last name as required
When she completes entering more information
Then that customer should be stored in the system

(多亏 David Starr 提供了 Scenario 的名称! 我从他的 Pluralsight 视频中借用了这个名称。)

如果未提供所需的数据会怎样? 我将在此功能中创建另一个方案以应对这种可能性:

Scenario: Missing Required Data
Given a user has entered information about a customer
And she has not provided the first name and last name
When she completes entering more information
Then that user will be notified about the missing data
And the customer will not be stored into the system

这个方案将暂时处理这种情况。

从用户情景到某些代码

到目前为止,您已了解 SpecFlow 提供的 Feature 项和彩色编码。 注意,有一个隐藏代码文件附加到功能文件,前者有一些根据功能创建的空测试。 其中每个测试都将执行您的方案中的步骤,但您确实需要创建这些步骤。 有几种方法可以做到这一点。 可运行测试,然后 SpecFlow 将在测试输出中返回 Steps 类的代码列表供复制和粘贴。 或者,可使用功能文件的上下文菜单中的某个工具。 我将介绍第二种方法:

  1. 在功能文件的文本编辑器窗口中单击右键。 在上下文菜单上,您将看到一个专用于 SpecFlow 任务的部分。
  2. 单击“生成步骤定义”。 随后将弹出一个窗口,其中验证要创建的步骤。
  3. 单击“将方法复制到剪贴板”按钮,然后使用默认值。
  4. 在项目中的 AddCustomer 文件夹中,新建一个名为 Steps.cs 的类文件。
  5. 打开文件,然后在类定义中,粘贴剪贴板内容。
  6. 使用 TechTalk.SpecFlow 向文件顶部添加一个命名空间引用。
  7. 向类添加绑定批注。

图 2 列出这个新类。

图 2:Steps.cs 文件

[Binding]
public class Steps
{
  [Given(@"a user has entered information about a customer")]
  public void GivenAUserHasEnteredInformationAboutACustomer()
  {
    ScenarioContext.Current.Pending();
  }
  [Given(@"she has provided a first name and a last name as required")]
  public void GivenSheHasProvidedAFirstNameAndALastNameAsRequired
 ()
  {
    ScenarioContext.Current.Pending();
  }
    [When(@"she completes entering more information")]
  public void WhenSheCompletesEnteringMoreInformation()
  {
    ScenarioContext.Current.Pending();
  }
  [Then(@"that customer should be stored in the system")]
  public void ThenThatCustomerShouldBeStoredInTheSystem()
  {
    ScenarioContext.Current.Pending();
  }
  [Given(@"she has not provided both the firstname and lastname")]
  public void GivenSheHasNotProvidedBothTheFirstnameAndLastname()
  {
    ScenarioContext.Current.Pending();
  }
  [Then(@"that user will get a message")]
  public void ThenThatUserWillGetAMessage()
  {
    ScenarioContext.Current.Pending();
  }
  [Then(@"the customer will not be stored into the system")]
  public void ThenTheCustomerWillNotBeStoredIntoTheSystem()
  {
    ScenarioContext.Current.Pending();
  }
}

如果查看我创建的两个方案,就会注意到,虽然在定义的内容中有一些重复(如“a user has entered infor­mation about a customer”),但生成的方法不会创建重复的步骤。 还可注意到 SpecFlow 将利用方法特性中的常量。 实际的方法名称无关紧要。

此时,可让 SpecFlow 运行其将调用这些方法的测试。 虽然 SpecFlow 支持许多单元测试框架,但我使用的是 MSTest,因此,如果在 Visual Studio 中查看此解决方案,就会发现,Feature 的代码隐藏文件为每个方案都定义一个 TestMethod。 每个 TestMethod 执行正确的步骤方法组合以及一个为 HappyPath 方案运行的 TestMethod。

如果我现在要通过右键单击功能文件,然后选择“运行 SpecFlow 方案”运行此方案,则测试将没有明确结论,并显示消息: “One or more step definitions are not implemented yet”(还有一个或多个步骤定义未实现)。这是因为 Steps 文件中的每种方法都仍在调用 Scenario.Current.Pending。

因此,现在要充实这些方法。 我的方案告知我,我将需要 Customer 类型和一些必要的数据。 根据其他文档,我了解到当前需要名字和姓氏,因此我将在 Customer 类型中需要这两个属性。 我还需要一个用于存储该客户的机制以及一个存储它的位置。 我的测试不考虑其存储方式或位置,保留原样即可,因此我将使用一个将负责获得和存储数据的存储库。

首先,向 Steps 类添加 _customer 和 _repository 变量:

private Customer _customer;
private Repository _repository;

然后,去掉 Customer 类:

public class Customer
{
  public int Id { get; set; }
  public string FirstName { get; set; }
  public string LastName { get; set; }
}

这样即足以让我向我的步骤方法添加代码。 图 3 显示添加到 HappyPath 相关步骤的逻辑。 我在某一步新建一个客户,然后在下一步提供所需的名字和姓氏。 实际上,不必做任何操作即可详细说明 WhenSheCompletesEnteringMoreInformation 步骤。

图 3:某些 SpecFlow 步骤方法

[Given(@"a user has entered information about a customer")]
public void GivenAUserHasEnteredInformationAboutACustomer()
{
  _newCustomer = new Customer();
}
[Given(@"she has provided a first name and a last name as required")]
public void GivenSheHasProvidedTheRequiredData()
{
  _newCustomer.FirstName = "Julie";
  _newCustomer.LastName = "Lerman";
}
[When(@"she completes entering more information")]
public void WhenSheCompletesEnteringMoreInformation()
{
}

最后一步最令人关注。 我在这一步不仅存储客户,还证明确实已存储了它。 我需要存储库中有一个 Add 方法用于存储客户、一个 Save 用于将其推送到数据库以及一种方法以查看存储库能否实际找到该客户。 因此我将向存储库添加一个 Add 方法、一个 Save 方法和一个 FindById 方法,如下所示:

public class CustomerRepository
{
  public void Add(Customer customer)
    { throw new NotImplementedException();  }
  public int Save()
    { throw new NotImplementedException();  }
  public Customer FindById(int id)
    { throw new NotImplementedException();  }
}

现在,我可向 HappyPath 方案将调用的最后一步添加逻辑。 我将向存储库添加该客户,然后通过测试,查看能否在存储库中找到该客户。 在这里,我最后使用一个断言判断我的方案是否成功。 如果找到客户(即 IsNotNull),则测试通过。 这是测试已存储这些数据的常用模式。 但是,凭借我对实体框架的经验,我发现了一个无法通过测试发现的问题。 我首先将给出以下代码,因此可用一种方式向您说明问题,而这种方式与仅向您告知正确的开始方式(这样有点不好意思)相比更容易记住:

[Then(@"that customer should be stored in the system")]
public void ThenThatCustomerShouldBeStoredInTheSystem()
{
  _repository = new CustomerRepository();
  _repository.Add(_newCustomer);
  _repository.Save();
  Assert.IsNotNull(_repository.FindById(_newCustomer.Id));
}

再次运行 HappyPath 测试时,该测试失败。 可在图 4 中看到测试输出显示我的 SpecFlow 方案到现在为止如何工作。 但请注意测试失败的原因: 不是因为 FindById 未找到客户,而是因为尚未实现我的存储库方法。

图 4:失败的测试所产生的输出,其中显示每一步的状态

Test Name:  HappyPath
Test Outcome:               Failed
Result Message:             
Test method UnitTestProject1.UserStories.Add.AddCustomerFeature.HappyPath threw exception:
System.NotImplementedException: The method or operation is not implemented.
Result StandardOutput:     
Given a user has entered information about a customer
-> done: Steps.GivenAUserHasEnteredInformationAboutACustomer() (0.0s)
And she has provided a first name and a last name as required
-> done: Steps. GivenSheHasProvidedAFirstNameAndALastNameAsRequired() (0.0s)
When she completes entering more information
-> done: Steps.WhenSheCompletesEnteringMoreInformation() (0.0s)
Then that customer should be stored in the system
-> error: The method or operation is not implemented.

那么,下一步是向我的存储库提供逻辑。 最后,我将使用此存储库与数据库进行交互,由于我碰巧是实体框架的爱好者,因此我将在我的存储库中使用实体框架 DbContext。 首先,我将创建一个 DbContext 类,它公开 Customers DbSet:

public class CustomerContext:DbContext
{
  public DbSet<Customer> Customers { get; set; }
}

然后,我可重构我的 CustomerRepository 以使用 CustomerContext 进行暂留。 对于此演示,我将直接对照上下文进行工作而不关注抽象。 以下是经过更新的 CustomerRepository:

public  class CustomerRepository
{
  private CustomerContext _context = new CustomerContext();
  public void Add(Customer customer
  {    _context.Customers.Add(customer);  }
  public int Save()
  {    return _context.SaveChanges();  }
  public Customer FindById(int id)
  {    return _context.Customers.Find(id);  }
}

现在,当我重新运行 HappyPath 测试时,该测试通过,并且我的所有步骤均被标为已完成。 但我仍不满意。

确保这些集成测试了解 EF 行为

为什么我的测试通过并且看到漂亮的绿色圆圈时还不满足? 因为我知道该测试并非确实证明存储了客户。

在 ThenThatCustomerShouldBeStoredInTheSystem 方法中,将对 Save 的调用注释掉,然后再次运行该测试。 它仍可通过测试。 并且,我甚至没有将客户保存到数据库中! 现在,您是否察觉到什么不正常的情况? 这正是所谓的“误报”。

问题在于我在存储库中使用的 DbSet Find 方法是实体框架中的一个特殊方法,它首先检查由上下文跟踪的内存中对象,然后再转到数据库。 当我调用 Add 时,就使 CustomerContext 感知到该客户实例。 对 Customers.Find 的调用发现该实例,并跳过对数据库执行无意义的操作。 实际上,客户的 ID 仍为 0,因为尚未保存它。

那么,由于我使用实体框架(应将所使用的任何对象关系映射 [ORM] 框架的行为考虑在内),因此我有一种更简单的方式可了解客户是否确实存入数据库。 当 EF SaveChanges 指令将客户插入数据库时,它将取回由新数据库生成的客户 ID,然后将其应用于它插入的实例。 因此,如果新客户的 ID 不再为 0,那么我就知道我的客户确实已存入数据库。 我不必重新查询数据库。

我将相应地修改该方法的 Assert。 以下是我所知道的将进行适当测试的方法:

[Then(@"that customer should be stored in the system")]
  public void ThenThatCustomerShouldBeStoredInTheSystem()
  {
    _repository = new CustomerRepository();
    _repository.Add(_newCustomer);
    _repository.Save();
    Assert.IsNotNull(_newCustomer.Id>0);
  }

该测试通过,并且我知道它因为正确的原因而通过。 定义的测试无法通过的情况并不少见,例如,使用 Assert.IsNull(FindById(customer.Id) 确保您不是因为错误的原因而未通过。 但在这种情况下,直到我删除对 Save 的调用后,问题才会显现。 如果对 EF 的工作方式没有把握,那么最好还要创建一些与用户情景无关的特定集成测试,以确保存储库表现出的行为符合预期。

行为测试还是集成测试?

在我仔细研究有关理解这第一个 SpecFlow 方案的学习曲线时,我遇到了急转直下的情况。 我的方案规定客户应存储在“系统”中。

问题在于我对系统的定义没有把握。 我的职业经历告诉我,数据库或至少某些持久性机制是系统中非常重要的一部分。

但用户不关注存储库和数据库 - 而只关注其应用程序。 但是,如果用户重新登录其应用程序,却因客户并未真正存储到数据库中(因为我觉得 _repository.Save 对于实现其方案并非必要)而无法再次找到该客户,那么用户不会很高兴。

我请教了另一位 Dennis,Dennis Doomen,他是 Fluent Assertions 的作者,多次参与过大型企业系统的 BDD、TDD 等项目。 他确认,我作为开发人员,肯定应该将我的知识应用于步骤和测试,即使这意味着超出定义原始方案的用户的意图也是如此。 用户提供其知识,而我加入我的知识,但不要将我的技术观点强加于用户。 我继续以用户能听懂的方式交谈,于是与用户的交流畅通无阻。

深入学习 BDD 和 SpecFlow

我深知,如果没有所有这些为支持 BDD 而开发的工具,我学习它的过程不会这么轻松。 虽然我是一个数据极客,但我非常注重与客户协同工作、了解其业务并确保其使用我帮助为其开发的软件时拥有快乐的体验。 这正是领域驱动设计和行为驱动设计对我如此重要的原因。 我觉得许多开发人员与我感同身受,甚至感触更深,并且也可受到这些方法的启发。

除了一路走来曾帮助过我的朋友以外,以下是我找到的一些有用资源。 MSDN 杂志文章“使用 SpecFlow 和 WatiN 进行行为驱动开发”很有助益,可在 msdn.microsoft.com/magazine/gg490346 上找到它。 我还观看了 David Starr 在 Pluralsight.com 上 Test First Development 课程中一个非常好的单元。 (实际上,我观看了该单元许多次。) 我发现维基百科上有关 BDD 的条目 (bit.ly/LCgkxf) 令人感兴趣,其中详细介绍了 BDD 的历史以及它适合哪些其他做法。 此外,我热切盼望 Paul Rayner(他也曾建议我到这儿来)与人合著的“BDD and Cucumber”这本书的出版上市。

Julie Lerman是 Microsoft MVP、.NET 导师和顾问,住在佛蒙特州的山区。 您可以在全球的用户组和会议中看到她对数据访问和其他 Microsoft .NET 主题的演示。 她是《Programming Entity Framework》(2010) 以及“代码优先”版 (2011) 和 DbContext 版 (2012)(均出自 O’Reilly Media)的作者,博客网址为 thedatafarm.com/blog。 请关注她的 Twitter:twitter.com/julielerman

衷心感谢以下技术专家对本文的审阅: Dennis Doomen (Aviva Solutions) 和 Paul Rayner (Virtual Genius)
Dennis Doomen 在 Aviva Solutions(荷兰)担任首席咨询顾问,偶尔进行演讲,担任敏捷教练,著有《Coding Guidelines for C# 3.0, 4.0 and 5.0》、开发了 Fluent Assertions 框架并著有《The Silverlight Cookbook》。 当前,他以 .NET、事件溯源和 CQRS 为基础开发企业级解决方案。 他热爱敏捷开发、架构、极限编程和领域驱动设计。 可在 Twitter 上使用 @ddoomen 关注他。