[單元測試]為什麼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都過不了,而錯誤訊息一整個很模糊,錯誤訊息長的像這樣:
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不相等。
為什麼?沒錯,就因為比較的是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);
}
}
- 我們新增了一個interface叫做IPrepareRetireLife,裡面有兩個method,都叫做SaveMoney,但一個參數是Person,一個是int。參數型別為Person的部分,就是我們要努力克服的問題,而參數為int的部分,只是對照組,證明byVal的比對其實沒這些問題。
- 我們新增了一個Factory的interface,裡面有一個function叫做CreateNewPerosn會回傳Person。
接著把我們原本new Person()的部分,改用CreatePerson()來取代。 - 在我們的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()上面:
- 我們要mock IMyFactory進去actual class中
- 定義IMyFactory.CreatePerson的回傳值Person
- 當actual class的function parameter為偶數時,則IPrepareRetireLife.SaveMoney的參數,應為剛剛IMyFactory.CreatePerson回傳之firstPerson
透過mock abstract factory所return的firstPerson,我們可以確保都是同一份object,就可以進行mock的verify。
Visual Nunit的執行結果:
結論
可以的話,應盡量避免在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/