[單元測試]Stub的原理 & Why we need mock rather than stub

  • 17688
  • 0

[單元測試]Stub的原理 & Why we need mock rather than stub

前言

在前一篇文章 [單元測試]NUnit.Mocks使用介紹 - 以Service Test為例 裡面有提到,要模擬class中使用到的外部元件,可以透過mock來達到自行定義回傳值的效果。而文章中有引用到一段Testing ASP.NET Web Application書裡的定義,指出『Mock objects are also a form of test double and work in a similar fashion to stub objects.』,這邊提到mock其實方式跟stub很像,(嚴格來說,stub做的到的,mock都做的到),stub就像是輕量級版本的mock,而且不需要透過mock framework或是unit test framework,我們就能達到stub的效果,來取代API中實際用到的外部元件執行的情況。

接著我們來看一下,在Testing ASP.NET Web Application書裡,對stub的定義:
A test stub is a specific type of test double. A stub is used when you need to replicate an object and control the output, but without verifying any interactions with the stub object for correctness.』

可以看到stub的目的,也是當我們需要模擬一個object,並定義其回傳值時,所使用到的一個test double。它與mock最大不一樣的地方,在於它不會去驗證stub object的正確性,也就是我們定義我們的stub在被呼叫的時候,都是正確的情況。這邊所謂正確的情況,其實不是focus在回傳值,單就回傳值而言,stub與mock是相同的,而是針對呼叫的function參數,以及被呼叫的function次數,我們都定義stub被呼叫時是正確的。

很抽象,是吧…沒關係,我們會在文章的後半段,來說明什麼東西stub做不到,得用mock才能達到測試的需求。這邊我們先來解釋一下stub的原理,真的是簡單到爆炸,只要你懂OO的繼承與多型,就可以從『沒那麼簡單』唱到『簡單愛』,讓我們繼續往下GO下去。


How to Create a Stub to Simulate the External Object

要注意到,單元測試不論是mock或stub,模擬的都是external class的行為,external是個重點。一個好的設計,應該要符合Dependency Inversion Principle,也就是透過抽象的部分去與其他class做互動,抽象的部分就包括了abstract與interface。

也就是,如果您設計的class行為,在使用外部class時,都是透過new class()去達成目的。抱歉,這樣子設計的耦合性過高,違反了SOLID原則中的『S:單一職責原則』、『O:開放封閉原則』、『D:依賴反轉原則』,而且這樣子的設計,會增加該class中的複雜性,也會將裡面的logic變成黑箱,為了測試這樣子的class,可能得額外花費很多代價,測試程式也會變得複雜,且不容易維護。這邊指的測試程式不容易維護,是因為要測的class,太容易因為外在改變而改變,改變實體程式的頻率一旦變高,相對的測試程式就要跟著裡面的邏輯改變而改變。這也是為什麼TDD或單元測試很難推動的原因,因為大家認為撰寫與維護的成本過高,卻沒想到撰寫與維護成本,是根據設計的好壞來決定。

所以,透過interface或abstract來使用外部class的行為,我們就可以利用mock或stub的class(類似dynamic class或temp class)來實作或繼承外部元件,進而覆寫該行為,並將此mock/stub assign給我們要測試的實體程式。

我們的API使用的是inteface或abstract,而run time執行的是實作了interface或繼承abstract的子類別,這就是OO裡面的多型的目的。由於繼承後的子類別擁有父類別所有的屬性與行為,且可以自行定義屬於自己的行為,也就是透過override父類別的function,來達到屬於子類別自己的行為。

這樣有什麼用?當然有用,我們只需要定義一個叫做stub的class,去繼承與實作抽象介面,然後定義在要測試的實體API中被呼叫的方法,用到哪一個方法,我們就override該方法,然後return的值我們就可以自行透過程式碼來定義,高興的話,可以讓每一個被呼叫的方法都return null。這樣一來,就可以透過stub來模擬外部class的行為,去定義我們預期外部class會回傳的值,或是執行的狀態。

說了這麼多,我們來看看實際的例子:

Story

我們現在的場景是有人要來看醫生,會有人(maybe護士)去請現在值班的醫生來對病人進行醫治的動作。


Scenario

在我們預計的情況裡面,現在值班的醫生是Bill叔,Bill叔會根據病人的狀況,以及自己的喜好,來決定怎麼樣醫治病人。


Design

醫治的方式,我們定義成一個介面,因為別的醫生也有可能用這些個方式來醫治病人。
這邊我們簡單的舉出三種醫治方式 (感覺是繼承華山派,專治性飢渴和投錯胎有異曲同工之妙):

  1. 拿藥吃就好(藥)
  2. 需要醫生跟病人靈肉雙修(病人 , 醫生)
  3. 是要宰了他還是救他(是否宰了他 , 病人)

image


醫生的部分,我們定義為一個Abstract Class,簡單定義一個Name的屬性,以及針對單一病人和群體病人的醫療方式

image

接著我們來定義Bill叔,Bill叔的醫治行為,是我們本篇文章的重點,我們也會針對這個部分,來實際設計stub。
由於Bill叔在這邊扮演的是醫生,所以他應該要繼承AbstractDoctor,並且覆寫HealAllBody與HealBody兩個方法。

假設我們設計Bill叔在面對要醫治很多病人時,他的醫治行為是:

  1. 當病人超過3個人的時候,Bill叔會先去針對每個病人進行測試,如果病人體重超過60公斤,Bill叔就會生氣,一生氣就不想醫他,會想宰了病人。如果沒有生氣,Bill叔就會醫治該病人,也就是呼叫IHeal.KillOrHeal(生氣,病人)。
  2. 當病人沒有超過3個人的時候,Bill叔就會一個一個好好醫治。也就是呼叫HealBody的方法。
    1. 定義HealBody的方式
      1. 當病人是女生,而且年齡小於40時,則採取靈肉雙修的方式
      2. 其他情況,則看病人有沒有錢,有錢的話,就拿人參給他吃。沒錢就拿維他命給他吃。


根據這樣的需求,我們設計出這樣的Sequence diagram:

image


Test-Driven

根據上面的循序圖,我們可以先來撰寫我們的測試程式了,要測試的方法,是Bill叔的HealAllBody(IList<Patient> patients)。

  1. 我們可以看到,Bill叔的HealAllBody方法,裡面有用到一個外部class的行為,也就是HealWay: IHeal,所以我們要去定義一個stub class,實作IHeal,定義KillOrHeal方法要執行的動作
  2. assign給Bill.HealWay這個屬性
  3. 定義我們的病人,也就是我們的test case
  4. 呼叫Bill.HealAllBody(我們的test case)

接著讓我們來看實際的code,我們可以在各個方法中定義要執行的動作,可能記log,可能throw exception,如果是function,可以定義回傳什麼樣的資料回去。


    /// <summary>
    /// 用來模擬Bill裡,HealAllBody中,超過三個病人時,要使用到的IHeal裡面的method    
    /// </summary>
    public class StubKillOrHealOfIHeal : IHeal
    {

        #region IHeal 成員

        public void TakeMedicine(AbstractMedicine medicine)
        {
            throw new System.NotImplementedException();
        }

        public void OverlapBody(Patient patient, AbstractDoctor doctor)
        {
            throw new System.NotImplementedException();
        }

        public void KillOrHeal(bool isKill,Patient patient)
        {
            //do what you want, for example, record the parameter to log
            //if this is a function, then return your expect value. Now this is a stub to replace IHeal.KillOrHeal()
        }

接著設計我們的test case。


        /// <summary>
        /// 手動設計一個Stub來取代IHeal.KillOrHeal()
        /// </summary>
        [Test]
        public void StubManualReplaceKillOrHeal()
        {
            // Arrange
            Bill instance = new Bill();
            instance.HealWay = new StubKillOrHealOfIHeal();          

            Patient ArrayElement = new Patient();
            ArrayElement.Weight = 80;
            Patient ArrayElement1 = new Patient();
            ArrayElement1.Weight = 40;
            Patient ArrayElement2 = new Patient();
            ArrayElement2.Weight = 50;
            Patient ArrayElement3 = new Patient();
            ArrayElement3.Weight = 61;

            // Act
            instance.HealAllBody(new Patient[] {
						ArrayElement,
						ArrayElement1,
						ArrayElement2,
						ArrayElement3});

            // 使用Stub模擬IHeal的行為KillOrHeal,不會檢查輸入參數,直接定義預期回傳值(function)或是執行成功(void)
            // Assert
            Assert.Pass();
        }

可以看到,我們現在的test case是四個病人,體重分別為80,40,50,61。
並且我們只需要把我們模擬IHeal的stub class,assign給Bill,我們這個測試就算完成了。

接著我們用Visual Nunit跑跑看測試會不會過。

image

見鬼,居然過了!!我Bill的HealAllBody的內容還沒寫耶,怎麼會過?而且這樣我要怎麼知道,TestBody裡面回傳的值到底對不對?雖然TestBody是private function,但我仍然想知道它有沒正常運作啊。

沒錯,你沒眼花,這個結果是正常的,因為stub如同我們上面所定義的,它是繼承/實作了實際的API裡面public出來的抽象/介面,並且透過override來定義回傳與執行的情況。也就是參數不管是啥,反正都固定會執行或return裡面的內容。正因為如此,他可以直接定義外部class回傳的資料。但是,stub無法得知,當呼叫這個override的function時,丟進來的參數到底對不對。他也無法很直覺的定義,當被呼叫多次的時候,回傳或執行的情況要不一樣。

像這個時候,你就要來一下mock了。Mock可以驗證,當實際API被測試,呼叫我們自訂定義的method時,parameter跟我們預期的有沒有一樣,有一樣才return我們預期的資料或執行動作,不一樣的話則出現failed。而且,面對被呼叫多次的情況也不用擔心,因為我們可以定義,當被呼叫第二次、第三次…該丟進來的參數以及該return的值。Mock會幫我們記住方法被呼叫的次數以及順序,並且驗證parameter是否如同預期。

真這麼神奇?我們用code來做個示範給各位看,應該會比較有感覺。

首先改用mock來做測試程式:


        /// <summary>
        /// 當Bill叔遇到四個病人一起來看病的時候。
        /// 超過三個病人,就會根據每個病人的體重來決定救他還是殺他
        /// 四個病人體重分別為80,40,50,61,結果應為殺,救,救,殺
        /// </summary>
		[Test]
		public void HealAllBody_Patients_Collection()
		{

			// Arrange
			Bill instance = new Bill();
            DynamicMock HealWayMock= new DynamicMock(typeof(IHeal));

            Patient ArrayElement = new Patient();
            ArrayElement.Weight = 80;
            Patient ArrayElement1 = new Patient();
            ArrayElement1.Weight = 40;
            Patient ArrayElement2 = new Patient();
            ArrayElement2.Weight = 50;
            Patient ArrayElement3 = new Patient();
            ArrayElement3.Weight = 61;

            //第一次是個80公斤的胖子,Bill叔會生氣殺了他
            HealWayMock.Expect("KillOrHeal", true, ArrayElement);

            //第二次是個40公斤的瘦子,Bill叔會溫和地救他
            HealWayMock.Expect("KillOrHeal", false, ArrayElement1);

            //第三次是個50公斤的瘦子,Bill叔會溫和地救他
            HealWayMock.Expect("KillOrHeal", false, ArrayElement2);

            //第四次是個61公斤的胖子,Bill叔會生氣殺了他
            HealWayMock.Expect("KillOrHeal", true, ArrayElement3);
            
			instance.HealWay = (IHeal)HealWayMock.MockInstance;			

			// Act
			instance.HealAllBody(new Patient[] {
						ArrayElement,
						ArrayElement1,
						ArrayElement2,
						ArrayElement3});

			// 使用Mock測試中間的邏輯對不對,看Bill叔是heal patient,還是kill patient
            //如果第一個病人<60公斤,而傳入參數改為false,就會failed,因為Mock會檢查被呼叫時,參數是否與期望值符合
			// Assert
            Assert.Pass();
		}

可以看到mock跟stub不一樣的地方,我們需要去定義function的回傳值以及input參數。如果這是個function,會回傳東西,那就應該要用DynamicMock的ExpectAndReturn這個API。如果是void,就只需要用Expect即可。

因為我們的test case是四個病人,且體重分別為80,40,50,61,結果應該會是殺,救,救,殺。因為80公斤和61公斤會讓Bill叔生氣。
我們要驗證,如果病人>60公斤,就要宰了他,不到60公斤,我們就應該要救他。而且不能殺錯病人,殺錯病人的話,就要出現failed。

接著讓我們來看,我們實際Bill的function內容要怎麼撰寫:


    public class Bill: AbstractDoctor
    {
        public IHeal HealWay { get; set; }
        protected bool IsAngry { get; set; }

        private string _name="Bill";

        /// <summary>
        /// Initializes a new instance of the <see cref="Bill"/> class.
        /// 您北就是Bill。
        /// </summary>
        public Bill() 
        { 
            this.Name=_name;
        }


        /// <summary>
        /// Heals the body.
        /// 如果病人是女生,且不到40歲,Bill叔的治療方式是疊在一起
        /// 否則,看病人有沒有錢,有錢就拿人參給他吃,沒錢就拿維他命給他吃。
        /// </summary>
        /// <param name="patient">The patient.</param>
        public override void HealBody(Patient patient)
        {
            if (patient.IsGirl && patient.YearsOld < 40)
            {
                HealWay.OverlapBody(patient, this);
            }
            else
            {
                if (patient.IsRich)
                {
                    HealWay.TakeMedicine(new Ginseng());
                }
                else
                {
                    HealWay.TakeMedicine(new Vitamin());
                }
            }
        }

        /// <summary>
        /// Heals all bodys.
        /// 一次要看一掛病人,
        /// 如果病人超過三個,Bill叔會先測試一下病人,如果病人超過60公斤,Bill叔就會生氣。如果不超過60公斤,Bill叔就不生氣。
        /// Bill叔生氣,就會宰了那個病人。沒生氣,就會救那個病人。
        /// 
        /// 如果病人數目在三個以內,Bill叔就會一個一個醫。
        /// </summary>
        /// <param name="patients">The patients.</param>
        public override void HealAllBody(IList<Patient> patients)
        {
            if (patients.Count > 3)
            {
                foreach (Patient patient in patients)
                {
                    this.TestBody(patient);
                    HealWay.KillOrHeal(this.IsAngry, patient);
                }
            }
            else
            {
                foreach (Patient patient in patients)
                {
                    HealBody(patient);
                }
            }
            
        }

        /// <summary>
        /// Tests the body.
        /// 測試病人
        /// </summary>
        /// <param name="patient">The patient.</param>
        private void TestBody(Patient patient)
        {
            this.IsAngry = (patient.Weight > 60);
        }
    }

 接著我們看一下,測試的結果對不對:

image

結果是Pass的,代表一切符合預期。

接著我們來看,當測試程式寫錯的時候,會出現什麼樣的錯誤提示。

第一種情況:第二個病人,我們假設Bill叔要殺了他,但人家只有40公斤,這應該要救他才對。

    
                //第一次是個80公斤的胖子,Bill叔會生氣殺了他
                HealWayMock.Expect("KillOrHeal", true, ArrayElement);
    
                //第二次是個40公斤的瘦子,Bill叔會溫和地救他
               //結果卻預期要宰了他
                HealWayMock.Expect("KillOrHeal", true, ArrayElement1);

    測試結果:出現Failed:Expected: True, But Was:False。要救他,反而殺了他。
    image

    這就是mock可以幫我們透過黑箱測試TestBody是否正常運作。


第二種情況:第一個病人太胖,要宰了他,結果殺到第二個病人。


            Patient ArrayElement = new Patient();
            ArrayElement.Weight = 80;
            Patient ArrayElement1 = new Patient();
            ArrayElement1.Weight = 40;
            Patient ArrayElement2 = new Patient();
            ArrayElement2.Weight = 50;
            Patient ArrayElement3 = new Patient();
            ArrayElement3.Weight = 61;

            //第一次是個80公斤的胖子,Bill叔會生氣殺了他
            //殺成ArrayElement1了
            HealWayMock.Expect("KillOrHeal", true, ArrayElement1);

            //第二次是個40公斤的瘦子,Bill叔會溫和地救他
            HealWayMock.Expect("KillOrHeal", false, ArrayElement1);

測試結果:殺錯人啦~~~

image

第三種情況,也是最常發生的情況,就是測試程式沒錯,但是需求異動了。
Bill叔覺得60公斤的標準太鬆了,超過45公斤就要宰了,所以我們的TestBody改成『大於45公斤,Bill叔就會生氣』


        /// <summary>
        /// Tests the body.
        /// 測試病人
        /// </summary>
        /// <param name="patient">The patient.</param>
        private void TestBody(Patient patient)
        {
            this.IsAngry = (patient.Weight > 45);
        }

 

 

測試結果:原本期望第三個病人應該不會惹Bill叔生氣的,但是實際結果Bill叔生氣了,想要宰了他。

image

如果是實際邏輯面的修改,我們可以看到之前的單元測試程式,可以保護我們知道有哪一些我們原本預期的狀況,因為需求異動而跟著改變。
這樣的改變,有可能是合理的,有可能是這些程式都要跟著異動,才能符合新的需求。
這樣的改變,也有可能是不合理的,也就是修改了這個method,導致很多其他原本可以正常運作的function死掉了。就得再回頭評估,是不是改錯地方了。

至於,因為需求異動,而要導致修改單元測試的程式,會不會是很大的effort ? 如同文章一開始提到的,當設計是符合好的OO原則時,單元測試的撰寫與維護,是相當輕鬆的。因為很多時候,是不需要維護的,而是重新撰寫測試程式。因為與其維護,重新撰寫可能會更快。如果設計就是高耦合、低內聚,裡面複雜度又高到不行,那不管是撰寫、維護和重寫,都會是不小的effort。而且這個effort不只在於測試程式,包括實際要執行的程式,在維護上也會是個大trouble。


結論

單元測試就像鋪鐵軌或造公路/造橋一樣,絕對是需要花額外的成本的,但這個成本是有形且得以控制的。沒有單元測試,當需求異動或需要重構的時候,那個維護的風險是無形且不堪負荷的。

而TDD,是一個好的開發方式,並不是一個測試的方式。以測試程式為基底,來規劃出最精準、耦合性最低的程式該如何撰寫,且可以保證撰寫出來的程式,目標是對的,方向是對的。

一旦發生bug,該bug就是我們的test case,將我們的test case加進測試程式後,就可以知道是不是這個function造成的bug,也就是與預期不符的情況。這也是為什麼測試程式應該是交付程式以及code review的標準之一。一份程式要交出來,當然要證明這份程式是對的。沒有環境、沒有資料,都不應該是交付一個不能測試、不能驗證程式的藉口。

而身為一個SA與SD,應該要能告訴developer,除了需求以外,我們的test case為何,期望的output為何。
如果連這都無法提供,那這個功能,從一開始就是個笑話,就是盲羊走在懸崖上,SA或測試人員,根本也不知道所謂的對錯,又如何能分辨程式的結果是對還是錯。

希望透過上面的例子,大家可以知道stub的原理是怎麼設計的,而什麼樣的情況下,要使用到mock,mock可以額外帶來什麼樣的好處。單元測試可以防止什麼樣的風險與錯誤發生,而這些問題在我們眼前的系統開發上,卻不斷不斷的在發生。

 [註1]Source code包含了從病人去看病的Story,下載位置:http://cid-ce86d112fa702d7b.office.live.com/self.aspx/.Public/QuickUnitDemo%20v2.zip

 [註2]參考:MartinFowler:Mocks Aren't Stubs 

 


blog 與課程更新內容,請前往新站位置:http://tdd.best/