使用 SpecFlow 讓我們從實際需求情境起步,自然而然將開發與測試融為一體地實現 TDD 開發精神。
前言
前些日子有幸參加公司內訓,講者就是在TDD方面相當有研究的91哥,上完三天課讓我對於TDD有更完整深層的認識及體會,當然這些認識體會並不代表實務上就可以順利導入,其中的心法及如何讓自己寫出具有可測試性的程式,以及面對 Legacy Code 該如何調整使其具有可測試性,這些都是需要在真實戰場中不斷思考及衝撞下才會有的經驗值,而筆者也正努力累積相關經驗值中;說了這麼多,其實這篇不講 TDD 而是把重點放在初嚐 BDD 開發模式,體驗一下如何使用 SpecFlow 讓我們從實際需求情境起步,自然而然將開發與測試融為一體地實現 TDD 開發精神。
安裝
首先點選擴充功能和更新,搜尋 SpecFlow 就可以找到對應版本的擴充功能,點選下載安裝。
在測試專案中從 Nuget 下載 SpecFlow 套件至專案中。
由於 SpecFlow 預設 Unit Test Provider 是 NUnit Test,本文使用 MS Test 就需要開啟 app.config 進行調整。
建立需求功能與場景
我們可以在測試專案中建立 Features 資料夾,接著加入新項目選擇 SpecFlow Feature File 後,新增一個副檔名為 *.feature (與對應 *.cs 檔) 的需求功能檔案。
首先會以 User Story 方式建立 Feature 的作用,並於下方清楚描述場景(Scenario)及需求規格(input & output);其中 Scenario 中每句紫色文字都表示一個步驟,而紫色表示尚未對應到步驟定義(Step Definitions)代碼,因此會在下個步驟中自動產出。
- Feature: 功能標題與描述
- Tag: 可訂於 Feature 或 Scenario 層級中,並且有繼承效果;可以透過 Tag 操作所相關項目
- 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 識別變數,因此作了以下的調整:
- 將 CountWorkingDay.feature 中的 2016/10/07 的雙引號移除 (不再以字串方式輸入)
- 調整 Given 中的 regex 字串,移除輸入參數的雙引號
- 最後將輸入型態改為 DateTime 後,剩下就交給 Specflow 來幫忙自動將輸入參數自動轉型了
調整 Step Definition 的 Binding Scope
特別注意由於 Step Definition 的 Binding 是全域的,也就是可以大家共用相同 Patten 的 Step Definition,因此也會帶來不同 Feature 間相同 Step Patten 的衝突,所以如果要限制 Step Definitions 的 Binding 範圍,可以透過 Scope 標籤限制哪個 Feature, Scenario, Tag 範圍,這樣就不會互相干擾;而 Scope 標籤可以設定在類別或方法上,表示個別限制類別或方法的 Binding 範圍。
實作步驟內容及執行測試
這個部分就如同 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 開發模式,調整開發的思維,強迫自己寫出可測試性高且耦合性低的程式,讓完成的功能具有測試保護並符合用戶實際需求,這種一氣呵成的感覺真不賴,讓我們一起共勉之。
參考
Step argument transformation in Specflow
希望此篇文章可以幫助到需要的人
若內容有誤或有其他建議請不吝留言給筆者喔 !