本章從更宏觀的角度審視隔離框架的世界,探討為何不同框架具有不同的能力、如何區分受限與不受限框架,以及選擇框架時應重視哪些價值與特性。

受限與不受限框架#

隔離框架依據其在程式語言層面能做到的事情,分為兩大類:受限框架(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 介面成員包括 JitCompilationStartedSetILFunctionBody,它們允許在執行期間取得並修改即將被編譯的 IL 程式碼。

啟用 profiling 需要設定環境變數 Cor_Enable_Profiling=0x1COR_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 中使用明確的 mockstub 詞彙(如 Rhino Mocks)
  • 不使用 mock 和 stub 這兩個詞,改用通用術語(如 FakeItEasy 使用 Fake<T>、NSubstitute 使用 Substitute<T>、Typemock Isolator 使用 Isolate.Fake.Instance<T>
  • 若框架不區分,至少在變數命名上使用 mockXXXstubXXX 來增加可讀性

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-proofingusability 兩大價值,支援遞迴偽造、預設忽略參數、廣域偽造、非嚴格行為等特性
  • 應避免的設計反模式包括:概念混淆、錄製與重播、非黏性行為、複雜語法
  • 選擇隔離框架時,不僅是選擇一套 API,更是選擇一組能力與限制