本章介紹隔離框架(isolation framework)的概念與實務應用。隔離框架是一種可重複使用的函式庫,能在執行期間動態建立和設定 fake 物件,取代手動撰寫 stub 和 mock 的繁瑣工作。作者以 NSubstitute(簡稱 NSub)為範例,逐步展示如何用框架建立動態 stub 與 mock,並討論使用框架時應注意的優點與陷阱。
為什麼使用隔離框架#
隔離框架(isolation framework)是一組可程式化的 API,讓建立 fake 物件比手動撰寫更簡單、更快速、更精簡。
當介面變得複雜(例如包含多個方法、多個參數),手動撰寫 fake 類別會非常冗長且容易出錯。你需要為每個方法的每個參數建立對應的欄位來記錄值,程式碼會迅速膨脹。
使用隔離框架的三大優勢:
- 更容易驗證參數 – 手動 mock 要逐一比對參數值非常繁瑣,框架讓這件事變得輕鬆
- 更容易驗證多次方法呼叫 – 手動追蹤同一方法被呼叫多次且帶有不同參數的情況很困難,框架可輕鬆處理
- 更容易建立 fake – 框架同時支援建立 mock 和 stub,大幅減少樣板程式碼
隔離框架存在於大多數主流語言中。C++ 有 mockpp,Java 有 jMock 和 PowerMock,.NET 則有 Moq、FakeItEasy、NSubstitute、Typemock Isolator、JustMock 等。
動態建立 fake 物件#
動態 fake 物件(dynamic fake object)是在執行期間建立的 stub 或 mock,不需要手動撰寫(hardcoded)該物件的實作。使用動態 fake 可以免去手動實作介面或繼承類別的工作,因為框架會在執行期間、在記憶體中自動產生所需的類別。
NSubstitute 簡介#
NSubstitute(NSub)是一個開源的隔離框架,可透過 NuGet 安裝。它支援 arrange-act-assert 模式,與一般的測試撰寫方式一致:
- 在 arrange 階段建立並設定 fake
- 在 act 階段對待測程式執行操作
- 在 assert 階段驗證 fake 是否被正確呼叫
NSub 的核心類別是 Substitute,透過 For<Type>() 方法在執行期間動態建立符合指定型別或介面的 fake 物件。
NSub 是一個受限框架(constrained framework),最適合搭配介面使用。對於具體類別,它只能處理非密封(unsealed)類別,且只能 fake 虛擬(virtual)方法。
以動態 fake 取代手動 fake#
以下是手動撰寫 fake 的測試方式:
[Test]
public void Analyze_TooShortFileName_CallLogger()
{
FakeLogger logger = new FakeLogger();
LogAnalyzer analyzer = new LogAnalyzer(logger);
analyzer.MinNameLength = 6;
analyzer.Analyze("a.txt");
StringAssert.Contains("too short", logger.LastError);
}使用 NSub 改寫後,不再需要手動建立 FakeLogger 類別:
[Test]
public void Analyze_TooShortFileName_CallLogger()
{
ILogger logger = Substitute.For<ILogger>();
LogAnalyzer analyzer = new LogAnalyzer(logger);
analyzer.MinNameLength = 6;
analyzer.Analyze("a.txt");
logger.Received().LogError("Filename too short: a.txt");
}關鍵差異在於:
- 使用
Substitute.For<ILogger>()動態建立 fake 物件,不需要手動實作ILogger - 使用
Received()擴充方法來驗證方法是否被呼叫(概念上將 fake 當作 mock 使用) Received()回傳與被呼叫物件相同型別,用來聲明預期的呼叫
Received()是 NSub 命名空間提供的擴充方法,ILogger介面本身並沒有這個方法。呼叫Received()後接方法呼叫,等於是在「詢問」fake 物件該方法是否有被呼叫過。
模擬假值#
回傳假值(Stub 用法)#
當介面方法有回傳值時,可以使用 .Returns() 來設定 fake 的回傳值:
[Test]
public void Returns_ByDefault_WorksForHardCodedArgument()
{
IFileNameRules fakeRules = Substitute.For<IFileNameRules>();
fakeRules.IsValidLogFileName("strict.txt").Returns(true);
Assert.IsTrue(fakeRules.IsValidLogFileName("strict.txt"));
}使用參數匹配器#
如果不在意傳入的具體參數值,可以使用 Arg.Any<T>() 作為參數匹配器(argument matcher),讓 fake 對任何輸入都回傳相同的值。這樣做可以提高測試的可維護性:
fakeRules.IsValidLogFileName(Arg.Any<String>())
.Returns(true);
Assert.IsTrue(fakeRules.IsValidLogFileName("anything.txt"));模擬拋出例外#
使用 When...Do 語法來模擬例外情況:
[Test]
public void Returns_ArgAny_Throws()
{
IFileNameRules fakeRules = Substitute.For<IFileNameRules>();
fakeRules.When(x =>
x.IsValidLogFileName(Arg.Any<string>()))
.Do(context =>
{ throw new Exception("fake exception"); });
Assert.Throws<Exception>(() =>
fakeRules.IsValidLogFileName("anything"));
}同時使用 Stub 和 Mock#
在實際場景中,經常需要在同一個測試中同時使用 stub 和 mock。例如 LogAnalyzer2 同時依賴 ILogger 和 IWebService:當 logger 拋出例外時,應該通知 web service。
- Stub:模擬
ILogger,設定它在被呼叫時拋出例外 - Mock:驗證
IWebService.Write()是否被正確呼叫
sequenceDiagram
participant Test as 測試
participant LA as LogAnalyzer2
participant Logger as ILogger
participant WS as IWebService
Note over Logger: Stub
Note over WS: Mock
Test->>LA: Analyze("Short.txt")
LA->>Logger: LogError()
Logger--xLA: 拋出例外
LA->>WS: Write(錯誤訊息)
Note over Test,WS: Assert 針對 IWebService[Test]
public void Analyze_LoggerThrows_CallsWebService()
{
var mockWebService = Substitute.For<IWebService>();
var stubLogger = Substitute.For<ILogger>();
stubLogger.When(
logger => logger.LogError(Arg.Any<string>()))
.Do(info => { throw new Exception("fake exception"); });
var analyzer =
new LogAnalyzer2(stubLogger, mockWebService);
analyzer.MinNameLength = 10;
analyzer.Analyze("Short.txt");
mockWebService.Received()
.Write(Arg.Is<string>(s => s.Contains("fake exception")));
}參數匹配的 lambda 表達式雖然強大,但會影響測試的可讀性。作者指出,當 assert 中出現超過一個 lambda 表達式時,應該考慮手動 fake 是否會更易讀。
比較完整物件#
另一種驗證方式是建立一個預期的物件,直接比較是否收到相同的物件:
var expected = new ErrorInfo(1000, "fake exception");
mockWebService.Received().Write(expected);這種方式更易讀,但需要注意:
- 物件必須容易建立且屬性值已知
Equals()方法必須正確實作- 較不具彈性,任何屬性值的微小變化都會導致測試失敗
測試事件相關活動#
事件是雙向的,可以從兩個方向測試:
- 測試某物件是否監聽了事件
- 測試某物件是否觸發了事件
測試事件監聽器#
許多開發者會用檢查物件內部狀態的方式來驗證事件註冊,但這是一種過度指定且不易維護的做法。更好的方法是:觸發事件,然後觀察監聽物件是否做出了可見的公開行為。
[Test]
public void ctor_WhenViewIsLoaded_CallsViewRender()
{
var mockView = Substitute.For<IView>();
Presenter p = new Presenter(mockView);
mockView.Loaded += Raise.Event<Action>();
mockView.Received()
.Render(Arg.Is<string>(s => s.Contains("Hello World")));
}使用 NSub 的 Raise.Event<T>() 可以在測試中觸發 fake 物件上的事件。
在有多個依賴的場景中(例如同時有 view 和 logger),可以用 stub 觸發事件,再用 mock 驗證後續行為:
[Test]
public void ctor_WhenViewhasError_CallsLogger()
{
var stubView = Substitute.For<IView>();
var mockLogger = Substitute.For<ILogger>();
Presenter p = new Presenter(stubView, mockLogger);
stubView.ErrorOccured +=
Raise.Event<Action<string>>("fake error");
mockLogger.Received()
.LogError(Arg.Is<string>(s => s.Contains("fake error")));
}測試事件是否被觸發#
測試事件觸發的簡單方法是在測試中手動註冊一個匿名委派(delegate)來記錄事件是否被觸發:
[Test]
public void EventFiringManual()
{
bool loadFired = false;
SomeView view = new SomeView();
view.Load += delegate { loadFired = true; };
view.DoSomethingThatEventuallyFiresThisEvent();
Assert.IsTrue(loadFired);
}.NET 目前的隔離框架#
.NET 生態系中有多個隔離框架可供選擇:
| 框架 | 說明 |
|---|---|
| Moq | 使用量最大,但作者認為其錯誤訊息不佳,且 API 中「mock」一詞被過度使用(建立 stub 時也用 mock),容易造成混淆 |
| Rhino Mocks | 曾經很流行,但已不再積極維護 |
| FakeItEasy | 與 NSubstitute 並列推薦,值得嘗試 |
| NSubstitute | 作者本章選用的框架,文件完善 |
| Typemock Isolator | 不受限框架(unconstrained framework),能力更強 |
| JustMock | 另一個選項 |
建議選定一個框架後就堅持使用,以降低團隊的學習曲線並維持一致性。
為什麼方法字串在測試中是不好的#
在 .NET 以外的許多框架中,常用字串來描述要改變行為的方法。這樣做的問題是:如果生產程式碼中的方法名稱改變了,使用字串的測試仍然可以編譯通過,但會在執行時才失敗。使用強型別的方法名稱(透過 lambda 表達式和委派),方法名稱的任何變更都會讓測試無法編譯,立即發現問題。
隔離框架的優點與陷阱#
優點#
- 更容易驗證參數 – 即使參數很多,框架也能輕鬆處理
- 更容易驗證多次方法呼叫 – 追蹤同一方法的多次呼叫變得簡單
- 更容易建立 fake – 一行程式碼就能建立 mock 或 stub
應避免的陷阱#
不可讀的測試碼
在測試中使用 mock 已經會稍微降低可讀性。如果一個測試有太多 mock 或太多期望設定,會嚴重損害可讀性。遇到這種情況時,考慮移除部分 mock,或將測試拆分成多個小測試。
驗證錯誤的東西
mock 讓你可以驗證介面上的方法是否被呼叫,但這不代表你在測試正確的事情。例如,測試一個物件是否訂閱了事件,並不能告訴你該物件的功能是否正確。應該測試的是:當事件觸發時,是否發生了有意義的行為。
每個測試多個 mock
每個測試應只關注一個問題。一個測試中有兩個 mock,等於同時測試同一個工作單元的多個最終結果。如果無法為測試取一個清楚的名稱(因為它做了太多事),就應該將它拆分。
過度指定測試
過度指定是最常見且最危險的陷阱。應對策略:
- 盡可能使用非嚴格 mock(nonstrict mock) – 測試不會因為非預期的方法呼叫而失敗
- 盡可能使用 stub 而非 mock – 如果超過 5% 的測試使用 mock 物件(非 stub),可能就是過度指定的徵兆
- 避免把 stub 當 mock 用 – stub 只用於提供假回傳值或拋出例外;mock 只用於驗證方法是否被呼叫。不要在 stub 上做驗證,也不要用 mock 來回傳值
如果超過 5% 的測試使用了 mock 物件(而非 stub),你可能正在過度指定。過多的期望設定(如
x.Received().X()加上x.Received().Y())會讓測試變得脆弱,生產程式碼的微小變化就可能導致測試失敗,即使整體功能仍然正常。
總結#
隔離框架讓測試生活更輕鬆,但應該優先傾向回傳值測試或狀態測試,而非互動測試,讓測試盡量少假設內部實作細節。Mock 應只在沒有其他方式可以驗證實作時才使用。
當使用框架的程式碼開始變得醜陋時,這是一個訊號 – 可能需要改用手動 mock、測試不同的結果,或考慮使用更強大的框架(如 Typemock Isolator)。隔離框架的選擇應考量整體情境,選對工具來解決特定的測試問題。