【單元測試的藝術】Chap 6: 深入了解隔離框架


目錄

  • PART I: 入門
    • Chap 1: 單元測試基礎
    • Chap 2: 第一個單元測試
  • PART II: 核心技術
    • Chap 3: 透過虛設常式解決依賴問題
    • Chap 4: 使用模擬物件驗證互動
    • Chap 5: 隔離(模擬)框架
    • Chap 6: 深入了解隔離框架
  • PART III: 測試程式碼
    • Chap 7: 測試階層和組織
    • Chap 8: 好的單元測試的支柱
    • Chap 9: 在組織中導入單元測試
    • Chap 10: 遺留程式碼
    • Chap 11: 設計與可測試性

前言

前一章節,介紹了如何使用 NSubstitute 來建立假物件的方式。這一章,我們要回過頭來看看 .NET 和其他語言隔離框架的情況。


一、受限框架和不受限框架

  1. 受限框架(Constrained)

    • 稱它為受限框架,因為有些東西是這些框架所不能偽造的。
    • .NET 受限框架:Rhino Mocks, Moq, NMock, EasyMock, NSubstitute, FakeItEasy;
      Java 受限框架:jMock, EasyMock
    • .NET 受限框架不能偽造靜態方法、非虛擬方法和非公開方法
    • 受限框架通常透過在執行時期,產生繼承和覆寫介面或基底類別,跟之前章節所做的一樣,只不過它們是在執行程式之前就先處理這些工作。
    • 編譯限制:偽造對象必須得公開且可繼承的類別,且必須有公開的建構式或介面
  2. 不受限框架(Unconstrained)

    • .NET 不受限框架:Typemock Isolator, JustMock, Moles;
      Java 不受限框架:PowerMock, JMockit
    • 不受限框架不是在執行時期來產生和編譯從其他程式碼繼承過來的作法。
    • 所有 .NET 不受限框架,都是基於探查器(profiler-based),它們使用一套探查器 API(profiling API)的非託管(unmanaged)API,這些 API 對 .NET CLR 物件執行個體進行封裝。
    • 不受限框架優點:
      • 替那些以前無法寫測試的程式來撰寫單元測試
      • 替無法控制或是很難進行測試的第三方系統進行測試
      • 決定自己設計的彈性與複雜度
    • 不受限框架缺點:
      • 一不小心會進入死胡同,可能偽造了不需要的東西,導致無法在抽象層次較高的角度來了解工作單元
      • 一不小心偽造了不屬於你的 API,可會導致一些測試無法維護

二、好的隔離框架的價值

  1. 適應未來
  2. 可用性

三、支援適應未來和可用性的功能

提高測試見狀性的功能:

  1. 遞迴假物件
  2. 對行為和驗證預設忽略參數
  3. 非嚴格驗證與行為
  4. 大範圍偽造

接著我們會一個一個討論。


1. 遞迴假物件

遞迴假物件是假物件在函數回傳其他物件時的一種特殊行為。這些回傳物件會自動產生假物件。

說明範例
    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());
    }

2. 對行為和驗證預設忽略參數

送給改變行為的 API 或驗證 API 的任何參數值,都會被當作預設的期望值。在預設情況,Isolator 忽略傳入的值,除非你在 API 呼叫時特別宣告這個參數值。這樣就沒有必要在所有方法中,總是包含 Arg.IsAny<Type>,能少打幾個字,避免使用會破壞可讀性的泛型。如果想對任何參數都拋出一個例外:

    Isolate.WhenCalled(() => stubLogger.Write("")).WillThrow(new Exception("Fake"));

3. 非嚴格驗證與行為

  1. 假物件的非嚴格行為
    • 隔離框架事件以前是非常嚴格的,現在大多數語言也很嚴格,如 Java 和 Ruby,而很多 .NET 框架的發展已經過了這個階段。
    • 一個嚴格假物件的方法,只有透過隔離框架 API 將它設定預期(expected)的,曾能成功呼叫它們。如果一個方法被設定為預期的,任何異於這個預期的呼叫,不管是定義的參數值不同,還是名稱不同,通常會被處理成拋出一個例外。
    • 一個嚴格模擬物件在兩種情況下可能失敗:對它呼叫了一個非預期的方法,或是沒有對它所設定的預期方法進行呼叫。
  2. 非嚴格模擬物件
    • 一個非嚴格模擬物件允許對它進行任何方法的呼叫,即使這個呼叫並不在原本預期內。
    • 對於有回傳值的方法,如果回傳的是實質型別物件,非嚴格模擬物件會回傳一個預設值,如果回傳的是一個參考型別,非嚴格物件就會回傳 null。

4. 大範圍偽造

大範圍偽造是一次偽造多個方法的能力。

在 FakeItEasy 工具,你可以指定某個物件上所有方法都回傳同一個值,或是只對回傳值為特定型別的方法進行指定:

    A.CallTo(foo).Throws(new Exception());
    A.CallTo(foo).WithReturnType<string>().Returns("hello world");

使用 Typemock,你則可以指定一個型別中的所有靜態方法都預設回傳同一個值:

    Isolate.Fake.StaticMethods(typeof(HttpRuntime));

重申一下,大範圍偽造對策是在產品程式碼演化時的未來可維護性有極大的好處。


四、隔離框架設計反模式

以下是目前的框架中我們容易著手進行改善的一些反模式:

  1. 概念混淆
  2. 錄製與重播
  3. 黏性(Sticky)行為
  4. 語法過於複雜

1. 概念混淆

概念混淆又可稱為模擬過量(mock overdose)

你必須知道測試中有多少的模擬物件和虛設常式物件。以下是一個 Moq 的範例:

更改前
    [Test]
    pubic void ctor_WhenViewHasError_CallsLogger()
    {
        var view = new Mock<IView>();
        var logger = new Mock<ILogger>();
        Presenter p = new Presenter(view.Object, logger.Object);
        view.Raise(v => v.ErrorOccured += null, "fake error");
        logger.Verify(log => log.LogError(It.Is<string>(s => s.Contains("fake error"))));
    }
更改後
    [Test]
    pubic void ctor_WhenViewHasError_CallsLogger()
    {
        var stubView = new Mock<IView>();
        var mockLogger = new Mock<ILogger>();
        Presenter p = new Presenter(stubView.Object, mockLogger.Object);
        stubView.Raise(v => v.ErrorOccured += null, "fake error");
        mockLogger.Verify(log => log.LogError(It.Is<string>(s => s.Contains("fake error"))));
    }

這邊我們將 Stub 和 Mock 區分得更清楚。


2. 錄製與重播

隔離框架中的錄製與重播(record-and-replay)的設計風格大大降低了測試的可讀性。

讓我們來看看 Rhino Mocks(支援錄製與重播)的範例,與 Moq 支援準備-執行-驗證(Arrange-Act-Assert, AAA),兩者的對照。

Rhino Mocks(支援錄製與重播)
    [Test]
    public void ShouldIgnoreRespondentsThatDoesNotExistRecordPlayback()
    {
        // 準備
        var guid = Guid.NewGuid();
        // 一部分的執行
        IEventRaiser executeRaise;
        using(_mocks.Record())
        {
            // 準備(還是是在驗證?)
            Expect.Call(_view.Respondents).Return(new[] { guid.ToString() });
            Expect.Call(_repository.GetById(guid)).Return(null);
            // 一部分的執行
            _view.ExecuteOperation += null;
            executeRaiser = LastCall.IgnoreArguments()
                                    .Repeat.Any()
                                    .GetEventRaiser();
            // 驗證
            Expect.Call(_view.OperationErrors = null)
                  .IgnoreArguments()
                  .Constraints(List.IsIn("Non-existant respondent: " + guid));
        }
        using(_mocks.Playback())
        {
            // 準備
            new BulkRespondentPresenter(_view, _repository);
            // 執行
            executeRaiser.Raise(null, EventArgs.Empty);
        }
    }
Moq:支援準備-執行-驗證(Arrange-Act-Assert, AAA)
    [Test]
    public void ShouldIgnoreResponentsThatDoesNotExist()
    {
        // 準備
        var guid = Guid.NewGuid();
        _viewMock.Setup(x => x.Respondents).Returns(new[] { guid.ToString() });
        _respositoryMock.Setup(x => GetById(guid)).Returns(() => null);

        // 執行
        _viewMock.Raise(x => x.ExecuteOperation += null, EventArgs.Empty);

        // 驗證
        _viewMock.VerifySet(x => x.OperationErrors = It.Is<IList<string>>(l => l.Contains("Non-existant respondent: " + guid)));
    }

後者顯然比較直觀。


3. 黏性(Sticky)行為

隔離框架可以給行為加入預設的「黏性」(stickiness),一旦你告入一個方法該以某種方式運作(如:回傳 false,哪怕呼叫一百次也一樣),這個功能讓測試不需要知道這個方法未來應該如此運作,因為那時的呼叫對眼前的測試來說已經不重要了。


4. 語法過於複雜

有些框架,即使使用了一段時間以後,你還是很難記住如何進行最基本的操作,這會影響寫程式的體驗。API 的設計可以讓這些操作變得更容易。如 Typemock Isolator 所有 API 都是以單字 Isolate 開頭。


五、小結

  • 隔離框架可以分成:受限和不受限
  • 在 .NET 中,不受限框架使用探查器 API,而大多數的受限框架則是在執行時期動態產生和編譯程式碼,和你手刻物件時相同。
  • 支援適應未來性和可用性價值的隔離框架,可以讓你的單元測試變得更輕鬆
#Unit Test #單元測試的藝術