本章介紹隔離框架(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 模式,與一般的測試撰寫方式一致:

  1. arrange 階段建立並設定 fake
  2. act 階段對待測程式執行操作
  3. 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 同時依賴 ILoggerIWebService:當 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)。隔離框架的選擇應考量整體情境,選對工具來解決特定的測試問題。