[單元測試]為什麼mock的method無法驗證parameter為object的情況

  • 4845
  • 0

[單元測試]為什麼mock的method無法驗證parameter為object的情況

前言

首先,標題是有問題的,mock不是不能驗證parameter為object,而是使用的人懂不懂mock是怎麼去驗證run time的actual class去invoke mock object的method時,『實際的參數object』與『期望的參數object』是不是『同一份』。

還不知道mock可以做什麼的,可以參考前一份文章:
[單元測試]Stub的原理 & Why we need mock rather than stub,透過mock可以模擬使用外部class的method,驗證並定義輸入的參數以及回傳的資料。


Problem

然而今天碰到了個問題,是有一個mock的expect method,怎麼測parameter都過不了,而錯誤訊息一整個很模糊,錯誤訊息長的像這樣:

image

Failure:   Expected: <MyFirstService.Domain.Person> But was:  <MyFirstService.Domain.Person>

啊我明明兩個Person的所有值就都一樣啊,為啥一直過不了? 沒錯,因為是用object跟object一起比較的,比較的不是值,而是整個物件是不是同一份,也就是類似比較Reference,記憶體位置跟值有沒一模一樣。

知道了比較的方法,我們來找,那程式碼發生的原因點在哪。

我們的程式很簡單,我們先定義一個Person的class,擁有ID與Money的屬性:


    public class Person
    {
        public string ID { get; set; }
        public int Money { get; set; } 
    }


接著看我們的funcion:


        public IPrepareRetireLife MyOutterInstance { get; set; }

        /// <summary>
        /// Tests the mock parater.
        /// 測試Mock的class invoke method時,parameter為Person物件
        /// </summary>
        /// <param name="number">The number.</param>
        public void TestMockParater(int number)
        {
            Person first=new Person { ID = "Joey", Money = 0 };
            Person second=new Person { ID = "Jed", Money = 10000 };
            if (number % 2 == 0)
            {              
                MyOutterInstance.SaveMoney(first);
            }
            else
            {                
                MyOutterInstance.SaveMoney(second);
            }
        }

 

 

可以看到,當呼叫actual class的TestMockParater function時,我們會根據參數number是否為偶數,來決定要SaveMoney的參數為firstPerson還是secondPerson。這樣的程式,我想到處可見,例如new一個dto物件,將dto相關的屬性值都insert到DB裡面,就很常碰到這樣的例子,尤其是在貧血的domain model中更容易出現。

而我們的測試程式長怎樣?我們來看看:


        /// <summary>
        /// mock的參數為Person,測試會失敗
        /// </summary>
        [Test]
        public void TestMockParaterWithPersonObject()
        {
            #region test case
            Person first=new Person { ID = "Joey", Money = 0 };
            Person second=new Person { ID = "Jed", Money = 10000 };

            int myInputInstanceParameter=2;
            #endregion
         
            //Arrange
            DiscussMockParameterWithObject instance = new DiscussMockParameterWithObject();

            DynamicMock mockDaoA= new DynamicMock(typeof(IPrepareRetireLife));
            mockDaoA.Expect("SaveMoney",first);
            instance.MyOutterInstance = (IPrepareRetireLife)mockDaoA.MockInstance;

            //Act
            instance.TestMockParater(myInputInstanceParameter);

            //Assert
            mockDaoA.Verify();

        }

我們用了一模一樣的firstPerson跟secondPerson,來預期當我的myInputInstanceParameter為偶數的時候,我的mock應該是mockDaoA.Expect("SaveMoney",first);

但是不幸的事發生了,這個test case會發生failed,原因就在expect firstPerson與actual firstPerson不相等。

image 

為什麼?沒錯,就因為比較的是Reference,我們expect firstPerson是在測試程式裡面new的,而actual firstPerson是在actual class裡的TestMockParameter的function裡面new的,這兩份當然不一樣,當然測試結果就是failed了。

到這邊,大家應該知道,問題點的根本原因,發生在哪了。沒錯,就是在實際的function裡面去new了Person這個instance。這是一個很糟糕的寫法,讓我的TestDiscussMockParameterWithObject的class與Person發生了高耦合,也違反了Open-Close Principle,當我未來修改Person class時,也可能會影響到TestDiscussMockParameterWithObject的行為或結果。


Solution

我們要怎麼解決在actual class裡面new object的問題,這個時候,就感受到factory method的好處了。Factory pattern不是就是用來解決new的問題嗎?這樣就夠了嗎?還沒還沒,我們還要解決mock的問題,這個new的動作,應該要透過interface,並且要能由外部指定給actual class,我們才能夠透過mock來指定factory method所return的new instance。

我們來看看,改良之後的code長怎樣 (方便起見,我仍保留有錯誤的code來做比對):


namespace MyFirstService.Domain
{
    public  class DiscussMockParameterWithObject
    {
        public IPrepareRetireLife MyOutterInstance { get; set; }
        public IMyFactory  MyFactory { get; set; }
        /// <summary>
        /// Tests the mock parater.
        /// 測試Mock的class invoke method時,parameter為Person物件
        /// </summary>
        /// <param name="number">The number.</param>
        public void TestMockParater(int number)
        {
            Person first=new Person { ID = "Joey", Money = 0 };
            Person second=new Person { ID = "Jed", Money = 10000 };
            if (number % 2 == 0)
            {              
                MyOutterInstance.SaveMoney(first);
            }
            else
            {                
                MyOutterInstance.SaveMoney(second);
            }
        }

        /// <summary>
        /// Tests the mock parater value.
        /// 測試Mock的class invoke method時,parameter為int
        /// </summary>
        /// <param name="number">The number.</param>
        public void TestMockParaterValue(int number)
        {
            Person first=new Person { ID = "Joey", Money = 0 };
            Person second=new Person { ID = "Jed", Money = 10000 };
            if (number % 2 == 0)
            {
                MyOutterInstance.SaveMoney(first.Money);
            }
            else
            {
                MyOutterInstance.SaveMoney(second.Money);
            }
        }

        /// <summary>        
        /// 測試Mock的class invoke method時,parameter為int
        /// </summary>
        /// <param name="number">The number.</param>
        public void TestMockParaterByFactoryGenerateObject(int number)
        {

            Person first=MyFactory.CreateNewPerson("Joey",0);
            Person second=MyFactory.CreateNewPerson("Jed", 10000);
            
            if (number % 2 == 0)
            {
                MyOutterInstance.SaveMoney(first);
            }
            else
            {
                MyOutterInstance.SaveMoney(second);
            }
        }

    }

    public class Person
    {
        public string ID { get; set; }
        public int Money { get; set; } 
    }

    public interface IPrepareRetireLife
    {
        void SaveMoney(Person p);
        void SaveMoney(int money);
    }

    public interface IMyFactory
    {
        Person CreateNewPerson(string id,int money);
    }
}
  1. 我們新增了一個interface叫做IPrepareRetireLife,裡面有兩個method,都叫做SaveMoney,但一個參數是Person,一個是int。參數型別為Person的部分,就是我們要努力克服的問題,而參數為int的部分,只是對照組,證明byVal的比對其實沒這些問題。
  2. 我們新增了一個Factory的interface,裡面有一個function叫做CreateNewPerosn會回傳Person。
    接著把我們原本new Person()的部分,改用CreatePerson()來取代。
  3. 在我們的actual class上public一個property為IMyFactory,以便mock可以作業,以及未來可以透過abstract factory pattern可以擴充。


接著我們可以看到TestMockParaterByFactoryGenerateObject這個function裡面,所使用到的firstPerson與secondPerson就是透過IMyFactory所產生的class instance。

我們actual class中function的邏輯一模一樣,差異點只有new Person的方式不一樣,一個直接在function中下new Person(),一個則是透過interface的CreatePerson來return一個Person instance。

來看看我們的單元測試長怎樣:


        /// <summary>
        /// 這就是為什麼在actual class的function中,用new object()無法透過mock做驗證的原因
        /// 因為Equal會去比較在記憶體中是否為同一個區塊
        /// </summary>
        [Test]
        public void TestNewConstruct()
        {

            Person first=new Person { ID = "Joey", Money = 0 };
            Person second=new Person { ID = "Joey", Money = 0 };

            Assert.AreEqual(first, second);
            
        }

        /// <summary>
        /// mock的參數為Person,測試會失敗
        /// </summary>
        [Test]
        public void TestMockParaterWithPersonObject()
        {
            #region test case
            Person first=new Person { ID = "Joey", Money = 0 };
            Person second=new Person { ID = "Jed", Money = 10000 };

            int myInputInstanceParameter=2;
            #endregion
         
            //Arrange
            DiscussMockParameterWithObject instance = new DiscussMockParameterWithObject();

            DynamicMock mockDaoA= new DynamicMock(typeof(IPrepareRetireLife));
            mockDaoA.Expect("SaveMoney",first);
            instance.MyOutterInstance = (IPrepareRetireLife)mockDaoA.MockInstance;

            //Act
            instance.TestMockParater(myInputInstanceParameter);

            //Assert
            mockDaoA.Verify();

        }
        
        /// <summary>        
        /// mock的參數為Person.Money,測試會過
        /// </summary>
        [Test]
        public void TestMockParaterValue()
        {
            #region test case
            Person first=new Person { ID = "Joey", Money = 0 };
            Person second=new Person { ID = "Jed", Money = 10000 };

            int myInputInstanceParameter=2;
            #endregion

            //Arrange
            DiscussMockParameterWithObject instance = new DiscussMockParameterWithObject();
            DynamicMock mockDaoA= new DynamicMock(typeof(IPrepareRetireLife));

            mockDaoA.Expect("SaveMoney", first.Money);
            instance.MyOutterInstance = (IPrepareRetireLife)mockDaoA.MockInstance;

            //Act
            instance.TestMockParaterValue(myInputInstanceParameter);

            //Assert
            mockDaoA.Verify();

        }


        /// <summary>        
        /// mock的參數為factory method,被mock回傳的Person,再透過這個Person當作DaoA.SaveMoney的參數,就不會有問題了
        /// </summary>
        [Test]
        public void TestMockParaterByFactoryGenerateObject()
        {
            #region test case
            Person first=new Person { ID = "Joey", Money = 0 };
            Person second=new Person { ID = "Jed", Money = 10000 };

            int myInputInstanceParameter=2;
            #endregion
            
            //Arragne
            DiscussMockParameterWithObject instance = new DiscussMockParameterWithObject();
            
            DynamicMock mockMyFactory= new DynamicMock(typeof(IMyFactory));
            mockMyFactory.ExpectAndReturn("CreateNewPerson", first,"Joey",0 );
            mockMyFactory.ExpectAndReturn("CreateNewPerson", second, "Jed", 10000);

            DynamicMock mockDaoA= new DynamicMock(typeof(IPrepareRetireLife));
            mockDaoA.Expect("SaveMoney", first);

            instance.MyOutterInstance = (IPrepareRetireLife)mockDaoA.MockInstance;
            instance.MyFactory = (IMyFactory)mockMyFactory.MockInstance;

            //Act
            instance.TestMockParaterByFactoryGenerateObject(myInputInstanceParameter);

            //Assert
            mockDaoA.Verify();

        }

我們把精神focus在最後一個method,也就是TestMockParaterByFactoryGenerateObject()上面:

  1. 我們要mock IMyFactory進去actual class中
  2. 定義IMyFactory.CreatePerson的回傳值Person
  3. 當actual class的function parameter為偶數時,則IPrepareRetireLife.SaveMoney的參數,應為剛剛IMyFactory.CreatePerson回傳之firstPerson


透過mock abstract factory所return的firstPerson,我們可以確保都是同一份object,就可以進行mock的verify。

image


Visual Nunit的執行結果:

image


結論

可以的話,應盡量避免在function中使用class的方式,直接使用new class() (除了factory method),這樣子的設計會導致此function甚至class與外界class耦合性過高,被new的class並不會知道被誰使用,當需求變更想修改被new起來使用的class時,就可能因為我們這邊的actual class,而導致無法修正,或是需要進行大幅的修正,而不符合開放封閉原則。

當然大家會有很深的怨念,當我已經有一堆程式裡面使用new class()的方式,該怎麼辦?難道就不做單元測試嗎?當然不是,第一種作法是治標的,也是快速的方式,就是不要用mock,只用stub來做,當然少了很多保障,但還是至少有一層保障可以讓測試進行下去。第二種方法,就是當用stub快速的寫好一份測試程式之後,就可以開始進行重構,將new的動作改由factory class來做,然後再寫一份mock的unit test,這樣refacotring完的結果,stub與mock的unit test都應該pass才對。

[註]這邊的factory method只是很簡陋的做個示範而已,實際的design pattern當然更漂亮,大家可以自行survey後implement到自己的系統中。

 

 

 

 

 

 

 


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