【單元測試的藝術】Chap 5: 隔離(模擬)框架


目錄

  • 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: 設計與可測試性

前言

在前一章節,我們學會了怎麼手刻模擬物件和虛設常式物件,也瞭解這麼做會帶來一些問題。本章,我們將探討如何優雅地解決這些問題的一些方案,這些方案將基於隔離框架(isolation framwork)。也就是能在執行時期建立和設定假物件的類別褲。這些物件稱為動態虛設常式物件(dynamic stub)動態模擬物件(dynamic mock)


一、為什麼要使用隔離框架

隔離框架的定義:一個隔離框架是一套可用來幫助寫程式的 API ,使用這套 API 來建立假物件,比手刻物件要容易得多、快得多、簡潔得多。
隔離框架如果設計得當,可以幫助開發人員解決經常得撰寫重複的程式碼,重複撰寫驗證的語法或難以驗證模擬物件互動的問題。如果設計地極為精妙,隔離框架可以讓測試持續使用很多年,開發人員不需要每次為了一丁點的產品程式碼改動,而得回頭修復被影響的測試程式。

使用隔離框架來建立與使用虛設‘池是物件和模擬物件有很多的優點,可以讓程式碼更為優雅,簡化複雜的測試,提高測試執行速度,並減少測試中的錯誤。

假設你有一個比較複雜的介面:

    public interface IComplicatedInterface
    {
        void Method1(string a, string b, bool c, int x, object o);
        void Method2(string b, bool c, int x, object o);
        void Method3(bool c, int x, object o);
    }

替這個介面手動建立虛設常式物件和模擬物件,可能要花不少時間,因為你需要存放每個方法的參數,如下列程式碼清單:

    class MyTestableComplicatedInterface: IComplicatedInterface
    {
        // 手工撰寫笨拙的語句
        public string meth1_a;
        public stirng meth1_b, meth2_b;
        public bool meth1_c, meth2_c, meth3_c;
        public int meth1_x, meth2_x, meth3_x;
        public int meth1_0, meth2_0, meth3_0;

        public void Method1(string a, string b, bool c, int x, object o)
        {
            meth1_a = a;
            meth1_b = b;
            meth1_c = c;
            meth1_x = x;
            meth1_0 = 0;
        }

        public void Method2(string b, bool c, int x, object o)
        {
            meth2_b = b;
            meth2_c = c;
            meth2_x = x;
            meth2_0 = o;
        }

        public void Method3(bool c, int x, object o)
        {
            meth3_c = c;
            meth3_x = x;
            meth3_0 = o;
        }
    }

手刻這個物件,不但費時也費力,而且還有個大問題:如果需要測試的是某個方法被呼叫多次,該怎麼辦?如果該方法需要一句傳入的參數來決定回傳值,或是要記住同一個方法的所以參數,又該怎麼辦?手刻的程式碼很快就會一團糟。

使用隔離框架來實作以上功能是,測試程式在撰寫時會變得簡單、可讀,且簡短得多。


二、動態產生假物件

動態假物件的定義:動態假物件是在執行時期建立的任何虛設常式物件或模擬物件,它的建立不需要手刻假物件的程式碼(寫死在假類別中)。

使用動態假物件,我們就不需要再手刻程式來實作介面或子類別了。

接下來,來簡單介紹一下隔離框架 NSubstitute。

1. NSubstitute(簡稱 NSub)

  • 開源程式碼的隔離框架
  • 免費下載,可透過 NuGet 安裝
  • 語法簡單、執行速度快,API 的使用容易上手
  • 支援 準備 - 執行 - 驗證 (Arrange - Act - Assert) 模型
  • 理念:測試的準備階段建立和設定物件,接著對被測試產品執行動作,最後驗證在測試中是否正常的與假物件進行互動
  • 有 Substitute 類別,用來在執行時期產生假物件
    • For(type)的方法,有泛型和非泛型兩種,是使用產生假物件最常用的方法
    • 測試執行時,這個方法會動態建立,並回傳參數所指定的類別或介面的一個假物件,不需額外再寫程式完成這個物件的內容
  • 受限框架(constrained framework),它最適合替介面建立假物件
  • 對於類別,NSub 無法替不可繼承(sealed)建立假物件,而對不可繼承的類別,NSub 也只能偽造類別中的虛擬方法

2. 用動態假物件來取代手刻假物件

我們來看一個手刻的假物件,它會檢查呼叫 log 紀錄訊息是否正確執行。

透過手刻假物件來進行驗證
    [TestFixture]
    class LogAnalyzerTests
    {
        [Test]
        public void Analyze_TooShortFileName_CallLogger()
        {
            FakeLogger logger = new FakeLogger(); // 建立假物件

            LogAnalyzer analyzer = new LogAnalyzer(logger);
            analyzer.MinNameLength = 6;
            analyzer.Analyze("a.ext");

            StringAssert.Contains("too short", logger.LastError); // 把假物件當模擬物件來使用並驗證
        }
    }

    class FakeLogger: ILogger
    {
        public string LastError;
        public void LogError(string message)
        {
            LastError = message;
        }
    }

接著,我們會建立一個動態模擬物件,取代之前的測試方式。下面的程式碼清單呈現了使用 NSub 來模擬 ILogger,並且可以看到要驗證呼叫方法所傳入的參數,有多麼簡單!

使用 NSub 產生假物件
    [Test]
    public void Analyze_TooShortFileName_CallLogger()
    {
        ILogger logger = Substitute.For<ILogger>(); // 建立模擬物件,在測試最後進行驗證

        LogAnalyzer analyzer = new LogAnalyzer(logger);
        analyzer.MinNamaLength = 6;
        analyzer.Analyze("a.txt");

        logger.Received().LogError("Filename too short: a.txt"); // 使用 NSub API 來設定期望結果
    }

簡單幾行程式碼,就免去了手刻模擬物件或虛設常式那堆麻煩事,因為 NSub 能自動產生假物件。這個 ILogger 的假物件是動態產生的,假物件時做了 Ilogger 的介面,但是沒有實作 ILogger 上的任何方法。

透過在 LogError() 前呼叫 Received(),我們在告訴 NSub 實際上是要詢問假物件這個方法是否有被呼叫過。如果該方法沒有被呼叫,測試的最後一行會拋出一個例外,這行程式碼告訴讀測試程式的人:「某個物件應該曾經被呼叫過一次,否則這個測試應該失敗。」

如果 LogError() 方法沒有被呼叫,你會在測試失敗的訊息中看到類似下面的錯誤訊息:

NSubstitute.Exceptions.ReceivedCallsException: Expected to receive a call matching:
    LogError("FileName too short: a.txt")
Actuall received no matching calls.

瞭解如何把假物件當作模擬物件之後,接著我們來看看怎麼將它們看作虛設常式物件,提供被測試系統所需要的回傳值。


三、模擬回傳值

如果介面的方法不是回傳空值,那麼該如何完成介面的動態假物件回傳一個值呢?

從一個假物件回傳值
    [Test]
    public void Returns_ByDefault_WorksForHardCodedArgument()
    {
        IFileNameRules fakRules = Substitute.For<IFileNameRules>();
        fakeRules.IsValidLogFileName("strict.txt").Returns(true); // 強制方法被呼叫時要回傳假的值

        Assert.IsTrue(fakeRules.IsValidLogFileName("strict.txt"));
    }

接下來,我們來試著在程式碼中使用參數匹配器(argument matcher),略過傳入的參數值:

    [Test]
    public void Returns_ByDefault_WorksForHardCodedArgument()
    {
        IFileNameRules fakeRules = Substitute.For<IFileNameRules>();
        fakeRules.IsValidLogFileName(Arg.Any<String>()).Returns(true); // 忽略參數內容

        Assert.IsTrue(fakeRules.IsValidLogFileName("anything.txt"));
    }

留意一下是怎麼使用 Arg 類別的,這表示不論傳入的參數為何,我們都會回傳這個假值。Arg.Any<Type> 稱為參數匹配器,在隔離框架中被廣泛使用,用來控制參數的處理。

如果要拋出例外:

    [Test]
    public void Returns_ArgAny_Throws()
    {
        IFileNameRules fakeRules = Substitute.For<IFileNameRules>();
        fakeRules.When(x => x.IsValidLogFileName(Arg.Any<string>())) // 這邊會使用到 lambda 表示式
                 .Do(context => { throw new Exception("fake exception"); });

        Assert.Throws<Exception>(() => fakeRules.IsValidLogFileName("anything"));
    }

在程式碼中,使用 Assert.Throw 是用來驗證被測試方法是否有拋出期望的例外。

  • When 方法必須使用 Lambda 表示式:在 When 方法中,參數 x 代表你要模擬改變行為的假物件。
  • Do 部分則使用了 CallInfo context 參數:在執行時,context 包含了呼叫的參數值。

學會了模擬回傳值和例外,我們來討論一下更實務面的需求。


1. 同時使用模擬物件和虛設常式物件

我們在一個情境裡面結合使用這兩種類型的假物件。兩個假物件,一個用來當虛設常式物件,一個用來當模擬物件。

alt text

使用虛設常式物件來取代 logger 並模擬拋出例外,而使用模擬物件當假的 web 服務,確認它是否有被正確地呼叫。整個測試就是 LogAnalyzer 類別與其他物件的互動測試。

你需要確保的是:如果 log 物件拋出了例外,LogAnalyzer2 會把這個問題通知給 WebService。

被測試方法以及使用手刻模擬物件和虛設常式物件的測試程式
    [Test]
    public void Analyze_LoggerThrows_CallsWebService() // 測試方法
    {
        FakeWebService mockWebService = new FakeWebService();
        Fakelogger2 stubLogger = new FakeLogger2();
        stubLogger.WillThrow = new Exception("fake exception");

        var analyzer2 = new LogAnalyzer2(stubLogger, mockWebService)'
        analyzer2.MinNameLength = 8;
        string tooShortFileName = "abc.ext";
        analyzer2.Analyze(tooShortFileName);

        Assert.That(mockWebService.MessageToWebService, Is.StringContaining("fake exception"));
    }

    public class FakeWebService: IWebService // 用模擬物件來當作一個假的 web 服務
    {
        public string MessageToWebService;
        public void Write(string message)
        {
            MessageToWebService = message;
        }
    }

    public class FakeLogger2: ILogger // 用虛設常式物件來模擬 logger 拋出例外
    {
        public Exception WillThrow = null;
        public string LoggerGotMessage = null;
        public void LogError(string message)
        {
            LoggerGotMessage = message;
            if (WillThrow != null)
            {
                throw WillThrow;
            }
        }
    }

    // ----- PRODUCION CODE
    public class LogAnalyzer2
    {
        private ILogger _logger;
        private IWebService _webService;
        public LogAnalyzer2(ILogger logger, IWebService webService)
        {
            _logger = logger;
            _webService = webService;
        }
        public int MinNameLength { get; set; }
        public void Analyze(string filename)
        {
            if (filename.Length<MinNameLength)
            {
                try
                {
                    _logger.LogError(string.Format("Filename too short: {0}", filename));
                }
                catch (Exception e)
                {
                    _webService.Write("Error From Logger: " + e);
                }
            }
        }
    }

    public interface IWebService
    {
        void Write(string message);
    }

下一段程式碼清單,則用 NSub 來取代上述的程式碼內容。

把前面的測試轉換成 NSubStitute 的寫法
    [Test]
    public void Analyze_LoggerThrows_CallWebService()
    {
        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"))); // 確認 web 服務的模擬物件有被正確呼叫,而且傳入的字串參數包含了 fake exception 的內容
    }

這個測試寫法的好處:不需要手刻假物件的內容。但要注意的是,這段程式的可讀性變差了。

  • 參數匹配約束(argument-matching constraint)既可以用在測試準備的部分,進行虛設常式物件的設定,也可以用在驗證的部分,檢查在測試過程中是否正確地與模擬物件進行互動。

2. 驗證物件是帶著某些屬性的情況

如果你預期的參數是一個帶有 某個期望屬性值 的物件,像是在呼叫 WebService.Write 方法時,應傳入一個帶有 serverity 和 message 屬性的 ErrorInfo 物件,該怎麼做呢?

    [Test]
    public void Analyze_LoggerThrows_CallsWebServiceWithNSubObject()
    {
        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 Loganalyzer3(stubLogger, mockWebService);
        analyzer.MinNameLength = 10;
        analyzer.Analyze("Short.txt");

        mockWebService.Received()
                      .Write(Arg.Is<ErrorInfo>(info => info.Severity == 1000 // 針對你期望的物件類型所使用的強型別參數匹配器
                      && info.Message.Contains("fake exception"))); // 透過 C# 裡面簡單的 and 來建立你對物件更複雜的期望
    }

要測試參數物件期望值,最簡單的方式是比較兩個物件。你可以建立一個帶有所有預期屬性的 expected 物件,和實際傳入的參數物件進行比較。如下列程式所示。

比較完整物件
    [Test]
    public void Analyze_LoggerThrows_CallsWebServiceWithNSubObjectCompare()
    {
        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 LogAnalyzer3(stubLogger, mockWebService);
        analyzer.MinNameLength = 10;
        analyze.Analyze("Short.txt");

        var expected = new ErrorInfo(1000, "fake exception"); // 建立你期望接收到的物件
        mockWebService.Received().Write(expected); // 驗證你是否得到了值相等的物件,本質上就是 assert.equals
    }

只有滿足以下條件時,測試整個物件的方式才可行:

  • 容易建立帶有預設值的物件
  • 需要測試目標物件上的所有屬性
  • 知道全部屬性的期望值
  • 進行比較的兩個物件,實作了 Equals() 方法

四、測試事件相關的活動

事件是雙向的,你可以從兩個方向進行測試:

  • 測試監聽事件的那一方
  • 測試觸發事件的那一方

1. 測試事件監聽者

我們要解決的第一個測試情境就是:檢查一個物件是否有註冊到另一個物件的一個事件。

一個比較好的設計方式是:檢查監聽物件是否對發生的事情做出某種具體的反應,如果監聽者沒有註冊到這個事件,那它就不會採取任何可觀察到的公開行為。詳細的實現方式如下:

事件相關的程式碼,以及如何觸發事件
    class Presenter
    {
        public readonly IView _view;
        public Presenter(IView view)
        {
            _view = view;
            this._view.Loaded += OnLoaded;
        }
        private void OnLoaded()
        {
            _view.Render("Hello World");
        }
    }

    public interface IView
    {
        event Action Loaded;
        void Render(string text);
    }

    // ----- TESTS
    [TestFixture]
    public class EventRelatedTests
    {
        [Test]
        public void ctor_WhenViewIsLoaded_CallsViewRender()
        {
            var mockView = Substitute.For<IView>();
            Presenter p = new Presenter(mockView);
            mockView.Loaded += Raise.Event<Action>(); // 使用 NSubstitute 觸發事件
            mockView.Received().Render(Arg.Is<string>(s => s.Contains("Hello World"))); // 驗證在測試中 view 物件的 Render 方法是否被呼叫
        }
    }

有幾點留意:

  • 這個模擬物件同時也是個虛設常式物件
  • 要觸發一個試驗,你需要在測試中註冊這個事件。這種作法是為了滿足編譯器的要求,因為事件相關的屬性編譯器會特別謹慎,編譯要求很嚴格。事件只能由宣告它們的類別或結構直接喝叫。

另一個情境有兩個相依物件:一個 log 物件,一個 View。下列程式碼確保了 Presenter 在虛設常式物件得到一個事件時寫入 log。

模擬一個事件,以及一個單獨的模擬物件
    [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"); // (1) 模擬錯誤發生
        mockLogger.Received().LogError(Arg.Is<string>(s => s.Contains("fake error"))); // (2) 使用模擬物件驗證 log 有被正確呼叫
    }

注意:你使用了 (1) 虛設常式物件觸發事件,一個 (2) 模擬物件驗證是否正確地呼叫了 log 服務。

接著,我們來看看驗證事件發生點有在正確的時機點觸發事件,而不是檢查測試監聽者的反應。


2. 測試事件是否觸發

測試事件的一個簡單方式,就是在測試方法內部使用一個匿名委派,手動註冊這個方法。

使用匿名委派來註冊一個事件
    [Test]
    public void EventFiringManual()
    {
        bool loadFired = false;
        SomeView view = new SomeView();
        view.Load += delegate { loadedFired = true };
        view.DoSomethingThatEventuallyFiresThisEvent();

        Assert.IsTrue(loadFired);
    }

這個委派只記錄了這個事件是否被觸發。你也可以在委派中加入參數,用來記錄需要的值,以便後續進行驗證。


五、現有的 .NET 隔離框架

2012 隔離框架統計結果:

alt text


六、隔離框架的優缺點

隔離框架有以下顯著優點:

  • 更容易驗證參數
  • 更容易驗證一個方法被多次呼叫
  • 更容易建立假物件

使用隔離框架時應避免的陷阱:

  • 測試程式可讀性變差
  • 驗證了錯誤的東西
  • 一個測試中有多個模擬物件
  • 過度指定的測試

七、小結

隔離框架很酷,但有一點很重要:盡量選擇測試回傳值系統狀態改變的測試(相對於互動測試),使用這兩種測試,可以減少對程式碼內部細部實作的假設。只有在非不得已才此用模擬物件,因為容易更難維護與閱讀。

如果有超過 5% 的測試使用模擬物件,而非虛設常式物件,你可能過度指定了。

在下一章節,我們將深入了解隔離框架,了解它們的設計,以及影響其能力的底層實作內容。

#Unit Test #單元測試的藝術
也許你是單元測試甚至 TDD 愛好者,也許你著墨過一點單元測試,也許你是個剛開始寫程式的新手,不論你是誰,你都必須讀讀這本「單元測試的藝術」。 這是一本由 Roy Osherove 撰寫,針對靜態程式語言最經典的單元測試書籍。這個系列文會慢聊這本單元測試聖經,感受超層級的藝術。






Related Posts

完美主義的悲哀:放下就不再煩惱

完美主義的悲哀:放下就不再煩惱

陳小嬰
Chapter 2 選擇正確的工具

Chapter 2 選擇正確的工具

graceccl
我很菜,所以只會用原生 JS 跟 CSS 寫「口罩地圖 」Ep.00

我很菜,所以只會用原生 JS 跟 CSS 寫「口罩地圖 」Ep.00

ABow_Chen


Comments