[30天快速上手TDD][Day 7]Unit Test - Stub, Mock, Fake 簡介
前言
在上一篇文章中,說明了有哪些方式可以對目標物件進行獨立測試,隔絕目標物件與外部的相依性。
這篇文章則要簡介一下,如何透過 mock framework,來輔助我們更便利地模擬目標物件的相依物件,而不必手工刻一堆只為了這次測試而存在的輔助類別。
而模擬目標物件的部分,常見的有 stub object, mock object, fake object,本篇文章也會簡單介紹一下三者的不同點,並且透過實例,幫助讀者快速的 pick up 實戰經驗。
安裝與範例說明
本篇文章的範例,使用 VS2012 為開發工具,mock framework 則是使用 Rhino.Mocks,物件設計透過 IoC 的方式,由建構式來傳入 stub/mock/fake object。
註:在 Microsoft Fakes 裡面也有內建的 stub object,但是是類似 fake object 的方式產生,而非 Rhino.Mocks, moq 這類 mock framework 是使用動態產生 stub/mock object的方式。Stub 與 fake 的使用與比較,也可以參考這一篇文章:Isolating Code under Test with Microsoft Fakes
讀者可透過 NuGet 安裝 Rhino.Mocks,如下圖所示:
先介紹一下範例的 production code 的意義為何,針對 Pub 物件,描述如下:
- 效益:顧客入場時,幫助店員統計出門票收入,確認是否核帳正確
- 角色:Pub 店員
- 目的:根據顧客與相關條件,算出對應的門票收入總值
先來看看程式碼:
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
使用時機:
在[30天快速上手TDD][Day 2]Unit Testing 簡介中,提到了驗證物件行為是否符合預期有三種方式。Stub 通常使用在驗證目標回傳值,以及驗證目標物件狀態的改變。
這兩種驗證方式的重點,都擺在目標物件自身的邏輯。
意即測試目標物件時,並不在乎目標物件與外部相依物件如何互動,關注在當外部相依物件回傳什麼樣的資料時,會導致目標物件內部的狀態或邏輯變化。
所以這類的驗證方式,是透過 stub object 直接模擬外部相依物件回傳的資料,來驗證目標物件行為是否如同預期。
範例:
第一個測試,是驗證收費人數是否符合預期,程式碼如下:
[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 相當簡單,步驟如下:
- 透過
MockRepository.GenerateStub<T>()
,來建立某一個 T 型別的 stub object,以上面例子來說,是建立 ICheckInFee 介面的實作子類。 - 把該 stub object 透過建構式,設定給測試目標物件。
- 定義當呼叫到該 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
使用時機:
上面提到驗證物件的第三種方式:「驗證目標物件與外部相依介面的互動方式」,如下圖所示:
這聽起來可能相當抽象,但在實務上,的確可能會碰到這樣的測試需求。
Mock 的驗證比起 stub 要複雜許多,變動性通常也會大一點,但往往在驗證一些 void 的行為會使用到,例如:在某個條件發生時,要記錄 Log。這種情境,用 stub 就很難驗證,因為對目標物件來說,沒有回傳值,也沒有狀態變化,就只能透過 mock object 來驗證,目標物件是否正確的與Log 介面進行互動。
範例:
以這個範例來說,我們想驗證的是:在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
使用時機:
當目標物件使用到靜態方法,或 .net framework 本身的物件,甚至於針對一般直接相依的物件,我們都可以透過 fake object 的方式,直接模擬相依物件的行為。
範例:
以這例子來說,假設 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);
}
}
說明如下:
- 在
using (ShimsContext.Create()){}
的範圍中,會使用 Fake 組件。 - 當在 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人。
補充:
連 System.dll 都可以進行 fake object 模擬了,所以即使是我們自訂的 class,直接相依,也可以透過這種方式來模擬。
這樣一來,即便是直接相依的物件,也可以進行獨立測試了。
但強烈建議,針對自訂物件的部分,這是黑魔法類型的作法,如果沒有包袱,建議物件設計還是要採 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 原生組件。
也請讀者朋友務必記得幾個基本原則:
- 同一測試案例中,請避免 stub 與 mock 在同一個案例一起驗證。原因就如同一直在強調的單元測試準則,一次只驗證一件事。而 stub 與 mock 的用途本就不同,stub 是用來輔助驗證回傳值或目標物件狀態,而 mock 是用來驗證目標物件與相依物件互動的情況是否符合預期。既然八竿子打不著,又怎麼會在同一個測試案例中,驗證這兩個完全不同的情況呢?
- Mock 的驗證可以相當複雜,但越複雜代表維護成本越高,代表越容易因為需求異動而改變。所以,請謹慎使用 mock,更甚至於當發生問題時,針對問題的測試案例才增加 mock 的測試,筆者都認為是合情合理的。
- 當要測試一個目標物件,要 stub/mock/fake 的 object 太多時,請務必思考目標物件的設計是否出現問題,是否與太多細節耦合,是否可將這些細節職責合併。
- 當測試程式寫的一狗票落落長時,請確認目標物件的職責是否太肥,或是方法內容太長。這都是因為目標物件設計不良,導致測試程式不容易撰寫或維護的情況。問題根源在目標物件的設計品質。
- 請將測試程式當作 production code 的一部份,production code 中不該出現的壞味道,一樣不該出現在測試程式中,尤其是重複的程式碼。所以測試程式,基本上也需要進行重構。但也請務必提醒自己,測試程式基本上不會包含邏輯,因為包含了邏輯,您就應該再寫一段測試程式,來測這個測試程式是否符合預期。
Reference
- Microsoft Fakes 入門 (跟煥麟老師的這篇文交叉參考一下 :P)
blog 與課程更新內容,請前往新站位置:http://tdd.best/