本章從更宏觀的角度審視隔離框架的世界,探討為何不同框架具有不同的能力、如何區分受限與不受限框架,以及選擇框架時應重視哪些價值與特性。
受限與不受限框架#
隔離框架依據其在程式語言層面能做到的事情,分為兩大類:受限框架(Constrained) 與 不受限框架(Unconstrained)。
受限框架#
受限框架(Constrained frameworks)在 .NET 中包括 Rhino Mocks、Moq、NMock、EasyMock、NSubstitute、FakeItEasy;在 Java 中包括 jMock 和 EasyMock。
之所以稱為「受限」,是因為這些框架有些事情做不到。其限制取決於所在平台以及它們使用平台的方式。
- 在 .NET 中,受限框架無法偽造靜態方法、非虛擬方法、非公開方法等
- 受限框架的運作方式與手寫 fake 相同——在執行期間產生並編譯程式碼,繼承介面或覆寫基底類別
- 因此,它們受到與一般程式碼相同的編譯器規則約束:要偽造的程式碼必須是 public、可繼承(非 sealed)、具有公開建構子,或是介面;基底類別的方法需要是 virtual 才能被覆寫
使用受限框架時,靜態方法、私有方法、sealed 類別、沒有公開建構子的類別等,都無法被偽造。你基本上受限於與一般程式碼相同的編譯器規則。
不受限框架#
不受限框架(Unconstrained frameworks)在 .NET 中包括 Typemock Isolator、JustMock、Moles(MS Fakes);在 Java 中包括 PowerMock 和 JMockit;在 C++ 中包括 Isolator++ 和 Hippo Mocks。
不受限框架不會在執行期間產生繼承自其他程式碼的程式碼,而是透過其他手段來達成偽造目的。
Profiler-based 框架的運作原理#
在 .NET 中,所有不受限框架都是基於 profiling API 運作的。它們使用一組包裹在 CLR(Common Language Runtime)執行實例周圍的 unmanaged API,稱為 profiling APIs。
這些 API 提供了以下能力:
- 對 CLR 執行過程中發生的任何事件進行掛鉤(hook),包括靜態方法、私有建構子,甚至第三方程式碼
- 在 .NET IL 程式碼被編譯成二進位碼之前,注入新的 IL 程式碼到記憶體中
- 攔截 JIT(Just-in-time)編譯過程,在方法前後插入 IL 標頭(headers),加入行為檢查的邏輯
關鍵的 COM 介面成員包括 JitCompilationStarted 和 SetILFunctionBody,它們允許在執行期間取得並修改即將被編譯的 IL 程式碼。
啟用 profiling 需要設定環境變數
Cor_Enable_Profiling=0x1與COR_PROFILER=SOME_GUID,且同一時間只能附加一個 profiler。Typemock、JustMock 和 Moles 等框架都有 Visual Studio 外掛或命令列工具來自動處理這些設定。
不受限框架的優缺點#
優點:
- 可以為原本不可測試的程式碼撰寫單元測試,無需觸碰或重構生產程式碼即可隔離依賴
- 可以偽造無法控制的第三方系統(如 SharePoint、CRM、Entity Framework 等)
- 可以自由選擇設計層級,不被工具強制採用特定模式
缺點:
- 若不謹慎使用,可能偽造不必要的東西而忽略了應該從更高層次看待工作單元
- 若偽造了不屬於你的 API,部分測試可能變得難以維護(但實務上,越底層的 API 越穩定,改變的機率越低)
基於 profiler 的框架會帶來一定的效能損耗,因為它們在每一步都會對程式碼加入額外呼叫。當測試數量增加到數百個後,速度差異會變得明顯。但相較於能夠偽造和測試遺留程式碼的巨大好處,這是值得付出的小代價。
各框架能力比較#
雖然所有 profiler-based 框架理論上擁有相同的底層能力,但實際上各框架實作的功能子集不同:
- Typemock Isolator:歷史最悠久,支援幾乎所有遺留程式碼測試情境,包括未來物件、靜態建構子等。唯一缺少的是 mscorlib.dll 中部分核心 API(如 DateTime、System.String、System.IO)的偽造
- MS Fakes(Moles):由微軟內部開發,對部分未公開的 profiling API 有更深入的存取,可偽造某些 Typemock 無法偽造的類型;但 API 較為有限,主要只允許用委派替換公開方法
- JustMock:能力接近 Typemock Isolator,但在靜態建構子和私有方法偽造等方面仍有些不足
好的隔離框架的價值#
近年來,.NET 和 Java 出現了新一代的隔離框架(如 Typemock Isolator、NSubstitute、FakeItEasy),它們在可讀性、易用性和簡潔性方面做出了巨大改善。最重要的是,它們支援測試的長期穩健性。
好的隔離框架應具備兩大核心價值:
- Future-proofing(面向未來):測試只會因為正確的原因、在生產程式碼發生重大變更時才失敗
- Usability(易用性):框架容易理解和使用
支援未來擴展與易用性的功能#
以下是支持測試穩健性與框架易用性的幾項關鍵特性:
Recursive Fakes(遞迴偽造)#
遞迴偽造是指當 fake 物件的方法回傳另一個物件時,該回傳物件自動也是 fake 的,而且這個行為會遞迴地持續下去。
public interface IPerson
{
IPerson GetManager();
}
[Test]
public void RecursiveFakes_work()
{
IPerson p = Substitute.For<IPerson>();
Assert.IsNotNull(p.GetManager());
Assert.IsNotNull(p.GetManager().GetManager());
Assert.IsNotNull(p.GetManager().GetManager().GetManager());
}這項功能的重要性在於:你不需要為每個具體的 API 設定 fake 行為,測試與實際生產程式碼的耦合度就越低,未來生產程式碼變更時需要修改測試的機會也越少。
並非所有隔離框架都支援遞迴偽造。受限框架只能對可被覆寫的函式(虛擬方法或介面成員)支援遞迴偽造。
Ignored Arguments by Default(預設忽略參數)#
在大多數框架中,你傳入行為設定或驗證 API 的任何參數值都會被當作預設的預期值。這意味著你需要在所有方法中加入 Arg.IsAny<Type> 這類泛型語法,降低了可讀性。
而 Typemock Isolator 預設會忽略你傳入的參數值,除非你在 API 呼叫中明確指定關心的參數。例如:
Isolate.WhenCalled(() => stubLogger.Write(""))
.WillThrow(new Exception("Fake"));這種設計省去了不必要的型別標注,大幅提升可讀性。
Wide Faking(廣域偽造)#
廣域偽造是指一次偽造多個方法的能力。在某種程度上,遞迴偽造是這個概念的子功能,但也有其他實作方式。
// FakeItEasy:讓某物件的所有方法都拋出例外
A.CallTo(foo).Throws(new Exception());
// FakeItEasy:只讓回傳特定型別的方法回傳假值
A.CallTo(foo).WithReturnType<string>().Returns("hello world");
// Typemock:讓某型別的所有靜態方法都回傳假值
Isolate.Fake.StaticMethods(typeof(HttpRuntime));這項功能對測試的長期可維護性很有幫助——六個月後新增並被生產程式碼使用的方法,會自動被所有既有測試偽造,不需要修改測試。
Nonstrict Behavior of Fakes(非嚴格行為)#
在嚴格模式下,fake 的方法只有在你事先設定為「預期」時才能被成功呼叫。任何未預期的呼叫(方法名稱不同、參數值不同)都會導致測試拋出例外而失敗。
嚴格模式的問題在於:當你不在意工作單元內部物件之間的內部協議時,卻因為某個內部方法的非預期呼叫而導致測試失敗,這使測試變得脆弱且過度耦合於實作細節。
非嚴格模式則允許 fake 物件接受任何呼叫,即使該呼叫沒有被預期。對於回傳值的方法,會回傳預設值(值型別的預設值或 null)。
大多數情況下,非嚴格 mock 能產生更不脆弱的測試。你不在意其他什麼呼叫發生了,只關心你要驗證的特定互動。NSubstitute 和 FakeItEasy 天生就是非嚴格的,不具備「預期方法被呼叫」的概念。
Nonstrict Mocks(非嚴格 Mock)#
非嚴格 mock 物件會允許任何呼叫,即使該呼叫不在預期之中。在更進階的框架中,還結合了遞迴偽造的概念:一個回傳物件的 fake 方法會預設回傳另一個 fake 物件,該 fake 物件的方法也會回傳 fake 物件,以此遞迴。
搭配 argument matcher 而非完整的字串比對,可以讓測試更加穩健、面向未來。但要注意的是,使用 argument matcher 有時會讓測試語法變得較為醜陋。
隔離框架設計反模式#
以下是在現有框架中常見的設計反模式:
Concept Confusion(概念混淆)#
作者稱之為 mock 過量症(mock overdose)。問題在於當框架不區分 mock 和 stub 時,你需要自行判斷測試中有多少個 mock 和 stub,而每個測試中應該只有一個 mock。
以 Moq 為例,無論建立的是 stub 還是 mock,語法都是 new Mock<T>(),這會讓讀者搞不清楚哪個是真正用來驗證的 mock、哪個只是提供假資料的 stub。
避免概念混淆的方法:
- 在 API 中使用明確的 mock 和 stub 詞彙(如 Rhino Mocks)
- 不使用 mock 和 stub 這兩個詞,改用通用術語(如 FakeItEasy 使用
Fake<T>、NSubstitute 使用Substitute<T>、Typemock Isolator 使用Isolate.Fake.Instance<T>) - 若框架不區分,至少在變數命名上使用
mockXXX和stubXXX來增加可讀性
Record and Replay(錄製與重播)#
錄製與重播風格的隔離框架會造成可讀性低落。讀者需要在測試程式碼中反覆上下翻閱,才能理解發生了什麼事。
這種風格的程式碼通常分成 using(_mocks.Record()) 和 using(_mocks.Playback()) 兩個區塊,在 Record 區塊中混合了 Arrange 和 Assert 的設定,在 Playback 區塊中才執行實際動作。
相比之下,支援 AAA(Arrange-Act-Assert) 風格的框架(如 Moq、NSubstitute、FakeItEasy)讓測試結構清晰、直觀,大幅提升可讀性。
Sticky Behavior(黏性行為)#
當你設定一個 fake 方法以某種方式行為(例如回傳 false),下次在生產程式碼中被呼叫時會怎樣?如果框架的 fake 行為設計為只觸發一次,那麼每次生產程式碼變更導致額外呼叫該方法時,你都必須提供新的行為設定,測試就會與內部實作細節過度耦合。
好的隔離框架應該為行為加上預設的黏性:一旦你設定方法以某種方式行為,它就會永遠保持那樣,即使被呼叫 100 次。這讓測試不需要知道方法在目前測試中會被呼叫幾次。
Complex Syntax(複雜語法)#
有些框架即使用了一段時間,要記住如何執行標準操作仍然很困難,這增加了編碼的摩擦。好的框架應該讓 API 設計簡單、可發現。
FakeItEasy 是一個好例子:所有可能的操作都以大寫 A 開頭,只要記住這一個入口點,就能透過 IDE 的 IntelliSense 找到所有可用操作。
// 建立 fake
var lollipop = A.Fake<ICandy>();
var shop = A.Fake<ICandyShop>();
// 設定方法行為
A.CallTo(() => shop.GetTopSellingCandy()).Returns(lollipop);
// 使用 argument matcher
A.CallTo(() => foo.Bar(A<string>.Ignored, "second argument"))
.Throws(new Exception());
// 驗證方法呼叫
A.CallTo(() => shop.BuyCandy(lollipop)).MustHaveHappened();Typemock Isolator 也採用類似概念,所有 API 呼叫都以 Isolate 開頭。NSubstitute 則需要記住使用 Substitute 來建立 fake,使用擴充方法來驗證或改變行為,使用 Arg<T> 來匹配參數。
總結#
- 隔離框架分為受限(constrained)和不受限(unconstrained)兩大類,取決於平台能力,框架可擁有不同的偽造能力
- 在 .NET 中,不受限框架使用 profiling API,受限框架則在執行期間產生並編譯程式碼(與手寫 mock/stub 相同)
- 好的隔離框架應具備 future-proofing 和 usability 兩大價值,支援遞迴偽造、預設忽略參數、廣域偽造、非嚴格行為等特性
- 應避免的設計反模式包括:概念混淆、錄製與重播、非黏性行為、複雜語法
- 選擇隔離框架時,不僅是選擇一套 API,更是選擇一組能力與限制