單元測試透過Fake物件破除依賴時,有兩個小細節須注意。
- 要記得定義與Fake物件互動的方法的回傳值。
- 定義的回傳值須注意實例化的方式是否符合測試情境需求。
前言
之前在寫測試Code的時候,有遇到兩個案例,明明Production Code都正常執行,但測試案例卻始終紅燈的問題。最後有發現是因為觀念不清楚,而造成很多時間的浪費,為了怕以後自己又忘記,趕緊把它紀錄下來。
只有套好的招才算數
在寫單元測試時,第一個遇到的挑戰就是要如何破除依賴,破除依賴的方式可以透過撰寫手工類別,注入測試的目標物件來達到。也可以透過第三方套件NSubstitute達到同樣的效果。簡單來說就是Fake依賴的物件,並給他一個預期回傳的結果,而不去真正的執行商業邏輯。
如果是選擇使用NSubstitute套件,我們可透過NSubstitute提供的API來產生Fake物件,因為是依賴介面,所以只要是介面有定義的方法,都可以被呼叫,但Fake物件不會真的去跑商業邏輯。
這時候第一個疑問一定是,如果Fake物件不會去跑商業邏輯,那如何得到執行結果?假如被呼叫的方法沒有回傳值,那基本上不會有甚麼問題,不需要做甚麼額外的事情;但如果有回傳值的話,則必須由測試人員根據模擬的情境來套招。講白話就是,我們要跟Fake物件約定好,當我呼叫你的AAA方法,傳入XXX參數,請回傳YYY給我。
套好招之後,接下來跑測試,Production Code照理來說會按照預期的邏輯,呼叫Fake物件的AAA方法,並傳入XXX參數,就會收到YYY的回傳值。假如Production Code沒有依照你的想法執行,那就要確認Production Code是否有符合你情境描述的需求。
所以,這裡第一個觀念,就是要能識別出預期的商業邏輯中,有跟Fake物件互動到哪些方法,如果方法有回傳值,一定都要先跟Fake物件做約定。否則,呼叫到沒跟Fake物件約定好的方法,那Fake物件會一律回你一個null。
Fack物件套招時須注意如何實例化回傳的對象
一般來說,Production Code在跟Fake物件互動時,要注意互動的次數,是否跟你套招時,實體化物件的次數相等,假如不相等,就要檢查實體化物件的方式是否符合你的需求,否則有可能就會出現問題,這樣也許太攏統,直接來看範例就能了解。
假設我們要測試的功能情境是"汽車生產後,幫汽車掛上車牌",測試目標對象是CarService,依賴ICarFactory,物件的依賴關係和定義如下圖所示。
我們的測試情境如下(透過Specflow撰寫),由CarService的ProduceCar方法,透過ICarFactory生產汽車,剛生產的車子只會包含品牌屬性,不含車牌。接著假設我們擁有三組車牌資訊,然後呼叫CarService的HangPlate方法掛上車牌,最後驗證我們的車牌是不是都有掛到剛生產的汽車上。
Scenario: 替剛出產的汽車掛上車牌
Given 我預期ICarFactory生產的汽車屬性如下(ICarFactory是stub物件)
| Brand | Plate |
| Luxgen | |
And 我有車牌資料"ABC-0010","ABC-0011","ABC-0012"
When 我生產3輛車放入倉庫 (CarService.ProduceCar)
And 替倉庫的車子掛上車牌 (CarService.HangPlate)
Then 則倉庫內車子屬性應為
| Brand | Plate |
| Luxgen | ABC-0010 |
| Luxgen | ABC-0011 |
| Luxgen | ABC-0012 |
這裡只秀出第一行Give的邏輯。
[Given(@"我預期CarFactory生產的汽車屬性如下")]
public void Given我預期CarFactory生產的汽車屬性如下(Table table)
{
var car = table.CreateInstance<CarViewModel>();
this.carFactoryService.ProduceCar().Returns(car );
}
這段邏輯很簡單,看起來沒有問題,但跑出來的測試結果是錯誤的。
我們可以看到車牌全部都變成ABC-0012,可是真的跑Production Code,卻又沒有問題(假設Production Code真的是正確的),但這個測試案例就是通不過!
錯誤的理由是,因為我們跟Fake物件套招時只有實例化汽車1次。但生產3輛車,其實Fake物件被叫用了3次,所以造成Fake物件都是回傳同一輛車。所以,在倉庫內雖然看起來有3輛車,但這3輛車的參考都指向同一個記憶體位置(因為都是透過Fake物件ICarFactory產生的),所以依序在幫汽車掛車牌的時候,就會一直覆蓋原來的車牌,造成3輛車的車牌都是最後一個車牌,ABC-0012。
那樣怎麼避免這個問題呢?我們的需求很單純,只是要不同的車子指向不同的物件而已,所以,透過呼叫另外一個多載方法,傳一個Func進去,讓Fake物件每次套招的時候都產生一個新的物件,實作方式如下。
[Given(@"我預期CarFactory生產的汽車屬性如下")]
public void Given我預期CarFactory生產的汽車屬性如下(Table table)
{
//用全域私有變數接住table 下面會需要用到這個資訊
this.NewCarTable = table;
//傳入Func 由Func來實例化CarViewModel 就能避免物件都指向同一個記憶體位置
this.carFactoryService.ProduceCar().Returns(this.ProduceCar);
}
private CarViewModel ProduceCar(CallInfo arg)
{
//實例化汽車物件
return this.NewCarTable.CreateInstance<CarViewModel>();
}
這樣就大功告成了,測試案例也會出現綠燈。
總結
透過TDD的方式來開發系統可以享受到程式碼被保護的好處,以及商業流程可以用口語化的方式記錄下來,不用在從冷冰冰的程式碼中兜出商業邏輯。
但要寫出好的測試Code,基本的套路技巧,如破除依賴,如何具體描述測試案例都需要經驗來學習,尤其是破除依賴這一塊,有很多小陷阱要避免,也有很多小技巧可以運用,一開始也許會有點痛苦,但熟悉之後,就會慢慢體驗到TDD的浪漫。