iT邦幫忙

DAY 7
5

30天快速上手TDD系列 第 7

[Day 7]Unit Test - Stub, Mock, Fake簡介

在上一篇文章中,說明了有哪些方式可以對目標物件進行獨立測試,隔絕目標物件與外部的相依性。

這篇文章則要簡介一下,如何透過mock framework,來輔助我們更便利地模擬目標物件的相依物件,而不必手工刻一堆只為了這次測試而存在的輔助類別。

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

上一篇文章:[Day 6]隔絕相依性的方式與特性
本系列文章專區
@前言
本篇文章的範例,使用VS2012為開發工具,mock framework則是Rhino.Mocks,物件設計透過IoC的方式,由建構式來傳入stub/mock/fake object。

可透過NuGet安裝Rhino.Mocks,如下圖所示:

先介紹一下,production code的意義為何,針對Pub物件,描述如下:

  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 ICheckInFee _checkInFee;
        private decimal _inCome = 0;

        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

  1. 使用時機:
    [Day 2]Unit Testing 簡介中,提到了驗證物件行為是否符合預期有三種方式。Stub通常使用在驗證目標回傳值,以及驗證目標物件狀態的改變。


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

意即測試目標物件時,並不在乎目標物件與外部相依物件如何互動,關注在當外部相依物件回傳什麼樣的資料時,會導致目標物件內部的狀態或邏輯變化。

所以這類的驗證方式,是透過stub object直接模擬外部相依物件回傳的資料,來驗證目標物件行為是否如同預期。

  1. 範例:
    第一個測試,是驗證收費人數是否符合預期,程式碼如下:

        [TestMethod]
        public void Test_Charge_Customer_Count()
        {
            //arrange
            ICheckInFee stubCheckInFee = MockRepository.GenerateStub<ICheckInFee>();
            Pub 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},
            };
    
            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的哪一個方法時,若傳入的參數為何,則stub要回傳什麼

透過Rhino.Mocks,就這麼簡單地透過Lambda的方式定義stub object的行為,取代了原本要自己建立一個實體類別,並實作ICheckInFee介面,定義GetFee要回傳的值。

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

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

        [TestMethod]
        public void Test_Income()
        {
            //arrange
            ICheckInFee stubCheckInFee = MockRepository.GenerateStub<ICheckInFee>();
            Pub 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},
            };            

            var inComeBeforeCheckIn = target.GetInCome();
            Assert.AreEqual(0, inComeBeforeCheckIn);

            decimal expectedIncome = 100;

            //act
            var 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

  1. 使用時機:
    上面提到驗證物件的第三種方式:「驗證目標物件與外部相依介面的互動方式」,如下圖所示:

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

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

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

        [TestMethod]
        public void Test_CheckIn_Charge_Only_Male()
        {
            //arrange mock
            var customers = new List<Customer>();
    
            //2男1女
            var customer1 = new Customer { IsMale = true };
            var customer2 = new Customer { IsMale = true };
            var customer3 = new Customer { IsMale = false };
    
            customers.Add(customer1);
            customers.Add(customer2);
            customers.Add(customer3);
    
            MockRepository mock = new MockRepository();
            ICheckInFee stubCheckInFee = mock.StrictMock<ICheckInFee>();
    
            using (mock.Record())
            {
                //期望呼叫ICheckInFee的GetFee()次數為2次
                stubCheckInFee.GetFee(customer1);
    
                LastCall
                    .IgnoreArguments()
                    .Return((decimal)100)
                    .Repeat.Times(2);
            }
    
            using (mock.Playback())
            {
                var target = new Pub(stubCheckInFee);
    
                var count = target.CheckIn(customers);
            }
        }
    

mock的API相當多樣與複雜,有興趣的讀者朋友請自行參閱官方API document的說明。

@Fake

  1. 使用時機:
    當目標物件使用到靜態方法,或.net framework本身的物件,甚至於針對一般直接相依的物件,我們都可以透過fake object的方式,直接模擬相依物件的行為。

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

        public int CheckIn(List<Customer> customers)
        {
            var result = 0;
    
            foreach (var customer in customers)
            {
                var isFemale = !customer.IsMale;
                //for fake
                var isLadyNight = DateTime.Today.DayOfWeek == DayOfWeek.Friday;
                //禮拜五女生免費入場
                if (isLadyNight && 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;
        }
    

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

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

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

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

使用fake物件也相當簡單,先以測試星期五為例,程式碼如下:

        [TestMethod]
        public void Test_Friday_Charge_Customer_Count()
        {
            using (ShimsContext.Create())
            {
                System.Fakes.ShimDateTime.TodayGet = () =>
                    {
                        //2012/10/19為Friday
                        return new DateTime(2012, 10, 19);
                    };

                //arrange
                ICheckInFee stubCheckInFee = MockRepository.GenerateStub<ICheckInFee>();
                Pub 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},
                };

                decimal expected = 1;

                //act
                var actual = target.CheckIn(customers);

                //assert
                Assert.AreEqual(expected, actual);
            }
        }

說明如下:
(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。

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

        [TestMethod]
        public void Test_Saturday_Charge_Customer_Count()
        {
            
            using (ShimsContext.Create())
            {
                System.Fakes.ShimDateTime.TodayGet = () =>
                {
                    //2012/10/20為Saturday
                    return new DateTime(2012, 10, 20);
                };

                //arrange
                ICheckInFee stubCheckInFee = MockRepository.GenerateStub<ICheckInFee>();
                Pub 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},
                };

                decimal expected = 3;

                //act
                var actual = target.CheckIn(customers);

                //assert
                Assert.AreEqual(expected, actual);
            }
        }

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

  1. 補充:
    連System.dll都可以進行fake object模擬了,所以即使是我們自訂的class,直接相依,也可以透過這種方式來模擬。

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

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

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

by the way, 沒在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中不該出現的壞味道,一樣不該出現在測試程式中,尤其是重複的程式碼。所以測試程式,基本上也需要進行重構。但也請務必提醒自己,測試程式基本上不會包含邏輯,因為包含了邏輯,您就應該再寫一段測試程式,來測這個測試程式是否符合預期。


上一篇
[Day 6]隔絕相依性的方式與特性
下一篇
[Day 8]Integration Testing & Web UI Testing
系列文
30天快速上手TDD31
0
ted99tw
iT邦高手 1 級 ‧ 2012-10-15 07:57:55

沙發
這種文章是一定要拜讀地呀~~~
喜歡喜歡喜歡

pajace2001 iT邦研究生 1 級‧ 2012-10-15 08:16:43 檢舉

差一點~~沙發被搶走了XD

0
pajace2001
iT邦研究生 1 級 ‧ 2012-10-15 08:17:13

分享時間:2012-10-15 02:17:26

這個時間怎麼還沒睡啊~~~

ted99tw iT邦高手 1 級‧ 2012-10-15 08:23:18 檢舉

不是都說厲害的IT人每天只睡三小時嗎?疑惑

所以,這時間可能加啡貓才剛起床.....毆飛

就是91 iT邦研究生 4 級‧ 2012-10-15 09:50:25 檢舉

是還沒睡,不過又搞到失眠了...

當初猶豫要不要參加鐵人賽,其中一個理由也是很容易把身體搞壞。一篇好文章,至少都要花三四個小時在寫...能犧牲的就只有睡眠時間跟下班時間。真的挺吃力的。

pajace2001 iT邦研究生 1 級‧ 2012-10-15 20:51:40 檢舉

h大辛苦了. 讚讚讚讚讚

話說我只有花30分鐘到一小時而已!範例想不出來就跳過汗汗

0
pajace2001
iT邦研究生 1 級 ‧ 2012-10-15 20:49:59

原來 mock 這麼複雜啊~~
難怪我一直都搞不清楚 mock 到底是做什麼用的!
如果如果不搭配 MockRepository 來使用那是不是就會變的超難寫呢?

另外,新增 Fack 組件是不是要特別的版本才有呢?
我的在 System 上按右鍵只有

沒有 Fack 這個東西可以新增!!我的是 Visual Studio Premium 2012

拜讀h大的文章後真的覺得,自己必須再訓練筆記~~

就是91 iT邦研究生 4 級‧ 2012-10-15 20:59:37 檢舉

的確,是Ultimate才有的版本。

可能要請您使用Moles了...基本上是一樣的,我想fake object的骨子裡也是Moles。

我要留言

立即登入留言