[BDD] 使用 SpecFlow 從需求起步進行開發

使用 SpecFlow 讓我們從實際需求情境起步,自然而然將開發與測試融為一體地實現 TDD 開發精神。

前言

前些日子有幸參加公司內訓,講者就是在TDD方面相當有研究的91哥,上完三天課讓我對於TDD有更完整深層的認識及體會,當然這些認識體會並不代表實務上就可以順利導入,其中的心法及如何讓自己寫出具有可測試性的程式,以及面對 Legacy Code 該如何調整使其具有可測試性,這些都是需要在真實戰場中不斷思考及衝撞下才會有的經驗值,而筆者也正努力累積相關經驗值中;說了這麼多,其實這篇不講 TDD 而是把重點放在初嚐 BDD 開發模式,體驗一下如何使用 SpecFlow 讓我們從實際需求情境起步,自然而然將開發與測試融為一體地實現 TDD 開發精神。

 

安裝

首先點選擴充功能和更新,搜尋 SpecFlow 就可以找到對應版本的擴充功能,點選下載安裝。

在測試專案中從 Nuget 下載 SpecFlow 套件至專案中。

由於 SpecFlow 預設 Unit Test Provider 是 NUnit Test,本文使用 MS Test 就需要開啟 app.config 進行調整。

<unitTestProvider name="MsTest"></unitTestProvider>

 

建立需求功能與場景

我們可以在測試專案中建立 Features 資料夾,接著加入新項目選擇 SpecFlow Feature File 後,新增一個副檔名為 *.feature (與對應 *.cs 檔) 的需求功能檔案。

首先會以 User Story 方式建立 Feature 的作用,並於下方清楚描述場景(Scenario)及需求規格(input & output);其中 Scenario 中每句紫色文字都表示一個步驟,而紫色表示尚未對應到步驟定義(Step Definitions)代碼,因此會在下個步驟中自動產出。

  1. Feature: 功能標題與描述 
  2. Tag: 可訂於 Feature 或 Scenario 層級中,並且有繼承效果;可以透過 Tag 操作所相關項目
  3. Scenario: 測試情境,包含許多步驟 (Step)

簡單看一下這個 Scenario 包含了輸入條件、執行方式及預期結果,此情境表示當我輸入特定起始日期、結束日期及國定假日清單後(Given),呼叫 WorkingDayCounter 來計算工作日(When),預期會得到 5 天工作日的計算結果(Then),就如同測試的 3A (Arrange, Act and Assert)原則,透過這些白話文讓測試情境更加清晰。

 

傳入條件可以依照需求調整型態,以下簡單介紹兩種常用的輸入方式:

傳入單筆參數

要傳入的資料就直接寫在步驟文字中,如果是數字會自動被識別作為此步驟的傳入參數;另外如果是要傳入文字就使用雙引 "some string" 標示,也會被自動識別。但是若是其他輸入資料像是 Date 格式時,就無法被正確識別,但沒關係就先用字串方式填上就對了,我們會在下個步驟進行微調即可。

傳入多筆參數 (資料表)

如果有多筆資料可以使用表格方式呈現,使用 | 分隔符號分開各個欄位,第一列會輸入欄位名稱,此種型態的輸入參數會以 Table 物件傳遞,而後續可以透過 Table.CreateSet<MyClass>() 方式轉為 IEnumerable 泛型類型,讓我們可以方便操作強型別物件進行後續行為。

 

自動產出步驟定義 (Step Definitions)

這部分會透過先前定義的情境步驟轉化為實際執行的步驟,簡單來說就是點選 Generate Step Definitions 產出各執行步驟的空殼,讓我們自行去實作各步驟需要測試目標物件所執行的行為,藉此驗證測試目標的行為結果是否與需求一致。

產出後查看一下 Feature 檔,原本沒有定義的部分(紫色)都變白色文字(有定義),而此時最需要關注的是輸入參數是否如預期被剖析出來,因為SepcFlow 會自動判別數字、字串或資料表作為輸入參數(灰色文字部分),但大家都知道自動產生的東西一定沒有辦法滿足所有情況,所以若是無法滿足個人需求的話,就需要自行調整標籤中的 Regular Expression 文字,來正確剖析出各個 Step 描述中標示為輸入參數的位置。

調整 DateTime 格式

由於剛剛為了簡單讓工具幫我們產出 Step Definition 代碼,因此把日期先用字串標示,但我們真正需要的是 DateTime 格式的輸入參數阿!別擔心我們可以透過 Specflow 針對參數提供的自動轉型功能來進行微調,而調整的方式相當直覺,就是使用 Regular Expression 來定義文字 Petten 識別變數,因此作了以下的調整:

  1. 將 CountWorkingDay.feature 中的 2016/10/07 的雙引號移除 (不再以字串方式輸入)
  2. 調整 Given 中的 regex 字串,移除輸入參數的雙引號
  3. 最後將輸入型態改為 DateTime 後,剩下就交給 Specflow 來幫忙自動將輸入參數自動轉型了

調整 Step Definition 的 Binding Scope

特別注意由於 Step Definition 的 Binding 是全域的,也就是可以大家共用相同 Patten 的 Step Definition,因此也會帶來不同 Feature 間相同 Step Patten 的衝突,所以如果要限制 Step Definitions 的 Binding 範圍,可以透過 Scope 標籤限制哪個 Feature, Scenario, Tag 範圍,這樣就不會互相干擾;而 Scope 標籤可以設定在類別或方法上,表示個別限制類別或方法的 Binding 範圍。

以下就是明確定義這個 Step Definition 類別的 Binding 範圍,只針對名稱為 Count Working Day 這個 Feature 而已 

 

實作步驟內容及執行測試

這個部分就如同 TDD 的操演方式,在實作各步驟的同時也就像是在寫測試 3A,因此不需急著實作出具體的邏輯代碼,而是先依物件導向設計思路寫出所需類別、屬性及方法後,接著善用智能標籤快速鍵 (Ctrl + .) 自動產出對應類別屬性方法代碼,勾勒完程式空殼後先來執行測試得到個紅燈(因尚未實作任何邏輯),接著就可使盡洪荒之力實作出恰好符合我們所需情境下的程式邏輯,此時只要確認執行測試後得到綠燈,這一回合就結束了;當然因為有測試的保護,我們也可以很放心地繼續精進(重構)代碼囉!

善用工具自動產出需要的類別及其屬性與方法代碼

完成後的 Step Definition 如下

namespace BDD.ConsoleAppTest.Features
{
    [Binding]
    [Scope(Feature = "Count Working Day")]
    public class CountWorkingDaySteps
    {
        private WorkingDayCounter target = new WorkingDayCounter();

        [Given(@"輸入起始日期 (.*)")]
        public void Given輸入起始日期(DateTime startDate)
        {
            target.StartDate = startDate;
        }
        
        [Given(@"輸入結束日期 (.*)")]
        public void Given輸入結束日期(DateTime endDate)
        {
            target.EndDate = endDate;
        }
        
        [Given(@"國定假日")]
        public void Given國定假日(Table table)
        {
            var holidays = table.CreateSet<Holiday>();
            target.Holidays = holidays;
        }
        
        [When(@"呼叫WorkingDayCounter計算工作日")]
        public void When呼叫WorkingDayCounter計算工作日()
        {
            int workingDay = target.CountWorkingDay();
            ScenarioContext.Current.Set(workingDay, "workingDay");
        }
        
        [Then(@"工作日計算結果應為 (.*)")]
        public void Then工作日計算結果應為(int expected)
        {
            var actual = ScenarioContext.Current.Get<int>("workingDay");
            Assert.AreEqual(expected, actual);
        }
    }
}

其中由於 Given國定假日 方法是傳入 Table 型態,需要將他轉形成我們所需之物件,而 TechTalk.SpecFlow.Assist 提供 Table 的擴充方法,方便我們執行轉型任務如下。

主程式空殼如下

namespace BDD.ConsoleApp
{
    public class WorkingDayCounter
    {
        public DateTime StartDate { get; set; }
        public DateTime EndDate { get; set; }
        public IEnumerable<Holiday> Holidays { get; set; }

        public int CountWorkingDay()
        {
            throw new NotImplementedException();
        }
    }
}

執行測試取得紅燈

完成符合我們所需要情境下的程式邏輯後

namespace BDD.ConsoleApp
{
    public class WorkingDayCounter
    {
        public DateTime StartDate { get; set; }
        public DateTime EndDate { get; set; }
        public IEnumerable<Holiday> Holidays { get; set; }

        public int CountWorkingDay()
        {
            int days = 0;
            while (StartDate <= EndDate)
            {
                if (StartDate.DayOfWeek != DayOfWeek.Saturday
                    && StartDate.DayOfWeek != DayOfWeek.Sunday
                    && !Holidays.Any(h => h.Date == StartDate))
                {
                    ++days;
                }
                StartDate = StartDate.AddDays(1);
            }
            return days;
        }
    }
}

執行測試取得綠燈(收工)

 

建立新的測試情境

就是因為需求存在著許多不同的情境條件,才會容易造成開發人員有所疏漏,因此如果需要加入新的測試情境,我們只需要加入一段新的 Scenario 即可。

Feature: Count Working Day
	In order to 減少人工運算產生的錯誤
	As a 專案經理
	I want to 自動計算一段時間的工作日總數


@ManagerTag
Scenario: Count with holiday
	Given 輸入起始日期 2016/10/07
	And 輸入結束日期 2016/10/14
	And 國定假日
	| HolidayName | Date       |
	| 國慶日       | 2016/10/10 |
	When 呼叫WorkingDayCounter計算工作日
	Then 工作日計算結果應為 5


Scenario: Count without holiday
	Given 輸入起始日期 2016/10/14
	And 輸入結束日期 2016/10/17
	And 國定假日
	| HolidayName | Date       |
	| 國慶日       | 2016/10/10 |
	When 呼叫WorkingDayCounter計算工作日
	Then 工作日計算結果應為 2


Scenario: Count without holiday and weekend
	Given 輸入起始日期 2016/10/18
	And 輸入結束日期 2016/10/21
	And 國定假日
	| HolidayName | Date       |
	| 國慶日       | 2016/10/10 |
	When 呼叫WorkingDayCounter計算工作日
	Then 工作日計算結果應為 4

編譯後就會出現對應的測試案例於測試總管中

直接就可以執行測試看看我們的程式邏輯有沒有滿足這個測試情境

 

使用 Background 集中共同執行步驟

大家有沒有發現有重複的步驟資料不斷的出現在各個 Scenario 中,感覺非常冗長。

我們可以把這些步驟透過 Background 集中起來,讓測試每個情境時都會先執行這個共同步驟。

Feature: Count Working Day
	In order to 減少人工運算產生的錯誤
	As a 專案經理
	I want to 自動計算一段時間的工作日總數

Background: 準備共同使用的資料
	Given 國定假日
	| HolidayName   | Date       |
	| 國慶日         | 2016/10/10 | 

@ManagerTag
Scenario: Count with holiday
	Given 輸入起始日期 2016/10/07
	And 輸入結束日期 2016/10/14
	When 呼叫WorkingDayCounter計算工作日
	Then 工作日計算結果應為 5


Scenario: Count without holiday
	Given 輸入起始日期 2016/10/14
	And 輸入結束日期 2016/10/17
	When 呼叫WorkingDayCounter計算工作日
	Then 工作日計算結果應為 2


Scenario: Count without holiday and weekend
	Given 輸入起始日期 2016/10/18
	And 輸入結束日期 2016/10/21
	When 呼叫WorkingDayCounter計算工作日
	Then 工作日計算結果應為 4

測試一下功效果然相同,測試依舊綠燈

 

使用 Scenario Outline 重複驅動測試

最後大家應該會發現,所有情境的Step組成都是一樣的,差別只有在輸入值的不同,這樣繼續複製下去相當冗長。

可以透過 Scenario Outline 來消除所有重複的測試情境;我們可以透過一個測試資料集合,定義不同輸入輸出值後,就可以重複執行不同邊界值下的測試情境,這樣是不是優雅多了!

編譯後測試案例出現在測試總管中

執行看看,達到一樣的效果,順利通過測試

 

使用 BeforeScenario 及 AfterScenario 處理測試前後事件

當在測試前後需要執行某些動作時,可以透過 BeforeScenario 及 AfterScenario 標籤對自訂 Method 進行定義;如下例就是在執行 Scenario 測試前要建立測試目標實體。

 

後記

本文實際演練如何透過 SpecFlow 實現 BDD 開發模式,調整開發的思維,強迫自己寫出可測試性高且耦合性低的程式,讓完成的功能具有測試保護並符合用戶實際需求,這種一氣呵成的感覺真不賴,讓我們一起共勉之。

 

參考

SpecFlow Hello World

Step argument transformation in Specflow


希望此篇文章可以幫助到需要的人

若內容有誤或有其他建議請不吝留言給筆者喔 !