65.9K
CodeProject 正在变化。 阅读更多。
Home

使用 NBehave 进行行为驱动开发

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.97/5 (34投票s)

2009年1月14日

CPOL

11分钟阅读

viewsIcon

93621

downloadIcon

390

使用 NBehave 和 MbUnit 进行 BDD 教程。

目录

引言

本文简要介绍了行为驱动开发(BDD)的概念。在本文中,我将介绍如何使用 BDD 技术来创建 C# 语言中的银行账户模型,该模型具有标志性的 Deposit()Withdraw() 方法。为此,我将使用 NBehave 框架进行 BDD,并使用 MbUnit 作为底层测试框架。

请注意,对于这篇入门文章,我将避免使用模拟对象(mock objects)——当然,在实际项目中不应该回避这个问题。此外,我不会讨论代码覆盖率问题,当然,如果您希望测试覆盖所有可能的场景,代码覆盖率也是很重要的。

什么是 BDD?

好的,您知道 TDD 是什么,对吧?TDD,也称为测试驱动开发,是一个非常简单的概念。在 TDD 中,您为尚未实现的某个功能编写单元测试,看到它失败,添加必要的功能,然后看到它成功。使用 TDD,您的银行账户提款单元测试可能如下所示

[Test]
public void WithdrawalTest()
{
  Account a = new Account(100);
  a.Withdraw(30);
  Assert.AreEqual(a.Balance, 70);
  Assert.Throws<InsufficientFundsException>(a.Withdraw(100));
}

这个单元测试还可以,但它并没有告诉您实际测试了什么。上面本质上发生的是状态比较。例如,第一次提款后,您将账户的 Balance 状态与值 70 进行比较。这里没有“余额减少了提款金额”的概念。这个测试非常机械化,而且描述性不强。

引入 BDD 的概念。基本上,BDD 的设计理念是,不是将被测代码描述为某种最终的状态机,而是赋予它与行为相关的特性。这意味着您用英语描述单元测试中的每个步骤做什么,并将特定的代码与该步骤关联起来。您还提供有关用户故事的附加元数据,即用户在此测试中希望发生什么,以及这对他们来说为什么重要。

听起来很困惑?稍后我们将通过一个实际示例来介绍,但在那之前,让我们先简要讨论一下您需要添加到应用程序中的库。

工具

首先,您需要 NBehave,这是实现 BDD 的库。虽然 NBehave 的主页在这里,但您应该从其 Google Code 存储库获取最新版本。这一点至关重要,因为只有 0.4 版本才包含我们需要的 MbUnit 支持。如果您获取的是早期版本,将无法让 MbUnit 和 NBehave 协同工作。

NBehave 运行在一个“常规”单元测试框架之上,本文将使用 MbUnit。您可以 在这里 获取 MbUnit,它是 Gallio 自动化框架的一部分。下载链接就在首页上,所以我建议直接去下载。

与常规单元测试一样,您需要某种测试运行器来实际运行测试并报告结果。如果您有 ReSharper,就不用担心了,因为 Gallio 自带了一个 ReSharper 插件,用于运行各种单元测试,包括 MbUnit。如果您没有 ReSharper,可以使用 Gallio 本身,因为它自带测试运行器。您只需要打开包含单元测试的程序集,即可完成。

示例:银行账户

让我们定义一个模拟银行账户的实体。它将具有以下特性:

  • 一个 Balance 属性。
  • 一个 Withdraw(int amount) 方法,用于从账户中提取资金。如果资金不足,将抛出 InsufficientFundsException
  • 一个 Deposit(int amount) 方法,用于向账户存入资金。
  • 一个静态 Transfer(Account from, Account to, int amount) 方法,用于将资金从一个账户转移到另一个账户。

对于上述方法,我们还同意,如果传递给任何函数的 amount 为非正数,则抛出 ArgumentException

这个非常简单的模型足以让我们开始。实际上,我现在就可以编写这个类的接口,因为我们无论如何都需要它(没有接口,我们就无法编写测试——即使是失败的测试)。

public sealed class Account
{
  private int balance;

  public int Balance
  {
    get { return balance; }
    set { balance = value; }
  }

  public void Deposit(int amount)
  {
  }

  public void Withdraw(int amount)
  {
  }

  public static void Transfer(Account from, Account to, int amount)
  {
  }
}

为完整起见,这里是 InsufficientFundsException

public class InsufficientFundsException : Exception
{
  public InsufficientFundsException(int requested, int available)
  {
    AmountAvailable = available;
    AmountRequested = requested;
  }

  public int AmountRequested { get; set; }

  public int AmountAvailable { get; set; }
}

模型就位后,我们终于可以开始 BDD 了!万岁!

测试

概述

让我们通过添加项目引用开始

首先,添加对 MbUnit 程序集的引用。它在 GAC 中,所以不需要搜索。我们只需要这个程序集中的 MbUnit.Framework 命名空间,用于一些基本属性,例如 [SetUp]——其他所有内容都由 NBehave 直接处理。

我们需要 NBehave 的三个程序集——它们是 NBehave.Narrator.FrameworkNBehave.Spec.FrameworkNBehave.Spec.MbUnit。第一个程序集导入所谓的 Narrator 的 API——一个模仿叙述者讲述用户故事的接口。基本上,您将使用代码来说“作为一个用户,我希望账户提款正常工作”。第二个框架包含 SpecBase 类的基本定义——这个类很重要,因为我们的测试类需要继承它。最后,第三个框架是——您猜对了—— MbUnit 和 NBehave 之间的桥梁。实际上,这个框架依赖于 NBehave.Spec.Framework,因为它包含了一个用于 MbUnit 的 SpecBase 的特化。

关于 NBehave.Spec.MbUnit 的一点需要注意是,除了 SpecBase 类(我们只需继承它然后忽略它(在大多数情况下)),这个程序集还包含一些扩展方法,在一定程度上镜像了 MbUnit 的 Assert 功能。我的意思是

// in MbUnit, we write this
Assert.AreEqual(a.Balance, 70);
// but in NBehave, we write this
a.Balance.ShouldEqual(70);

上述语句是等效的,但 NBehave 的版本可能更清楚地表达了它的实际含义。但是,请注意,在撰写本文时,启用此行为的扩展方法尚未涵盖 MbUnit 的 Assert 类的所有功能。因此,尽管这种语法很好,但您并不总能使用它——特别是如果您依赖 MbUnit 的更高级功能。

测试类

长话短说,这是我的测试类的骨架

[
  Author("Dmitri", "dmitrinesteruk@gmail.com"),
  Wrote("Account operation tests"),
  TestsOn(typeof(Account)),
  For("Banking system")
]
public class AccountTest : SpecBase
{
  public Account account;
  public Account account2;

  [SetUp]
  public void Initialize_before_each_test()
  {
    account = new Account();
    account2 = new Account { Balance = 100 };
  }
}

这里没有什么特别有趣的。我们的测试类继承自 SpecBase,并用相当标准的 MbUnit 测试属性装饰,这些属性已被重新定义(参见下一节)以使其更具可读性。在测试类本身中,我创建了两个账户——一个空的,一个带有 100 美元。您会注意到,尽管我使用了老式的 [SetUp] 属性,但方法名称有点奇怪。事实上,这是 TDD 的惯例之一,让方法名称用英语而不是某种简写符号来描述正在发生的事情。所以,这正是这里所做的。

故事

一切都始于一个故事。很久很久以前,NBehave 开发者编写了(有些巧妙的)名为 Story 的类。这个类旨在描述您试图测试的特定的一组使用场景。例如,故事会这样描述账户的存款操作:

[Story, That, Should("Increase account balance when money is deposited")]
public void Deposit_should_increase_account_balance()
{
  Story story = new Story("Deposit");
  story.AsA("User")
    .IWant("The bank account balance to increase by the amount deposited")
    .SoThat("I can deposit money");

  // scenarios here
}

那么,这里发生了什么?首先,装饰测试方法的属性实际上使用了熟悉的 xUnit 测试属性(例如 [Test]),但对于 BDD,许多人(包括我)使用 C# 的 using 语法重新定义了它们

using That = MbUnit.Framework.TestAttribute;
using Describe = MbUnit.Framework.CategoryAttribute;
using For = MbUnit.Framework.CategoryAttribute;
using Wrote = MbUnit.Framework.DescriptionAttribute;
using Should = MbUnit.Framework.DescriptionAttribute;

这些重新定义唯一的目的是使测试更具可读性。这种趋势渗透到 NBehave 中——习惯它。现在,让我们看看我们对 Story 类做了什么。这里没有单元测试!我们所做的只是使用英语和 NBehave 的流畅界面来描述一个用户故事。正如您稍后将看到的,NBehave 的大部分内容都使用流畅界面,即每个函数都返回 this,从而允许进行长链调用。

为了完全清楚,我们编写上述故事定义是为了反映一个特定的需求。例如,需求规定用户存入资金,他们的银行账户相应增长,所以我们就写了那个。这个故事定义将出现在我们的单元测试输出中,从而更容易识别我们正在测试什么。

场景

我们创建了一个故事定义,那么让我们编写一个单元测试并看看它失败。为了测试它,我们必须提供一个称为场景的东西——一种可能发生的事件的描述。例如,您可能会从一个空的银行账户中提款,或者一个钱不够的账户。或者您可能会提取您实际拥有的金额。或者您可能会尝试提取负数金额。所有这些情况都是场景,并且为了获得 100% 的覆盖率,您需要测试每一种情况。然而,让我们从一些简单的开始

story.WithScenario("Money deposit")
  .Given("My bank account is empty", () => { account.Balance = 0; })
  .When("I deposit 100 units", () => account.Deposit(100))
  .Then("The account balance should be 100", () => account.Balance.ShouldEqual(100));

好了,这段代码可能暴露了 BDD 的 99%。本质上,我们定义了一个场景(一笔存款),然后在一个 C# 语句中描述了先决条件、测试本身和后置条件!我们在代码中的操作如下:

  • 我们从调用 WithScenario() 开始,它告诉系统这个场景是什么。您猜对了——信息会被输出到测试工具!您知道,它后面的所有内容也是如此。
  • 然后,我们使用 Given() 方法定义一个先决条件——即一个初始为空的银行账户。这个函数——就像接下来的两个一样——在这里使用了两个参数:一个 string,描述正在发生的事情,以及一个 Action 参数,它执行前一个参数所描述的操作
  • When() 用于描述我们要测试其后果的操作。
  • 最后,Then() 调用是我们检查是否发生了正确事情的地方。

您可能现在已经注意到测试中有相当多的 lambda 语法。这是因为每个子句中的参数都是 Action 类型,因此使用 lambda 语法比使用 delegate 关键字更简洁。

由于我们在这里做 BDD,让我们运行测试看看它是否失败。在我的系统上,我得到以下错误消息:

*** DebugTrace ***
Story: Deposit

Narrative:
    As a User
    I want The bank account balance to increase by the amount deposited
    So that I can deposit money

    Scenario 1: Money deposit
        Given My bank account is empty
        When I deposit 100 units
        Then The account balance should be 100 - FAILED


MbUnit.Core.Exceptions.NotEqualAssertionException:
  Equal assertion failed: [[0]]!=[[100]]

您可以看到测试运行器如何获取我们编写的规范并将其输出为可读的场景?它还显示了失败点,因此我们不必破译神秘的 MbUnit 消息(它们仍然可用,如果您愿意,请随意)。所以,既然我们有了一个失败的测试,让我们添加缺失的功能再试一次

public void Deposit(int amount)
{
  balance += amount;
}

很简单。现在,当我们运行测试时,它就成功了。TDD/BDD 真的就是这样!但是,让我们看看一个更复杂的场景——存入负数金额。我们应该得到一个异常,并且我们的银行账户余额应保持不变。这样的测试看起来是这样的:

story.WithScenario("Negative amount deposit")
  .Given("My bank account is empty", () => { account.Balance = 0; })
  .When("I try to deposit a negative amount", () => { })
  .Then("I get an exception",
        () => typeof(Exception).ShouldBeThrownBy(() => account.Deposit(-100)))
  .And("My bank account balance is unchanged",
       () => account.Balance.ShouldEqual(0));

这里有两点需要注意。首先,我们使用 ShouldBeThrownBy() 扩展方法来确保在调用 Deposit() 并传入负数金额时,确实会引发异常。此外,我们使用 And() 方法来确保,除了引发异常之外,账户余额保持不变。在我们代码上运行测试,我们得到以下输出:

*** DebugTrace ***
Story: Deposit

Narrative:
    As a User
    I want The bank account balance to increase by the amount deposited
    So that I can deposit money

    Scenario 1: Money deposit
        Given My bank account is empty
        When I deposit 100 units
        Then The account balance should be 100

    Scenario 2: Negative amount deposit
        Given My bank account is empty
        When I try to deposit a negative amount
        Then I get an exception - FAILED

输出基本符合预期,但我们不得不打破常规才能得到这里的输出。让我解释一下。首先,在期望实际存款的金额时,我未能提供一个 Action。相反,我使用了一个空的 lambda:

.When("I try to deposit a negative amount", () => { })

基本上,我不能在这个子句中尝试负数存款,因为我也打算捕获异常并检查其类型是否符合我的预期——这更适合 Then() 子句。另一方面,我不想让 When() 子句为空,因为如果我这样做,文本输出(即“I try…”第一个参数)将不会出现在输出中。这可能是一个 bug 或一个特性,但无论如何,我使用一个空的 lambda 来确保不会发生这种情况。

此时,我可以简单地完成我的 Deposit() 函数的粗略实现,然后再次运行测试。它看起来会是这样的:

public void Deposit(int amount)
{
  if (amount <= 0)
    throw new Exception();
  balance += amount;
}

这是一个您无法使用漂亮扩展方法的场景。假设您正在测试一个银行账户之间的转账。您想确保,如果转账是可能的(即,资金充足,金额为非负数等),则在转账时不会引发任何异常。因为没有 ShouldNotThrow() 扩展方法,所以我们最终会写下:

story.WithScenario("Valid transfer")
  .Given("I have 100 dollars", () => { account.Balance = 100; })
  .And("You have 100 dollars", () => { account2.Balance = 100; })
  .When("I give you 50 dollars",
        () => Assert.DoesNotThrow(() => Account.Transfer(account, account2, 50)))
  .Then("I have 50 dollars left", () => account.Balance.ShouldEqual(50))
  .And("You have 150 dollars", () => account2.Balance.ShouldEqual(150));

结论

到目前为止,您可能已经意识到,除了描述方式(流畅界面、英文描述)之外,NBehave 中没有什么新鲜事。事实上,有些人恼怒于 BDD 本质上是一种“冗长的 xUnit”,它做着同样的事情,但坚持要描述您所做的一切。但是,您从中获得的益处是可追溯性。例如,您的单元测试中的词语可以引用您的需求规范中的用例,从而更容易地表明您的产品符合特定规范。事实上,编写一个转换工具,将英文句子变成一个骨架 NBehave 场景是可行的。如果您写了这样的工具,请告诉我!

本文到此结束。感谢您的阅读。非常感谢您的评论和建议!

© . All rights reserved.