本文章是由機器翻譯。

BDD 入門

使用 SpecFlow 和 WatiN 進行行為驅動開發

Brandon Satrom

下載示例代碼

隨著自動化單元測試在軟體發展中變得越來越普遍,對各種“測試優先”方法的採用也呈現出相同的趨勢。這些實踐為開發團隊既帶來了難得的機遇,也帶來了獨特的挑戰,但所有這些機遇和挑戰都是為了説明從業人員建立“根據設計進行測試”的思路。

但是在“測試優先”時代的大多數時間,用於表達使用者行為的方法一直貫穿于使用系統語言(一種與使用者的語言不相關的語言)編寫的單元測試。隨著行為驅動開發 (BDD) 技術的問世,這種情況也隨之改變。利用 BDD 技術,您可使用業務語言來編寫自動化測試,同時還可保持與已實現系統的連接。

當然,現在我們創建了很多工具,可説明您在開發過程中實現 BDD。這些工具包括 Ruby 中的 Cucumber 以及適用于 Microsoft .NET Framework 的 SpecFlow 和 WatiN。SpecFlow 可説明您在 Visual Studio 中編寫和執行規范,而 WatiN 可用於驅動流覽器進行自動化的端到端系統測試。

本文中,我將簡要概述 BDD,然後解釋 BDD 週期如何通過用於驅動單元級別實現的功能級別測試來包括傳統的測試驅動開發 (TDD) 週期。在介紹“測試優先”方法的基本內容後,我將介紹 SpecFlow 和 WatiN,並向您演示如何將這些工具與 MSTest 結合使用來為您的專案實現 BDD 的示例。

自動化測試簡史

敏捷軟體發展過程中產生的最有價值的實踐之一就是測試優先的自動化開發模式,通常稱為“測試驅動開發”或“TDD”。TDD 的一條關鍵原則就是,測試創建不僅與設計和開發指南相關,同樣還與驗證和回歸相關。測試創建還涉及到使用測試來指定一組所需的功能,以及稍後通過測試來只編寫實現該功能所需的代碼。因此,實現任何新功能的第一步就是通過一個失敗測試來描述您的期望(參見圖 1)。

圖 1 測試驅動開發的週期

許多開發人員和團隊已通過 TDD 取得了巨大的成功,而其他人沒有使用 TDD,結果發現自己長期以來疲于應付進程管理工作,尤其是,隨著測試量開始增長,這些測試的靈活性卻開始降低,情況會更糟糕。儘管有些人覺得 TDD 很容易上手,而有些人卻不清楚如何開始使用 TDD,結果只能將它放在一邊,眼睜睜地看著最終期限的臨近和工作的大量積壓而束手無策。最後,許多對此感興趣的開發人員遇到了其組織內部對這項工作的重重阻力,要麼是因為“測試”這個詞暗示這項職能屬於另一個團隊,或是因為“TDD 產生了太多額外的代碼並減緩了專案進度”這個錯誤的觀念。

Steve Freeman 和 Nat Pryce 在他們的著作“Growing Object-Oriented Software, Guided by Tests”(Addison-Wesley Professional, 2009) 中指出,“傳統的”TDD 缺少真正的“測試優先”開發的某些優點:

“通過為應用程式中的類編寫單元測試來開始 TDD 過程是有風險的。這不僅比不進行任何測試要好得多,還可以發現那些我們所熟知但又無法避免的常見程式設計錯誤…但是專案僅進行單元測試卻會讓 TDD 過程的重要好處大打折扣。我們也看到了,有些具有高品質和經過嚴格單元測試的代碼的專案並非從任何位置都可以調用,或者這些專案無法與系統的其餘部分集成,因而必須重寫。”

2006 年,Dan North 在 Better Software 雜誌 (blog.dannorth.net/introducing-bdd) 中的一篇文章中提到了許多這類難題。在他的文章中,North 介紹了三年來在測試實踐方面所採用的一系列做法。儘管這些實踐就本身而言仍屬於 TDD 的範疇,但卻促使 North 採用一種更加側重分析的觀點來看待測試,並創造了術語“行為驅動開發”以概括這種轉換。

BDD 最常用的一個應用嘗試通過接受度測試或可執行規范來強化創建測試的重點和過程,從而擴展 TDD。每個規範將作為進入開發週期的一個入口點,它從使用者角度以分步驟的形式介紹系統的行為方式。完成編寫後,開發人員將使用規範及其現有的 TDD 過程來實現足量的生產代碼,從而得到一個通過測試的方案(參見圖 2)。

圖 2 行為驅動開發的週期

從何處開始設計

大多數人認為 BDD 是 TDD 的超集合,而不是它的替代品。兩者的重要區別是對初始設計和測試創建的側重點不同。與 TDD 側重于針對單元或物件的測試不同,我將以使用者的目標以及他們為了實現這些目標而採取的步驟為側重點。因為我不再從小型單元的測試著手,所以我也不太願意考慮具體用法或設計細節。我更多的是記錄能夠證明系統合適的可執行規范。我仍然編寫單元測試,但是 BDD 鼓勵採用由外而內的方法,該方法首先要提供所要實現的功能的完整說明。

讓我們看看此差異的示例。在傳統的 TDD 實踐中,您可以在圖 3 中編寫測試,以便演練 CustomersController 的 Create 方法。

圖 3 針對創建客戶的單元測試

[TestMethod]
public void PostCreateShouldSaveCustomerAndReturnDetailsView() {
  var customersController = new CustomersController();
  var customer = new Customer {
    Name = "Hugo Reyes",
    Email = "hreyes@dharmainitiative.com",
    Phone = "720-123-5477" 
  };
  var result = customersController.Create(customer) as ViewResult;
  Assert.IsNotNull(result);
  Assert.AreEqual("Details", result.ViewName);
  Assert.IsInstanceOfType(result.ViewData.Model, typeof(Customer));
  customer = result.ViewData.Model as Customer;
  Assert.IsNotNull(customer);
  Assert.IsTrue(customer.Id > 0);
}

這將是我使用 TDD 編寫的首批測試之一。 我通過設置所期望的 CustomersController 物件的行為方式為它設計一個公共 API。 對於 BDD,我仍然創建該測試,但並非從一開始就創建。 相反,我通過編寫更類似于圖 4 的測試來提高對功能級別功能的側重度。 然後,我將該方案用作針對實現所需代碼的各個單元的指南,以使此方案通過。

圖 4 功能級別規範

Feature: Create a new customer
  In order to improve customer service and visibility
  As a site administrator
  I want to be able to create, view and manage customer records
Scenario: Create a basic customer record
  Given I am logged into the site as an administrator
  When I click the "Create New Customer" link
  And I enter the following information
    | Field | Value                       |
    | Name  | Hugo Reyes                  |
    | Email | hreyes@dharmainitiative.com |
    | Phone | 720-123-5477                |
  And I click the "Create" button
  Then I should see the following details on the screen:
    | Value                       |
    | Hugo Reyes                  |
    | hreyes@dharmainitiative.com |
    | 720-123-5477                |

這是圖 2 中的外層迴圈,失敗的接受度測試。 在創建此測試並且測試失敗之後,我將按圖 2 中所述的內部 TDD 迴圈來實現我的功能中每個方案的每個步驟。 對於圖 3 中的 CustomersController,一旦到達功能中的合適步驟,我就會在實現使該步驟通過測試所需的控制器邏輯之前立即編寫此測試。

BDD 和自動化測試

從一開始,BDD 社區就已設法使用已成為單元測試中的標準一段時間的接受度測試來提供相同級別的自動化測試。 值得注意的一個示例就是 Cucumber (cukes.info),它是一個基於 Rub 的測試工具,強調創建以“特定于域的業務可讀語言”編寫的功能級別的接受度測試。

Cucumber 測試使用針對每個功能檔的 User Story 語法和針對每個方案的 Given、When、Then (GWT) 語法來編寫。 (有關 User Story 語法的詳細資訊,請參閱 c2.com/cgi/wiki?UserStory。)GWT 描述了方案當前的上下文 (Given)、作為測試的一部分所執行的操作 (When) 以及預期的可觀察結果 (Then)。 圖 4 中的功能是此類語法的一個示例。

在 Cucumber 中,系統會對使用者可讀的功能檔進行解析,並將每個方案步驟與 Ruby 代碼(演練相關系統的公共介面並確定該步驟是成功還是失敗)進行匹配。

近年來,創新促使自動化測試這類方案的使用擴展到 .NET Framework 體系。 開發人員現已具有允許通過 Cucumbe 所使用的相同結構的英語語法來編寫規範的工具,而隨後 Cucumber 又可將這些規範用作演練代碼的測試。 利用 SpecFlow (specflow.org)、Cuke4Nuke (github.com/richardlawrence/Cuke4Nuke) 等 BDD 測試工具,您可首先在過程中創建可執行規范,在擴建功能時利用這些規範,並在最後記錄那些與您的開發和測試進程直接關聯的功能。

SpecFlow 和 WatiN 入門

本文中,我將利用 SpecFlow 來測試一個模型-視圖-控制器 (MVC) 應用程式。 若要開始使用 SpecFlow,您首先要下載並安裝它。 安裝 SpecFlow 後,請使用單元測試專案創建一個新的 ASP.NET MVC 應用程式。 我更願意我的單元測試專案只包含單元測試(控制器測試、存儲庫測試等等),這樣,我就還可以為我的 SpecFlow 測試創建一個 AcceptanceTests 測試。

添加 AcceptanceTests 專案並添加對 TechTalk.SpecFlow 程式集的引用後,請使用 SpecFlow 在安裝時創建的“添加”|“新建專案”範本添加一個新功能,並將其命名為 CreateCustomer.feature。

請注意,該檔創建時的副檔名為 .feature;由於有了 SpecFlow 的集成工具,Visual Studio 將此檔識別為支援的檔。 您還可能注意到,您的功能檔具有一個相關的 .cs 代碼隱藏檔。 您每次保存 .feature 檔時,SpecFlow 會對檔進行解析,並將該檔中的文本轉換到一個測試裝置。 關聯的 .cs 檔中的代碼代表該測試裝置,即每次運行您的測試套件時實際執行的代碼。

預設情況下,SpecFlow 將 NUnit 用作其測試運行程式,但它也支援配置稍有更改的 MSTest。 您只需向測試專案中添加一個 app.config 檔並添加以下元素即可:

<configSections>
  <section name="specFlow"
    type="TechTalk.SpecFlow.Configuration.ConfigurationSectionHandler, TechTalk.SpecFlow"/>
</configSections>
<specFlow>
  <unitTestProvider name="MsTest" />
</specFlow>

首個接受度測試

當您創建某個新功能時,SpecFlow 將使用預設文本填充該檔,以解釋用於描述該功能的語法。 將 CreateCustomer.feature 檔中的預設文本替換為圖 4 中的文本。

每個功能檔分為兩個部分。 第一個部分是頂部的功能名稱和說明,此部分使用 User Story 語法來描述使用者的角色、使用者的目標以及使用者為了在系統中實現這個目標而必須能夠執行的操作的類型。 SpecFlow 需要此部分來自動生成測試,但是內容本身不能用於這些測試。

每個功能檔的第二部分為一個或多個方案。 每個方案用於在關聯的 .feature.cs 檔中生成一個測試方法(如圖 5 所示),且方案中的每個步驟會傳遞到 SpecFlow 測試運行程式,該運行程式會將步驟的一個基於 RegEx 的匹配項執行到名為“步驟定義”檔的 SpecFlow 檔中的一個條目。

圖 5 由 SpecFlow 生成的測試方法

public virtual void CreateABasicCustomerRecord() {
  TechTalk.SpecFlow.ScenarioInfo scenarioInfo = 
    new TechTalk.SpecFlow.ScenarioInfo(
    "Create a basic customer record", ((string[])(null)));
  this.ScenarioSetup(scenarioInfo);
  testRunner.Given(
    "I am logged into the site as an administrator");
  testRunner.When("I click the \"Create New Customer\" link");
  TechTalk.SpecFlow.Table table1 = 
    new TechTalk.SpecFlow.Table(new string[] {
    "Field", "Value"});
  table1.AddRow(new string[] {
    "Name", "Hugo Reyesv"});
  table1.AddRow(new string[] {
    "Email", "hreyes@dharmainitiative.com"});
  table1.AddRow(new string[] {
    "Phone", "720-123-5477"});
  testRunner.And("I enter the following information", 
    ((string)(null)), table1);
  testRunner.And("I click the \"Create\" button");
  TechTalk.SpecFlow.Table table2 = 
   new TechTalk.SpecFlow.Table(new string[] {
  "Value"});
  table2.AddRow(new string[] {
    "Hugo Reyes"});
  table2.AddRow(new string[] {
    "hreyes@dharmainitiative.com"});
  table2.AddRow(new string[] {
    "720-123-5477"});
  testRunner.Then("I should see the following details on screen:", 
    ((string)(null)), table2);
  testRunner.CollectScenarioErrors();
}

完成您首個功能的定義後,請在按住 Ctrl 的同時按 R 和 T,以運行您的 SpecFlow 測試。您的 CreateCustomer 測試將失敗(無結果),因為 SpecFlow 無法為您的測試中的首個步驟找到一個匹配的步驟定義(參見圖 6)。請留意實際 .feature 檔中的異常報告方式,該方式與隱藏代碼檔中的異常報告方式相反。

圖 6 SpecFlow 找不到步驟定義

因為您尚未創建步驟定義檔,所以發生此異常是正常的。在“異常”對話方塊上按一下“確定”,並在 Visual Studio“測試結果”視窗中查找 CreateABasicCustomerRecord 測試。如果未找到匹配的步驟,則 SpecFlow 將使用您的功能檔生成您的步驟定義檔中所需的代碼,您可複製並使用這些代碼,以開始實現這些步驟。

在您的 AcceptanceTests 專案中,使用 SpecFlow 步驟定義範本創建一個步驟定義檔,並將其命名為 CreateCustomer.cs。然後,將 SpecFlow 中的輸出複製到該類。您將注意到,每個方法都使用 SpecFlow 特性進行了修飾,該特性將方法指定為 Given、When 或 Then 步驟,並提供用於將方法與功能檔中某個步驟匹配的 RegEx。

集成 WatiN 以進行流覽器測試

使用 BDD 的部分目標是創建一個自動化測試套件,此套件將盡可能多地演練端到端系統功能。因為我要構建一個 ASP.NET MVC 應用程式,所以我可以使用許多工具,這些工具有助於編寫 Web 流覽器的腳本以與網站進行交互。

這類工具中的其中一個就是 WatiN,它是一個用於自動化 Web 流覽器測試的開放源庫。您可從 watin.sourceforge.net 中下載 WatiN,並將對 WatiN.Core 的引用添加到您的接受度測試專案,以方便使用。

與 WatiN 進行交互最主要的途徑就是通過流覽器物件(IE() 或 FireFox(),具體取決於您選擇的流覽器),這就為控制已安裝流覽器的實例提供了一個公共介面。因為您要在方案中通過多個步驟來演練流覽器,所以您需要一種可在步驟定義類中的步驟之間傳遞相同流覽器物件的方法。為了處理此問題,我通常創建一個 WebBrowser 靜態類作為 AcceptanceTests 專案的一部分,並利用該類來處理 WatiN IE 物件和 ScenarioContext(SpecFlow 將其用來存儲方案中各步驟之間的狀態):

public static class WebBrowser {
  public static IE Current {
    get {
      if (!ScenarioContext.Current.ContainsKey("browser"))
        ScenarioContext.Current["browser"] = new IE();
      return ScenarioContext.Current["browser"] as IE;
    }
  }
}

在 CreateCustomer.cs 中需要實現的第一個步驟是 Given 步驟,該步驟通過讓使用者以管理員身份登錄到網站來開始測試:

[Given(@"I am logged into the site as an administrator")]
public void GivenIAmLoggedIntoTheSiteAsAnAdministrator() {
  WebBrowser.Current.GoTo(http://localhost:24613/Account/LogOn);
  WebBrowser.Current.TextField(Find.ByName("UserName")).TypeText("admin");
  WebBrowser.Current.TextField(Find.ByName("Password")).TypeText("pass123");
  WebBrowser.Current.Button(Find.ByValue("Log On")).Click();
  Assert.IsTrue(WebBrowser.Current.Link(Find.ByText("Log Off")).Exists);
}

請記住,方案的 Given 部分是用來設置當前測試的上下文。利用 WatiN,您可擁有自己的測試驅動並讓它與流覽器交互,以實現此步驟。

在本步驟中,我使用 WatiN 打開 Internet Explorer,導航到網站的“登錄”頁面,填寫“使用者名”和“密碼”文字方塊,然後按一下螢幕上的“登錄”按鈕。當我再次運行測試時,將自動打開一個 Internet Explorer 視窗,當 WatiN 與網站交互(按一下連結並輸入文本)時,我可以觀察工作中的 WatiN(參見圖 7)。

圖 7 帶有 WatiN 的 Autopilot 上的流覽器

現在將通過 Given 步驟,離實現功能又更近一步了。現在,SpecFlow 將會在第一個 When 步驟上失敗,因為該步驟尚未實現。您可使用以下代碼實現該步驟:

[When("I click the \" (.*)\" link")]
public void WhenIClickALinkNamed(string linkName) {
  var link = WebBrowser.Link(Find.ByText(linkName));
  if (!link.Exists)
    Assert.Fail(string.Format(
      "Could not find {0} link on the page", linkName));
  link.Click();
}

現在,當我再次運行這些測試時,它們又因為 WatiN 無法在頁面上找到帶有文本“創建新客戶”的連結而失敗。 只需向主頁添加一個帶有該文本的連結,下個步驟就會通過。

是否理解了模式? SpecFlow 鼓勵使用相同的 Red-Green-Refactor 方法,此方法是“測試優先”開發方法的主流方法。 功能中每個步驟的間隔的作用類似于實現的虛擬綁定程式,鼓勵您僅實現通過步驟所必需的功能。

但是 BDD 過程內部的 TDD 又是怎樣的呢? 現階段我只是在頁面級別工作,並且我尚未實現實際創建客戶記錄的功能。 為了簡便起見,現在讓我們實現剩下的步驟(參見圖 8)。

圖 8 步驟定義中剩下的步驟

[When(@"I enter the following information")]
public void WhenIEnterTheFollowingInformation(Table table) {
  foreach (var tableRow in table.Rows) {
    var field = WebBrowser.TextField(
      Find.ByName(tableRow["Field"]));
    if (!field.Exists)
      Assert.Fail(string.Format(
        "Could not find {0} field on the page", field));
    field.TypeText(tableRow["Value"]);
  }
}
[When("I click the \"(.*)\" button")]
public void WhenIClickAButtonWithValue(string buttonValue) {
  var button = WebBrowser.Button(Find.ByValue(buttonValue));
  if (!button.Exists)
    Assert.Fail(string.Format(
      "Could not find {0} button on the page", buttonValue));
  button.Click();
}
[Then(@"I should see the following details on the screen:")]
public void ThenIShouldSeeTheFollowingDetailsOnTheScreen(
  Table table) {
  foreach (var tableRow in table.Rows) {
    var value = tableRow["Value"];
    Assert.IsTrue(WebBrowser.ContainsText(value),
      string.Format(
        "Could not find text {0} on the page", value));
  }
}

我重新運行了測試,現在這些測試因為我沒有用來輸入客戶資訊的頁面而失敗。 若要允許創建客戶,則需要一個“創建客戶視圖”頁面。 若要在 ASP.NET MVC 中提供這樣一個視圖,則需要用來提供此視圖的 CustomersController。 現在我需要新的代碼,這意味著我的步驟要從 BDD 的外部迴圈進入到 TDD 的內部迴圈,如圖 2 所示。

第一步是創建一個失敗的單元測試。

將單元測試寫入實現步驟

在 UnitTest 專案中創建 CustomerControllersTests 測試類之後,您需要創建一個測試方法,該方法用於演練要在 CustomersController 中公開的功能。 具體地說,您要創建 Controller 的一個新實例,調用其 Create 方法,並確保您反過來可接收到合適的“視圖”和“模型”:

[TestMethod]
public void GetCreateShouldReturnCustomerView() {
  var customersController = new CustomersController();
  var result = customersController.Create() as ViewResult;
  Assert.AreEqual("Create", result.ViewName);
  Assert.IsInstanceOfType(
    result.ViewData.Model, typeof(Customer));
}

此代碼尚未編譯,因為您尚未創建 CustomersController 或其 Create 方法。 創建該控制器和一個空白的 Create 方法後,將立即編譯代碼,且測試將失敗,這是必需的下一個步驟。 而如果您完成了 Create 方法,則測試將立即通過:

public ActionResult Create() {
  return View("Create", new Customer());
}public ActionResult Create() {
  return View("Create", new Customer());
}

如果您重新運行 SpecFlow 測試,則會更接近于完成,但功能仍然不能通過。這次,測試將因為您不具有 Create.aspx 視圖頁而失敗。如果您按照功能指示,將其隨合適的欄位一起添加,則您向完整的功能又邁進了一步。

用於實現此創建功能的由外而內的過程如圖 9 所示。

圖 9 方案到單元測試過程

這些相同的步驟通常會在此過程中重複出現,迴圈訪問這些步驟的速度隨著時間的推移將大大加快,尤其是當您在 AcceptanceTests 專案中實現説明程式步驟(按一下連結和按鈕,填寫表單等等)和著手測試每個方案中的關鍵功能時。

現在功能將從有效的創建視圖中填寫相應的表單欄位,且將嘗試提交該表單。您現在可以猜到下麵將發生什麼事:測試將因為您尚不具有保存客戶記錄所需的邏輯而失敗。

請按照與前面相同的過程,使用如前面圖 3 中所示的單元測試代碼來創建測試。在添加接受客戶物件以允許編譯此測試的空白 Create 方法後,您將看到測試失敗,隨後請按以下方式完成 Create 方法:

[AcceptVerbs(HttpVerbs.Post)]
public ActionResult Create(Customer customer) {
  _repository.Create(customer);
  return View("Details", customer);
}

我的 Controller 只是一個控制器,客戶記錄的實際創建屬於一個瞭解資料存儲機制的存儲物件。為了簡便起見,本文省略了該實現;但要注意,在實際情況中,當需要用來保存客戶的存儲庫時應啟動另一個單元測試的子迴圈。當您需要訪問任意協作物件,而該物件不存在或未提供您需要的功能時,則應該遵循您對 Feature 和 Controller 遵循的相同單元測試迴圈。

實現 Create 方法並擁有工作存儲庫之後,您將需要創建“詳細資訊視圖”,此視圖保留新客戶的記錄並在頁面上顯示這些記錄。然後您可再次運行 SpecFlow。最後,在多次 TDD 迴圈和子迴圈後,您現在具有了通過測試的功能,它證明您系統中存在的一些端到端的功能合適。

祝賀您!您現已通過接受度測試和一組完整的單元測試(用於確保系統進行擴展以添加新功能時新功能可以繼續工作)實現了一組端到端功能。

關於重構的說明

在 UnitTests 專案中創建單元級別測試時,希望您不斷對每個測試創建進行重構。當您在鏈中從通過單元測試移回到通過接受度測試時,應遵循相同的過程,關注重構的機會,並重定義每個功能以及隨後的所有功能的實現。

還要密切關注重構您的 AcceptanceTests 專案中的代碼的機會。您將發現某些功能中經常出現某些重複的步驟,尤其是您的 Given 步驟。利用 SpecFlow,您可輕易將這些步驟移動到按功能分類的單獨的步驟定義檔中,例如,LogInSteps.cs。這就讓主要步驟定義檔變得簡單明瞭並針對您指定的唯一方案。

BDD 側重于設計和開發。通過將您的側重點從物件提升到功能,您自己和您的團隊就可以從系統使用者的角度出發進行設計。由於功能設計演變成為單元設計,因此要確保在編寫測試時將您的功能考慮在內,還要確保按照不連續的步驟或任務調整測試。

與任何其他實踐或規則類似,BDD 與您的工作流結合還需一些時間。本人建議您使用任何可用的工具來親自體驗相關操作,並觀察該工具的運行情況。當您以這種方式進行開發時,請注意 BDD 鼓勵您提出的問題。不斷尋找可改善您的實踐和過程的方法,並與他人協作以進行改善。我希望,無論您使用什麼工具集,對 BDD 的研究都能夠增加自己的軟體發展實踐的價值和關注度。

Brandon Satrom* 是 Microsoft 在德克薩斯州奧斯丁市的開發推廣人員。他的博客位址為 userinexperience.com@BrandonSatrom,還可以通過 Twitter 位址 與他取得聯繫。*

感謝以下技術專家對本文的審閱:Paul Rayner 和 Clark Sell