最近看了點單元測試的東西,發覺 MSDN 網站上的這篇文章蠻容易入手:Isolating Code under Test with Microsoft Fakes,便做了點整理。這篇筆記裡面的範例也有一些取自該文,只是寫的比較囉嗦一些。
簡介
Microsoft Fakes 是一套用來協助單元測試的框架,其目的在於隔離受測的部分程式碼,以便在某個單元測試失敗時,你能夠確定問題出在測試標的本身,而不是其他地方(就不用疑神疑鬼:到底是哪個類別出錯了)。
你可以利用 Fakes 來產生特定物件或方法的「替身」來把一些與測試目的無關的程式碼替換掉。Fakes 的替身有兩種:
如果沒有實際用過,恐怕不容易體會 Fakes 的用途,以及 stub 和 shim 的差別。底下分別就 stub 和 shim 提供實作練習的範例。
Note:這裡我用「替身」一詞來泛指 Microsoft Fakes 產生的假物件,係為了方便說明,也許不是那麼精確。
工具:Visual Studio 2012 Ultimate。
Stub 實作練習
步驟如下:
設想的情境是這樣:FruitStore 類別要提供一個 GetPrice 方法,此方法須傳入一個 IFruit 型別的參數,並傳回該水果的價格。
IFruit 介面的定義如下:
然後是 FruitStore 類別:
還缺什麼呢?實作 IFruit 介面的具象類別。所以,接著再加入一個新的類別:Apple。程式碼如下:
注意這時候我們還沒有提供 Apple.GetPrice() 方法的實作,目前只是簡單丟出一個 exception。因為我們打算再實作各類水果類別之前,先測試水果商店,也就是 FruitStore 類別的邏輯。
所以,接著在 FakesDemo solution 中加入一個新的 Unit Test 專案,取名為 MyLibTest。然後加入專案組件參考:MyLib(因為我們要對這個組件寫單元測試)。
在測試專案中加入 MyLib 組件參考之後,接著加入一個 Unit Test 類別:FruitStoreTest。然後為此類別加入一個測試方法,程式碼如下:
執行測試看看(主選單 \ TEST \ Run \ All Tests)。此時應該會看到測試結果是失敗的:
這是因為 Apple 類別的 GetPrice() 方法尚未提供實作,僅單純拋出例外的緣故。但我們目前還不想去處理 Apple 類別,只想先測試 FruitStore 而已。這個時候,Fakes 就派上用場了。
產生替身
現在我們要對 MyLib 組件產生一些替身程式碼,做法是在 Solution Explorer 中展開 MyLibTest 專案的 Reference 節點,然後在 MyLib 項目上點右鍵,選 Add Fakes Assembly。參考下圖:
接著你會看到這個專案裡面多了一些新東西:
這些就是我們目前所需要的了。接著修改剛才的測試程式碼,改成這樣:
注意這裡的變化:
再跑一次測試,這次就能通過了,因為我們已經用假蘋果取代真蘋果。換言之,受測標的 FruitStore 已經和 Apple 類別隔離開了。
Shim 實作練習
如前面提過的,shim 適合用於欲隔離之類別沒有實作特定介面,或者根本沒有原始碼的情況。MSDN 那篇文章是以 .NET BCL 中的 DateTime 來當作範例。簡單起見,我就依樣畫葫蘆,並做點補充。
跟剛才的 stub 實作練習一樣,先看看沒有使用替身物件的情況。
沿用先前的實作範例,在 MyLib 專案中加入一個類別:MyDateTime。程式碼很簡單:
GetCurrentMonth 方法會傳回目前的月份。
然後加入測試程式碼:在 MyLibTest 專案中加入一個 Unit Test 類別:MyDateTimeTest。程式碼如下:
OK! 我是在十月份的時候寫這個單元測試,所以預期 GetCurrentMonth 方法一定會傳回 10。可是,等到十一月份以後再來跑這個單元測試又不會過了,還得修改這段測試程式碼,這未免太麻煩了。可是,我們沒有 DateTime 的原始碼,而且它也沒有實作特定介面,無法套用剛才的 stub 伎倆。這時候就可以試試 shim 了。
DateTime 類別隸屬 System.dll 組件,所以現在我們要產生 System.dll 的替身:在 MyLibTest 專案的 References 節點中對 System 組件點右鍵,選 Add Fakes Assembly。
接著修改剛才的測試方法,如下所示:
幾個值得注意的地方:
試試單步追蹤剛才的測試程式碼,並且追到 MyDateTime.GetCurrentMonth 方法裡面去,看看當程式執行到 DateTime.Now() 的時候,接下來會跑到哪裡。此時你應該會對 Fakes 的運作原理更有感覺,也更清楚「替身」、「攔截」等詞彙在這裡的含意。
最後再附上一個 shim 的單元測試範例,主要是練習如何替換建構子和改寫的 ToString 方法。
這是欲替換掉的類別:
這是單元測試:
簡單解釋一下:
不知不覺越寫越多了....以入門練習來說,應該差不多夠了。先這樣吧!
延伸閱讀
簡介
Microsoft Fakes 是一套用來協助單元測試的框架,其目的在於隔離受測的部分程式碼,以便在某個單元測試失敗時,你能夠確定問題出在測試標的本身,而不是其他地方(就不用疑神疑鬼:到底是哪個類別出錯了)。
你可以利用 Fakes 來產生特定物件或方法的「替身」來把一些與測試目的無關的程式碼替換掉。Fakes 的替身有兩種:
- stub - 用來替換某些實作了相同介面的類別實作。適用場合:你的受測元件必須依賴介面(或抽象類別)、而非依賴特定具象類別。也就是說,前提是要遵循「針對介面來寫程式」的原則。嚴格來說,stub 並不等於 mock,因為它不提供行為驗證的檢查。有關 stub、shim、mock 等名詞的意義與用途,可參考 91 的文章<Unit Test - Stub, Mock, Fake簡介>。
- shim - 能夠將已經編譯好的 IL code 替換成你提供的程式碼。適用場合:欲隔離的類別並未實作特定介面,或者根本沒有原始碼,而只有編譯過的組件(例如 .NET Framework 組件)。
如果沒有實際用過,恐怕不容易體會 Fakes 的用途,以及 stub 和 shim 的差別。底下分別就 stub 和 shim 提供實作練習的範例。
Note:這裡我用「替身」一詞來泛指 Microsoft Fakes 產生的假物件,係為了方便說明,也許不是那麼精確。
工具:Visual Studio 2012 Ultimate。
Stub 實作練習
步驟如下:
- 建立一個空的 Solution,取名 FakesDemo。
- 在此 solution 中加入新的 Class Library 專案,取名 MyLib。
- 在 MyLib 中加入一個 Interface,取名 IFruit。
- 在 MyLib 中加入一個 Class,取名 FruitStore。
設想的情境是這樣:FruitStore 類別要提供一個 GetPrice 方法,此方法須傳入一個 IFruit 型別的參數,並傳回該水果的價格。
IFruit 介面的定義如下:
public interface IFruit { int GetPrice(); }
然後是 FruitStore 類別:
public class FruitStore { public int GetPrice(IFruit aFruit) { return aFruit.GetPrice(); } }
還缺什麼呢?實作 IFruit 介面的具象類別。所以,接著再加入一個新的類別:Apple。程式碼如下:
public class Apple : IFruit { public int GetPrice() { throw new NotImplementedException(); } }
注意這時候我們還沒有提供 Apple.GetPrice() 方法的實作,目前只是簡單丟出一個 exception。因為我們打算再實作各類水果類別之前,先測試水果商店,也就是 FruitStore 類別的邏輯。
所以,接著在 FakesDemo solution 中加入一個新的 Unit Test 專案,取名為 MyLibTest。然後加入專案組件參考:MyLib(因為我們要對這個組件寫單元測試)。
在測試專案中加入 MyLib 組件參考之後,接著加入一個 Unit Test 類別:FruitStoreTest。然後為此類別加入一個測試方法,程式碼如下:
using System; using Microsoft.VisualStudio.TestTools.UnitTesting; using MyLib; namespace MyLibTest { [TestClass] public class FruitStoreTest { [TestMethod] public void TestGetPrice() { IFruit apple = new Apple(); int actualValue = new FruitStore().GetPrice(apple); Assert.AreEqual(10, actualValue); } } }
TIP: 如果你曾用過 Visual Studio 2010 來撰寫單元測試,也許會發現原本在受測類別中點右鍵就可以產生對應的單元測試,可是在 Visual Studio 2012 中卻找不到這項功能了。是的,由於這項功能跟 MS-Test 太過緊密耦合,而且非常依賴私有存取子(private accessor)來產生單元測試代碼,所以被拿掉了。
執行測試看看(主選單 \ TEST \ Run \ All Tests)。此時應該會看到測試結果是失敗的:
這是因為 Apple 類別的 GetPrice() 方法尚未提供實作,僅單純拋出例外的緣故。但我們目前還不想去處理 Apple 類別,只想先測試 FruitStore 而已。這個時候,Fakes 就派上用場了。
產生替身
現在我們要對 MyLib 組件產生一些替身程式碼,做法是在 Solution Explorer 中展開 MyLibTest 專案的 Reference 節點,然後在 MyLib 項目上點右鍵,選 Add Fakes Assembly。參考下圖:
接著你會看到這個專案裡面多了一些新東西:
這些就是我們目前所需要的了。接著修改剛才的測試程式碼,改成這樣:
[TestMethod] public void TestGetPriceFaked() { IFruit fakedApple = new MyLib.Fakes.StubIFruit() { GetPrice = () => { return 10; } }; int actualValue = new FruitStore().GetPrice(fakedApple); Assert.AreEqual(10, actualValue); }
注意這裡的變化:
- 原先使用 Apple 類別的 instance,現在改成使用 Fakes.StubIFruit。這個 StubIFruit 就是實作了 IFruit 介面的替身類別。Mcirosoft Fakes 預設的命名方式會在介面名稱前面冠上 "Stub"。
- 建立替身物件的同時,也設定了委派 GetPrice,令它指向我們提供的一個匿名方法,這個匿名方法只是單純傳回 10(程式碼的第 6 行)。
- 呼叫 FruitStore 物件的 GetPrice() 方法時,傳入我們剛才建立的假蘋果。
TIP: 如果是第一次在單元測試中使用替身物件,不妨以單步追蹤的方式把測試程式碼逐行跑一遍,對於了解其中的來龍去脈應該會有些幫助。
再跑一次測試,這次就能通過了,因為我們已經用假蘋果取代真蘋果。換言之,受測標的 FruitStore 已經和 Apple 類別隔離開了。
Shim 實作練習
如前面提過的,shim 適合用於欲隔離之類別沒有實作特定介面,或者根本沒有原始碼的情況。MSDN 那篇文章是以 .NET BCL 中的 DateTime 來當作範例。簡單起見,我就依樣畫葫蘆,並做點補充。
跟剛才的 stub 實作練習一樣,先看看沒有使用替身物件的情況。
沿用先前的實作範例,在 MyLib 專案中加入一個類別:MyDateTime。程式碼很簡單:
public class MyDateTime { public int GetCurrentMonth() { return DateTime.Now.Month; } }
GetCurrentMonth 方法會傳回目前的月份。
然後加入測試程式碼:在 MyLibTest 專案中加入一個 Unit Test 類別:MyDateTimeTest。程式碼如下:
[TestClass] public class MyDateTimeTest { [TestMethod] public void TestCurrentMonth() { MyDateTime dt = new MyDateTime(); int expected = 10; Assert.AreEqual(expected, dt.GetCurrentMonth()); } }
OK! 我是在十月份的時候寫這個單元測試,所以預期 GetCurrentMonth 方法一定會傳回 10。可是,等到十一月份以後再來跑這個單元測試又不會過了,還得修改這段測試程式碼,這未免太麻煩了。可是,我們沒有 DateTime 的原始碼,而且它也沒有實作特定介面,無法套用剛才的 stub 伎倆。這時候就可以試試 shim 了。
DateTime 類別隸屬 System.dll 組件,所以現在我們要產生 System.dll 的替身:在 MyLibTest 專案的 References 節點中對 System 組件點右鍵,選 Add Fakes Assembly。
接著修改剛才的測試方法,如下所示:
[TestMethod] public void TestCurrentMonth() { using (ShimsContext.Create()) { System.Fakes.ShimDateTime.NowGet = () => { return new DateTime(2012, 10, 1); }; MyDateTime dt = new MyDateTime(); int expected = 10; Assert.AreEqual(expected, dt.GetCurrentMonth()); } }
幾個值得注意的地方:
- 預設的 shim 類別命名規則,是在目標類別名稱前面加上 "Shim",所以這裡會是 ShimDateTime。原本的 DateTime.Now 是個靜態的唯讀屬性(只有 getter 沒有 setter),Fakes 在產生其對應的委派時,會在名稱後面加上 "Get",所以就成了 NowGet。
- 我們將 NowGet 委派指向一個內嵌的匿名方法,此匿名方法會固定傳回 2012 年 10 月 1 日,而非當下的日期。
- 在 ShimsContext 物件的勢力範圍內的 DateTime 物件都會被 ShimDateTime 這個替身所取代。如此一來,當受測類別 MyDateTime 有用到 DateTime.Now 時,就會轉而執行我們的替身物件的 NowGet 委派。(試試看把後面三行程式碼搬到 using 區塊外層,看看結果有何不同。)
試試單步追蹤剛才的測試程式碼,並且追到 MyDateTime.GetCurrentMonth 方法裡面去,看看當程式執行到 DateTime.Now() 的時候,接下來會跑到哪裡。此時你應該會對 Fakes 的運作原理更有感覺,也更清楚「替身」、「攔截」等詞彙在這裡的含意。
最後再附上一個 shim 的單元測試範例,主要是練習如何替換建構子和改寫的 ToString 方法。
這是欲替換掉的類別:
public class Foo { private string _name; public Foo(string name) { _name = name; } public override string ToString() { return base.ToString(); } }
這是單元測試:
[TestClass] public class FooTest { [TestMethod] public void TestFoo() { using (ShimsContext.Create()) { MyLib.Fakes.ShimFoo.ConstructorString = delegate(Foo f, string s) { var shimFoo = new MyLib.Fakes.ShimFoo(f); shimFoo.ToString = () => { return "Faked " + s; }; }; Foo foo = new Foo("Michael"); string actual = foo.ToString(); Assert.AreEqual("Faked Michael", actual); } } }
簡單解釋一下:
- 此範例要假造的類別名稱是 Foo,所以 Fakes 產生的 shim 類別叫做 ShimFoo。
- Foo 類別的建構子需要傳入一個字串參數,所以 Fakes 會產生一個用來替換該建構子的委派,名叫 ConstructorString。如果建構子需要傳入兩個 string 參數,此替身委派的名稱就會變成 ConstructorStringString。
- 在建構子委派方法中有建立一個 ShimFoo 的物件實體,然後把該物件的 ToString 委派指向我們的匿名方法。
不知不覺越寫越多了....以入門練習來說,應該差不多夠了。先這樣吧!
延伸閱讀
建議大家,VS2012 fake/stub相關的MSDN文件,一定要看英文的...
回覆刪除因為有蠻多中文還沒翻好,加上中文的內容有的會少很多...
另外,很感謝煥麟老師的稱讚 :)
我今天發現用fake object做的測試專案,當debug的時候,專案會變好肥啊~~~~
用release要16~17MB, 如果是debug產生的部分,則會到80MB...
也幫老師補充一下,如果大家不是用VS2012 Ultimate,又希望做到isolate的功能,可以參考Microsoft Research的Moles Isolation Framework。
回覆刪除請參考:
http://research.microsoft.com/en-us/projects/moles/
就是Pex and Moles那個Moles啦
哇,聽起來測試組件會膨脹許多。也許是預設情況下,Visual Studio 會針對整個受測組件的所有符合條件的介面與類別產生 stubs 和 shims 的緣故?這部分我還沒細看,但....我不確定,也許型別篩選會有幫助:http://msdn.microsoft.com/en-us/library/hh708916.aspx#bkmk_type_filtering
回覆刪除另外,中文術語的確有點麻煩(殘片、墊片、偽物件?)。目前我暫時不去想這個部分。
嗯,Moles 是 Fakes 的前身。多謝 91 的補充 ^^
回覆刪除感謝老師提供那一篇type filtering,真是豐富的一篇reference啊。
回覆刪除這篇文學到好多東西,哈哈,大豐收。
91 客氣了 :)
回覆刪除「師」字不敢當,請叫我 Michael 或煥麟兄就好,真的!
關於Private是否要被測試的部份,我滿想知道Huan-Lin老師的看法,對於TDD來說,我認為老師分享的第三篇文章和91哥的論點是正確的,但我總覺得如果是採用Use Case Driven的話,Private Method應該也要列入測試的範圍,請老師指點迷津
回覆刪除亞斯狼:
回覆刪除我不是單元測試專家,也不是 test first 忠實信徒,所以我很小心地回答如下....
關於 private 方法是否也要做單元測試,如果你已經考慮過:
- 這些 private 方法真的不適合抽離出去成為另一個單獨類別,而必須放在這個類別裡。
- 把它們宣告為 public 也不適合。
- 雖然將來可能因為 refactoring 導致這些 private 方法變動而需要一併修改單元測試代碼,但衡量得失之後,還是覺得該對它們寫單元測試。
在上述前之下,我覺得對 private 方法做單元測試並沒有甚麼不妥。在此同時,我也覺得以盡量省力、簡單的方式來做單元測試比較好。到了 VS2012,如果要直接對 private 方法寫單元測試,你可能得用 reflection 或 C# dynamic typing 機制來存取 private 方法,這多少都會增加開發和維護上的麻煩。所以簡單地說,我覺得不是絕對不行;經過斟酌之後,沒有其他更好方案的情況下,不妨少量為之。最終,你的測試代碼會告訴你這麼做值不值得。這也是經驗累積的一部份。希望有回答到你的問題(但真的別叫我「老師」了,跟大家一樣是 developer 喔!)
受教了!
回覆刪除對我大有助益
期待您接下來關於測試以及架構的文章
很高興有幫助!
回覆刪除完全同意您提到的:
回覆刪除關於 private 方法是否也要做單元測試,如果你已經考慮過:
- 這些 private 方法真的不適合抽離出去成為另一個單獨類別,而必須放在這個類別裡。
- 把它們宣告為 public 也不適合。
- 雖然將來可能因為 refactoring 導致這些 private 方法變動而需要一併修改單元測試代碼,但衡量得失之後,還是覺得該對它們寫單元測試。
在上述前之下,我覺得對 private 方法做單元測試並沒有甚麼不妥。在此同時,我也覺得以盡量省力、簡單的方式來做單元測試比較好。
請問一下,如果我要測試return IEnumerable的話,我應該要怎麼寫呢???
回覆刪除如果了解多型的概念的話,要測試 return IEnumerable 就很簡單了。
刪除因為 List 實作了 IEnumerable,也實作了 IEnumerable,所以直接 return 有實作 IEnumerable 的型別就可以了。
public class List : IList, ICollection,
IList, ICollection, IReadOnlyList, IReadOnlyCollection, IEnumerable,
IEnumerable
當然,很多都有實作 IEnuemrable ,只要可以被 foreach 展開巡覽 就一定有實作 IEnumerable