1. 程式人生 > >TDD學習筆記【六】一Unit Test - Stub, Mock, Fake 簡介

TDD學習筆記【六】一Unit Test - Stub, Mock, Fake 簡介

-i moc load customers eight foreach 存在 執行 repo

這篇文章簡介一下,如何通過 mock framework,來輔助我們更便利地模擬目標對象的依賴對象,而不必手工敲堆只為了這次測試而存在的輔助類型。

而模擬目標對象的部分,常見的有 stub object, mock object, fake object,本文也會簡單介紹一下三者的不同點,並且通過實例,幫助讀者快速的 pick up 實戰經驗。

安裝與範例說明

本文的範例,使用 VS2013 為開發工具,mock framework 則是使用 Rhino.Mocks,通過 IoC 的方式,由構造函數來傳入 stub/mock/fake object。

註:在 Microsoft Fakes 裏面也有內建的 stub object,但是是類似 fake object 的方式產生,而非 Rhino.Mocks, moq 這類 mock framework 是使用動態產生 stub/mock object的方式。

Isolating Code under Test with Microsoft Fakes

  1. 效益:顧客入場時,幫助店員統計出門票收入,確認是否核帳正確
  2. 角色:Pub 店員
  3. 目的:根據顧客與相關條件,算出對應的門票收入總值
public interface ICheckInFee
    {
        decimal GetFee(Customer customer);
    }

    public class Customer
    {
        public bool IsMale { get; set; }

        
public int Seq { get; set; } } public class Pub { private readonly ICheckInFee _checkInFee; private decimal _inCome; public Pub(ICheckInFee checkInFee) { this._checkInFee = checkInFee; } /// <summary> ///
入場 /// </summary> /// <param name="customers"></param> /// <returns>收費的人數</returns> public int CheckIn(List<Customer> customers) { var result = 0; foreach (var customer in customers) { var isFemale = !customer.IsMale; //女生免費入場 if (isFemale) { continue; } else { //for stub, validate status: income value //for mock, validate only male this._inCome += this._checkInFee.GetFee(customer); result++; } } //for stub, validate return value return result; } public decimal GetInCome() { return this._inCome; } }


CheckIn 說明:

當顧客進場時,如果是女生,則免費入場。若為男生,則根據 ICheckInFee 接口來取得門票的費用,並累計到 inCome 中。通過 GetInCome() 方法取得這一次的門票收入總金額。

Stub

Stub 通常使用在驗證目標回傳值,以及驗證目標對象狀態的改變。

技術分享圖片

技術分享圖片

這兩種驗證方式的重點,都擺在目標對象自身的邏輯。

即測試目標對象時,並不在乎目標對象與外部依賴對象如何互動,關註在當外部相依對象回傳什麽樣的數據時,會導致目標對象內部的狀態或邏輯變化。

所以這類的驗證方式,是通過 stub object 直接模擬外部依賴回傳的數據,來驗證目標對象行為是否如同預期。

範例:

第一個測試,是驗證收費人數是否符合預期,代碼如下:

        [TestMethod]
        public void Test_Charge_Customer_Count()
        {
            //Arrange
            var stubCheckInFee = MockRepository.GenerateMock<ICheckInFee>();
            var target = new Pub(stubCheckInFee);
            var customers = new List<Customer>
            {
                new Customer {IsMale = true},
                new Customer {IsMale = false},
                new Customer {IsMale = false},
            };
            decimal expected = 1;
            //Act
            var actual = target.CheckIn(customers);
            //Assert
            Assert.AreEqual(expected, actual);
        }

使用 Rhino.Mocks 相當簡單,步驟如下:

  1. 通過 MockRepository.GenerateStub<T>(),來建立某一個 T 類型的 stub object,以上面例子來說,是建立實現 ICheckInFee 接口的子類。
  2. 把該 stub object 通過構造函數,傳給測試目標對象。
  3. 定義當調用到該 stub object 的哪一個方法時(例子中是GetFee方法),傳入的參數是什麽, stub 要回傳是什麽。

通過 Rhino.Mocks,就這麽簡單地通過 Lambda 的方式定義 stub object 的行為,取代了原本要自己建立一個實體類型,並實現ICheckInFee 接口,定義 GetFee 要回傳的值。

上面的測試案例,是入場顧客人數3人,一男兩女,因為目前 Pub 的 CheckIn 方法,只針對男生收費,所以回傳收費人數應為1人。

第二個測試,則是驗證收費的總數,是否符合預期。測試案例一樣是一男兩女,而通過 stub object模擬每一人收費為100元,所以預期結果門票收入總數為100。測試程序如下:

      [TestMethod]
        public void Test_Income()
        {
            //Arrange
            var stubCheckInFee = MockRepository.GenerateMock<ICheckInFee>();
            var target = new Pub(stubCheckInFee);
            stubCheckInFee.Stub(x => x.GetFee(Arg<Customer>.Is.Anything)).Return(100);
            var customers = new List<Customer>
            {
                new Customer {IsMale = true},
                new Customer {IsMale = false},
                new Customer {IsMale = false},
            };
//Act
decimal inComeBeforeCheckIn = target.GetInCome();
//Assert Assert.AreEqual(
0, inComeBeforeCheckIn); decimal expectedIncome = 100; //Act int chargeCustomerCount = target.CheckIn(customers); var actualIncome = target.GetInCome(); //Assert Assert.AreEqual(expectedIncome, actualIncome); }

可以看到這裏有兩個 Assert,因為我們這裏是驗證狀態的改變,期望在調用目標對象的 CheckIn 方法之前,取得的門票收入應為0。而調用之後,依照這個測試案例,門票收入應為100。

通過這兩個測試案例,其實實際要測試的部分是,CheckIn 的方法只針對男生收費這一段邏輯。不管實際 production code,門票一人收費多少,都不會影響到這一份商業邏輯。

怎麽根據環境或顧客來進行計價,那是在 production code 中,實現 ICheckInFee 接口的子類,要自己進行測試的,與 Pub 對象無關。這樣一來,才能隔離 ICheckInFee 背後的變化。

Mock

使用時機:

上面提到驗證對象的第三種方式:「驗證目標對象與外部依賴接口的互動方式」,如下圖所示:

技術分享圖片

這聽起來可能相當抽象,但在開發中,的確可能會碰到這樣的測試需求。

Mock 的驗證比起 stub 要復雜許多,變動性通常也會大一點,但往往在驗證一些 void 的行為會使用到,例如:在某個條件發生時,要記錄 Log。這種情境,用 stub 就很難驗證,因為對目標對象來說,沒有回傳值,也沒有狀態變化,就只能透過 mock object 來驗證,目標對象是否正確的與Log 接口進行互動。

範例:

以這個範例來說,我們想驗證的是:在2男1女的測試案例中,是否只呼叫 ICheckInFee 接口兩次。程序代碼如下:

  [TestMethod]
        public void Test_CheckIn_Charge_Only_Male()
        {
            //Arrange
            //兩男一女
            var customers = new List<Customer>
            {
                new Customer {IsMale = true},
                new Customer {IsMale = true},
                new Customer {IsMale = false},
            };
            MockRepository mock=new MockRepository();
            ICheckInFee stubCheckInFee = mock.StrictMock<ICheckInFee>();
            using (mock.Record())
            {
                //期望調用ICheckInFee的GetFee()次數為2
                //Assert
                stubCheckInFee.GetFee(customers.ElementAt(0));
                LastCall.IgnoreArguments()
                    .Return((decimal) 100)
                    .Repeat.Times(2);
            }
            using (mock.Playback())
            {
                var target = new Pub(stubCheckInFee);
                //Act
                target.CheckIn(customers);
            }
        }
  • http://blog.csdn.net/ghostbear/article/details/8032068
  • http://www.cnblogs.com/huyh/archive/2010/06/14/1758143.html
  • http://www.cnblogs.com/jams742003/archive/2010/05/08/1730408.html

Fake

使用時機:

當目標對象使用到靜態方法,或 .net framework 本身的對象,甚至於針對一般直接相依的對象,我們都可以透過 fake object 的方式,直接仿真相依對象的行為。

範例:

以這例子來說,假設 CheckIn 的需求改變,從原本的「女生免費入場」變成「只有當天為星期五,女生才免費入場」,修改程序代碼如下:

view source print?
01 public int CheckIn(List<Customer> customers)
02 {
03 var result = 0;
04
05 foreach (var customer in customers)
06 {
07 var isFemale = !customer.IsMale;
08 //for fake
09 var isLadyNight = DateTime.Today.DayOfWeek == DayOfWeek.Friday;
10 //禮拜五女生免費入場
11 if (isLadyNight && isFemale)
12 {
13 continue;
14 }
15 else
16 {
17 //for stub, validate status: income value
18 //for mock, validate only male
19 this._inCome += this._checkInFee.GetFee(customer);
20
21 result++;
22 }
23 }
24
25 //for stub, validate return value
26 return result;
27 }

碰到 DateTime.Today 這類東西,測試案例就會卡住。總不能每次測試都去改測試機上面的日期,或是只有星期五或非星期五才執行某些測試吧。

所以,我們得透過 Isolation framework 來輔助,針對使用到的組件,建立 fake object。

首先,因為這個例子建立的 fake object,是針對 System.DateTime,所以在測試項目上,針對System.dll來新增 Fake 組件,如下圖所示:

技術分享圖片

可以看到增加了一個 Fakes 的 folder,其中會針對要 fake 的 dll,產生對應的程序代碼,以便我們進行攔截與改寫。

技術分享圖片

使用 fake 對象也相當簡單,先以測試星期五為例,程序代碼如下:

view source print?
01 [TestMethod]
02 public void Test_Friday_Charge_Customer_Count()
03 {
04 using (ShimsContext.Create())
05 {
06 System.Fakes.ShimDateTime.TodayGet = () =>
07 {
08 //2012/10/19為Friday
09 return new DateTime(2012, 10, 19);
10 };
11
12 //arrange
13 ICheckInFee stubCheckInFee = MockRepository.GenerateStub<ICheckInFee>();
14 Pub target = new Pub(stubCheckInFee);
15
16 stubCheckInFee.Stub(x => x.GetFee(Arg<Customer>.Is.Anything)).Return(100);
17
18 var customers = new List<Customer>
19 {
20 new Customer{ IsMale=true},
21 new Customer{ IsMale=false},
22 new Customer{ IsMale=false},
23 };
24
25 decimal expected = 1;
26
27 //act
28 var actual = target.CheckIn(customers);
29
30 //assert
31 Assert.AreEqual(expected, actual);
32 }
33 }

說明如下:

  1. using (ShimsContext.Create()){} 的範圍中,會使用 Fake 組件。
  2. 當在 fake context 環境下,呼叫到 System.DateTime.Today 時,會轉呼叫 System.Fakes.ShimDateTime.TodayGet,並定義其回傳值為「2012/10/19」,因為這一天是星期五。

接著就跟原本的測試程序代碼一樣,當星期五時,只對男生收費。

偵錯時,可以看到 DateTime.Today 變成我們仿真的「2012/10/19」,但實際系統日期是「2012/10/15」。

技術分享圖片

再增加一個星期六的測試案例,程序代碼如下:

view source print?
01 [TestMethod]
02 public void Test_Saturday_Charge_Customer_Count()
03 {
04
05 using (ShimsContext.Create())
06 {
07 System.Fakes.ShimDateTime.TodayGet = () =>
08 {
09 //2012/10/20為Saturday
10 return new DateTime(2012, 10, 20);
11 };
12
13 //arrange
14 ICheckInFee stubCheckInFee = MockRepository.GenerateStub<ICheckInFee>();
15 Pub target = new Pub(stubCheckInFee);
16
17 stubCheckInFee.Stub(x => x.GetFee(Arg<Customer>.Is.Anything)).Return(100);
18
19 var customers = new List<Customer>
20 {
21 new Customer{ IsMale=true},
22 new Customer{ IsMale=false},
23 new Customer{ IsMale=false},
24 };
25
26 decimal expected = 3;
27
28 //act
29 var actual = target.CheckIn(customers);
30
31 //assert
32 Assert.AreEqual(expected, actual);
33 }
34 }

因為是星期六,所以1男2女,收費人數為3人。

補充:

連 System.dll 都可以進行 fake object 仿真了,所以即使是我們自定義,直接相依,也可以透過這種方式來仿真。

這樣一來,即便是直接相依的對象,也可以進行獨立測試了。

但強烈建議,針對自定義對象的部分,這是黑魔法類型的作法,如果沒有包袱,建議對象設計還是要采 IoC 方式設計。如果是 legacy code,想要進行重構,擺脫直接相依的問題,則可先透過 fake object 來建立單元測試,接下來進行重構,重構後當對象不直接相依時,再改用上面的 stub/mock 方式來進行測試。

可以參考這篇在 Martin Fowler 網站上的文章:Modern Mocking Tools and Black Magic

註:即使不是在VS2012的環境底下,也可以到 Microsoft Research 上 download Moles: Moles - Isolation framework for .NET使用

結論

今天這篇文章介紹了 stub, mock 與 fake 的用法,但依筆者實際經驗,使用 stub 的比例大概是8~9成,使用mock的比例大概僅1~2成。而 fake 的方式,則用在特例,例如靜態方法跟 .net framework 原生組件。

也請讀者朋友務必記得幾個基本原則:

  1. 同一測試案例中,請避免 stub 與 mock 在同一個案例一起驗證。原因就如同一直在強調的單元測試準則,一次只驗證一件事。而 stub 與 mock 的用途本就不同,stub 是用來輔助驗證回傳值或目標對象狀態,而 mock 是用來驗證目標對象與相依對象互動的情況是否符合預期。既然八竿子打不著,又怎麽會在同一個測試案例中,驗證這兩個完全不同的情況呢?
  2. Mock 的驗證可以相當復雜,但越復雜代表維護成本越高,代表越容易因為需求異動而改變。所以,請謹慎使用 mock,更甚至於當發生問題時,針對問題的測試案例才增加 mock 的測試,筆者都認為是合情合理的。
  3. 當要測試一個目標對象,要 stub/mock/fake 的 object 太多時,請務必思考目標對象的設計是否出現問題,是否與太多細節耦合,是否可將這些細節職責合並。
  4. 當測試程序寫的一狗票落落長時,請確認目標對象的職責是否太肥,或是方法內容太長這都是因為目標對象設計不良,導致測試程序不容易撰寫或維護的情況。問題根源在目標對象的設計質量。
  5. 將測試程序當作 production code 的一部份,production code 中不該出現的壞味道,一樣不該出現在測試程序中,尤其是重復的程序代碼所以測試程序,基本上也需要進行重構。但也請務必提醒自己,測試程序基本上不會包含邏輯,因為包含了邏輯,您就應該再寫一段測試程序,來測這個測試程序是否符合預期

TDD學習筆記【六】一Unit Test - Stub, Mock, Fake 簡介