本文章是由機器翻譯。

資料點

使用 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.” That’s because each of the methods in the Steps file are all still calling 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) and 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 關注他。