Microsoft Fakes 入門

最近看了點單元測試的東西,發覺 MSDN 網站上的這篇文章蠻容易入手:Isolating Code under Test with Microsoft Fakes,便做了點整理。這篇筆記裡面的範例也有一些取自該文,只是寫的比較囉嗦一些。

簡介

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 實作練習

步驟如下:
  1. 建立一個空的 Solution,取名 FakesDemo。
  2. 在此 solution 中加入新的 Class Library 專案,取名 MyLib。
  3. 在 MyLib 中加入一個 Interface,取名 IFruit。
  4. 在 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 委派指向我們的匿名方法。

不知不覺越寫越多了....以入門練習來說,應該差不多夠了。先這樣吧!

延伸閱讀

13 則留言:

  1. 建議大家,VS2012 fake/stub相關的MSDN文件,一定要看英文的...

    因為有蠻多中文還沒翻好,加上中文的內容有的會少很多...

    另外,很感謝煥麟老師的稱讚 :)

    我今天發現用fake object做的測試專案,當debug的時候,專案會變好肥啊~~~~

    用release要16~17MB, 如果是debug產生的部分,則會到80MB...

    回覆刪除
  2. 也幫老師補充一下,如果大家不是用VS2012 Ultimate,又希望做到isolate的功能,可以參考Microsoft Research的Moles Isolation Framework。

    請參考:
    http://research.microsoft.com/en-us/projects/moles/

    就是Pex and Moles那個Moles啦

    回覆刪除
  3. 哇,聽起來測試組件會膨脹許多。也許是預設情況下,Visual Studio 會針對整個受測組件的所有符合條件的介面與類別產生 stubs 和 shims 的緣故?這部分我還沒細看,但....我不確定,也許型別篩選會有幫助:http://msdn.microsoft.com/en-us/library/hh708916.aspx#bkmk_type_filtering

    另外,中文術語的確有點麻煩(殘片、墊片、偽物件?)。目前我暫時不去想這個部分。

    回覆刪除
  4. 嗯,Moles 是 Fakes 的前身。多謝 91 的補充 ^^

    回覆刪除
  5. 感謝老師提供那一篇type filtering,真是豐富的一篇reference啊。

    這篇文學到好多東西,哈哈,大豐收。

    回覆刪除
  6. 91 客氣了 :)
    「師」字不敢當,請叫我 Michael 或煥麟兄就好,真的!

    回覆刪除
  7. 關於Private是否要被測試的部份,我滿想知道Huan-Lin老師的看法,對於TDD來說,我認為老師分享的第三篇文章和91哥的論點是正確的,但我總覺得如果是採用Use Case Driven的話,Private Method應該也要列入測試的範圍,請老師指點迷津

    回覆刪除
  8. 亞斯狼:
    我不是單元測試專家,也不是 test first 忠實信徒,所以我很小心地回答如下....
    關於 private 方法是否也要做單元測試,如果你已經考慮過:
    - 這些 private 方法真的不適合抽離出去成為另一個單獨類別,而必須放在這個類別裡。
    - 把它們宣告為 public 也不適合。
    - 雖然將來可能因為 refactoring 導致這些 private 方法變動而需要一併修改單元測試代碼,但衡量得失之後,還是覺得該對它們寫單元測試。

    在上述前之下,我覺得對 private 方法做單元測試並沒有甚麼不妥。在此同時,我也覺得以盡量省力、簡單的方式來做單元測試比較好。到了 VS2012,如果要直接對 private 方法寫單元測試,你可能得用 reflection 或 C# dynamic typing 機制來存取 private 方法,這多少都會增加開發和維護上的麻煩。所以簡單地說,我覺得不是絕對不行;經過斟酌之後,沒有其他更好方案的情況下,不妨少量為之。最終,你的測試代碼會告訴你這麼做值不值得。這也是經驗累積的一部份。希望有回答到你的問題(但真的別叫我「老師」了,跟大家一樣是 developer 喔!)

    回覆刪除
  9. 受教了!
    對我大有助益
    期待您接下來關於測試以及架構的文章

    回覆刪除
  10. 完全同意您提到的:

    關於 private 方法是否也要做單元測試,如果你已經考慮過:
    - 這些 private 方法真的不適合抽離出去成為另一個單獨類別,而必須放在這個類別裡。
    - 把它們宣告為 public 也不適合。
    - 雖然將來可能因為 refactoring 導致這些 private 方法變動而需要一併修改單元測試代碼,但衡量得失之後,還是覺得該對它們寫單元測試。

    在上述前之下,我覺得對 private 方法做單元測試並沒有甚麼不妥。在此同時,我也覺得以盡量省力、簡單的方式來做單元測試比較好。

    回覆刪除
  11. 請問一下,如果我要測試return IEnumerable的話,我應該要怎麼寫呢???

    回覆刪除
    回覆
    1. 如果了解多型的概念的話,要測試 return IEnumerable 就很簡單了。

      因為 List 實作了 IEnumerable,也實作了 IEnumerable,所以直接 return 有實作 IEnumerable 的型別就可以了。

      public class List : IList, ICollection,
      IList, ICollection, IReadOnlyList, IReadOnlyCollection, IEnumerable,
      IEnumerable

      當然,很多都有實作 IEnuemrable ,只要可以被 foreach 展開巡覽 就一定有實作 IEnumerable

      刪除

技術提供:Blogger.
回頂端⬆️