【單元測試的藝術】Chap 2: 第一個單元測試


目錄

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

前言

在開始之前,這本書談的是 xUnit 框架,跟你所使用的框架可能不盡相同(以 iOS 為例,Xcode 自帶 XCTest 框架),現在很多框架都非常智慧,因此你可能看著看著就沒耐心了,但先別急著跳過,這本書的重點是「概念」,套句鳥哥思維:「多學點總是好事。」學會一個程式碼,你便看得懂大多程式碼,所以別害怕看到不熟悉的框架、程式碼,盡可能從中找到一些靈感與快樂!


ㄧ、撰寫第一個測試程式

單元測試框架提供哪些東西

  • 輕鬆撰寫結構化的測試
  • 執行一個或全部單元測試
  • 確認執行結果

xUnit 框架

通常單元測試框架都以它們所支援的程式語言開頭加上 Unit 作為名字,它們統稱為 xUnit 框架。這本書中,我們會使用 .Net 單元測試框架 NUnit。


NUnit

  1. 安裝 NUnit: 建議使用 NuGet 安裝。

  2. NUnit GUI: 以下為示意圖,但因我用 mac 所以沒使用這個工具 Q。
    alt text

  3. 載入方案:我們設立一個 LogAnalyzer。判斷 fileName 的結尾是否為 .SLF。(以下程式碼為故意寫錯,應為 !(fileName.EndsWith(".SLF)) 。也許是那天晚上,夜色寂靜、睡眼惺忪時寫下的錯誤程式碼,單元測試來拯救世界的時刻。)

     public class LogAnalyzer
     {
         public bool IsValidLogFileName(string fileName)
         {
             if (fileName.EndsWith(".SLF")) // INCORRECT HERE
             {
                 return false;
             }
             return true;
         }
     }
    
  4. 單元測試的命名:[UnitOfWorkName]_[ScenarioUnderTest]_[ExpectedBehavior]

    • UnitOfWorkName:被測試的方法、一組方法或一組類別
    • ScenarioUnderTest:測試進行的假設條件,例如登入失敗、無效的使用者、密碼正確
    • ExpectedBehavior:在測試情境的假設條件下,行為的預期。
      • 回傳一個結果值(一個真實的值或是一個 Exception)
      • 系統狀態的改變(在系統中新增一個使用者,導致下次登入時系統的行為發生改變)
      • 呼叫外部第三方系統提供的服務(與一個外部的 Web 服務進行互動)
  5. NUnit 特性:TestFixture 與 Test

    • 特性 [TestFixture] 標記一個包含自動化 NUnit 測試的類別。
    • 特性 [Test] 可以加在一個方法上,用來標記這個方法是一個需要被執行的自動化測試。
  • 提示:NUnit 要求測試方法必須是公開的且為 void,在基本設定情況下,不允許傳入參數,但是有時這些情況可以使用參數。
  1. 撰寫第一個測試程式
    • 一個單元測試包含三種行為
      1. 準備 (Arrange) 物件、建立物件、進行必要的設定
      2. 操作 (Act) 物件
      3. 驗證 (Assert) 某件事符合預期
    • LogAnalyzerTests 測試本人
      [TestFixture]
      public class LogAnalyzerTests
      {
        [Test]
        public void IsValidFileName_BadExtension_ReturnsFalse()
        {
            LogAnalyzer analyzer = new LogAnalyzer();
            bool result = analyzer.IsValidLogFileName("filewithbadextension.foo");
            Assert.False(result);
        }
      }
      
  2. Assert 類別:Assert 類別屬於 NUnit.Framework 的命名空間裡,提供靜態方法。範例:

    • Assert.False(result);
    • Assert.AreEqual(expextedObject, actualObject, message);
  3. 經過一輪測試後,我們會發現測試失敗了(不論是 NUnit GUI,還是程式碼內 Test Results),因此我們來修正一下那晚月夜風高、醉生夢死,意外手殘 (腦殘?) 寫錯的地方,認真說這種蟲最難抓,你以為自己很聰明,直到這一刻天崩地裂、懷疑人生。

     public class LogAnalyzer
     {
         public bool IsValidLogFileName(string fileName)
         {
             if !(fileName.EndsWith(".SLF")) // FIXED HERE
             {
                 return false;
             }
             return true;
         }
     }
    
  4. 新增正向測試:來另外新增兩個測試吧!

         [Test]
         public void IsValidLogFileName_GoodExtensionLowercase_ReturnsTrue()
         {
             LogAnalyzer analyzer = new LogAnalyzer();
             bool result = analyzer.IsValidLogFileName("filewithgoodextension.slf");
             Assert.True(result);
         }
    
         [Test]
         public void IsValidLogFileName_GoodExtensionUppercase_ReturnsTrue()
         {
             LogAnalyzer analyzer = new LogAnalyzer();
             bool result = analyzer.IsValidLogFileName("filewithgoodextension.SLF");
             Assert.True(result);
         }
    
  5. 糟糕,依照原本的程式碼,測試又失敗了,我們需要處理大小寫的問題。

    public class LogAnalyzer
    {
        public bool IsValidLogFileName(string fileName)
        {
            if !(fileName.EndsWith(".SLF", StringComparison.CurrentCultureIgnoreCase)) // ADJUST HERE
            {
                return false;
            }
            return true;
        }
    }
    
  6. 棒棒噠!美妙的綠色!由紅到綠:測試成功!
    alt text


二、使用參數來重構測試

到目前為止,所寫的測試都不容易維護。假設 LogAnalyzer 的建構式裡加一個參數,那 3 個的測試就都會編譯失敗 QQ,你可能想說不怕不怕大不了「工人」方式一個一個改,但... 倘若 30 個呢?300 個呢?身為電腦怪客,能自動化 裝逼 就自動化 裝逼,怎能這麼凡夫俗子對吧,所以,讓我們來重構這些測試吧!

    // Add [Test]
    [Test("filewithgoodextension.SLF")]
    [Test("filewithgoodextension.slf")]
    public class LogAnalyzerTests
    {
        [Test]
        public void IsValidFileName_BadExtension_ReturnsFalse(string file) // ADD Parameter
        {
            LogAnalyzer analyzer = new LogAnalyzer();
            bool result = analyzer.IsValidLogFileName(file); // Input file
            Assert.False(result);
        }
    }
  1. 把特性 [Test] 換成 [TestCase]
  2. 把測試中所寫死的值,替換持這個測試方法的參數
  3. 把被替換掉的名字放到特性中的括號裡,如 [TestCase(param1, param2, ...)]
  4. 用一個比較共用的名字
  5. 把這個測試方法上,對每個需要合併的測試方法,用其測試值新增一個 [TestCase(...)] 特性
  6. 移除其他測試,只保留這一個帶有多個 [TestCase] 特性的測試方法

我們還可以更近一步,加入負面的測試,走起~

    // Adjust [Test] Parameter
    [Test("filewithgoodextension.SLF", true)]
    [Test("filewithgoodextension.slf", true)]      
    [Test("filewithbadextension.foo", false)]
    public class LogAnalyzerTests
    {
        [Test]
        public void IsValidFileName_BadExtension_ReturnsFalse(string file, bool expected)
        {
            LogAnalyzer analyzer = new LogAnalyzer();
            bool result = analyzer.IsValidLogFileName(file);
            Assert.AreEqual(expected, result); // ADJUST AreEqual and set expected boolean value
        }
    }

三、更多的 NUnit 特性

setup 和 teardown

  • [Setup]: 這個特性可以像特性 [Test] 一樣,夾在一個方法上,NUnit 每次在執行測試類別裡的任何一個測試方法之前,都會先呼叫標記 [Setup] 的方法。
  • [TearDown]: 這個特性標記這個方法應該在每個測試執行完畢之後被呼叫。
        private LogAnalyzer m_analyzer = null;
        [SetUp]
        public void SetUp() // setup 特性
        {
            m_analyzer = new LogAnalyzer();
        }
        [Test]
        public void IsValidFileName_validFileLowerCased_ReturnsTrue()
        {
            bool result = m_analyzer.IsValidLogFileName("whatever.slf");
            Assert.IsTrue(result, "filename should be valid");
        }
        [Test]
        public void IsValidFileName_validFileUpperCased_ReturnsTrue()
        {
            bool result = m_analyzer.IsValidLogFileName("whatever.SLF");
            Assert.IsTrue(result, "filename should be valid");
        }
        [TearDown]
        public void TearDown() // TearDown 特性
        {
            // 反模式,不要在實作上這樣做
            m_analyzer = null;
        }

以上呈現了如何使用特性 [SetUp] 和 [TearDown],確保每個測試都得到一個新的 Analyzer 類別新的物件,同時能省去重複輸入。但要注意,使用 [SetUp] 越多,測試的可讀性就越差。

  • 延伸閱讀:XCTest 與 NUnit 的 Setup & TearDown,當時看這個部分時,一直在思考為什麼跟我想的不太一樣(依據我對 iOS XCTest 的了解),目前看來是 Life Cycle 不同。

在實際工作中,作者 使用 setup 來初始化測試,而是使用工廠模式。(詳情作法會在 Chap 7: 測試階層和組織

NUnit 還包含其他輔助設定和清理系統狀態的特性,例如 [TestFixtureSetUp] 和 [TestFixtureTearDown],可以在該測試類別裡的測試執行前,進行狀態的設定。

在實際專案中,幾乎 永遠不會 用到 TearDown 和 TestFixture 方法。如果用到的話,很有可能是相依檔案系統或資料庫,所寫的是整合測試

alt text


驗證預期的例外

    if (string.IsNullOrEmpty(fileName)
    {
        throw new ArgumentException("filename has to be provided");
    }

針對這個我們有兩種驗證方式:

  1. NUnit 有一個特別的特性可以用來測試例外: [ExpectedException]。此為 不該使用的方式 ,曾經很流行。
    [Test]
    [ExpectedException(typeof(ArgumenException), ExceptedMessage = "filename has to be provided")]
    public void IsValidFileName_EmptyFileName_ThrowsException()
    {
        m_analyzer.IsValidLogFileName(string.isEmpty);
    }
    private LogAnalyzer MakeAnalyzer() // 工廠模式
    {
        return new LogAnalyzer();
    }

上面程式碼有幾個需要特別留意的點:

  • 預期的例外訊息做為一個參數傳遞給 [ExpectedException] 特性。
  • 測試本身沒有使用 Assert 類別進行驗證。[ExpectedException] 就包含了驗證的檢查。
  • 因為呼叫了被測試方法,並預期拋出一個例外,所以測試哺需要被測試方法取得回傳的 bool 值。

那為什麼不該使用呢?簡單來說,因為這個特性的意思,類同於將整個方法包在一個 try-catch 裡面,如果沒有例外被「攔截」到,就認為是失敗。這個作法有個很大的問題,就是你不知道在整個測試方法的大區塊裡,是不是我們期望的那一行所拋出的例外。

  1. NUnit 提供了一個更新的 API:Assert.Catch(delegate)
    [Test]
    public void IsValidFileName_EmptyFileName_Throws()
    {
        LogAnalyzer la = makeAnalyzer();
        var ex = Assert.Catch<Exception>(() => la.IsValidLogFileName(""));
        StringAssert.Contains("filename has to be provided", ex.Message);
    }
  • 如果 Lambda 中的程式碼拋出了一個符合預期的例外,測試就會通過。反之,就會失敗。
  • Assert.Catch 函數會回傳 Lambda 內所拋出的例外的物件,你可以後續對這個例外進行驗證。
  • 使用 StringAssert。它能夠簡化字串驗證的輔助函數,增加可讀性。
  • 沒有用 Asser.AreEqual 來進行完整字串驗證,而是使用 StringAssert.Contains,因為隨著程式碼功能越來越多,字串內容經常發生變化,使用 StringAssert.Contains 更容易維護。

忽略此測試

有時候測試有問題,但 PM 又很 機車 緊迫盯人,需要趕快進版。在這種在於罕見跟不罕見之間的情況,可以加上 [Ignore] 特性。

    [Test]
    [Ignore("there is a problem with this test. DAMN!")]
    public void IsValidFileName_ValidFile_ReturnsTrue()
    {
        ...
    }
  • 補充:NUnit GUI 介面,會呈現黃色。

NUnit 流利語法

除了 Assert*. 用法外,還可以使用流利語法:Assert.That 開頭。

    [Test]
    public void IsValidFileName_EmptyFileName_ThrowsFluent()
    {
        LogAnalyzer la = MakeAnalyzer();
        var ex = Assert.Catch<ArgumentException>(() => la.IsValidLogFileName(""));
        Assert.That(ex.Message, Is.StringContaining("filename has to be provided");
    }

設定測試分類

你可以將測試,按照指定的測試分類來執行。如:執行快慢。使用 [Category] 特性可以完成此功能。

    [Test]
    [Category("Fast Tests")]
    public void IsValidFileName_ValidFile_ReturnsTrue()
    {
        ...
    }

測試系統狀態的改變,而非驗證回傳值

我們先來回顧一下,工作單元的三種最終結果:

  1. 回傳值
  2. 系統狀態的改變
  3. 第三方系統

Chap 1: 單元測試的基礎,我們學會針對工作單元的 回傳值 進行測試,接下來我們將會討論第二種驗證方式:系統狀態的改變 ——驗證被測試系統,在執行某個動作的前後,其行為所發生的變化是否符合預期。

    public class LogAnalyzers
    {
        public bool WasLastFileNameValid { get; set; }
        public bool IsValidLogFileName(string fileName)
        {
            WasLastFileNameValid = false; // 改變系統狀態
            if (string.IsNullOrEmpty(fileName))
            {
                throw new ArgumentException("filename has to be provided");
            }
            if (fileName.EndsWith(".SLF", StringComparison.CurrentCultureIgnoreCase))
            {
                return false;
            }
            WasLastFileNameValid = true; // 改變系統狀態
            return true
        }
    }

LogAnalyzer 記住了最後一次的驗證結果。因為 WasLastFileNameValid 是基於另一個方法被呼叫的情況,你需要用另外的方法來驗證這段邏輯是否正常。

    [Test]
    public void IsValidFileName_WhenCallid_ChangesWasLastFileNameValid()
    {
        LogAnalyzer la = MakeAnalyzer();
        la.IsValidLogFileName("badname.foo");
        Assert.False(la.WasLastFileNameValid); // 驗證系統狀態
    }
  • 注意:這裡測試的是 IsValidLogFileName 的功能,卻又對另一段程式碼進行驗證。

命名的延伸:

  • 如果沒有前置動作而有預期的回傳值,可以用 ByDefault,如 Sum_ByDefault_ReturnsZero
  • 針對第二種或第三種結果(改變狀態 或 呼叫第三方系統),如果不需要進行前置設定,可以用 WhenCallAlways。如 Sum_WhenCalled_CallsTheLoggerSum_Always_CallsTheLogger

小結

在本章中,我們學會用 NUnit 來撰寫第一個測試程式;利用 [TestCase]、[SetUp] 和 [TearDown] 來確保狀態乾淨;使用工廠模式來讓測試更好維護;使用 [Ignore] 來略過有問題的測試;測試分類 [Category] 來分類,如:Fast Tests;Assert.Catch() 確保程式在預期時候拋出例外;我們還討論了,如果測試的物件方法並非回傳值,你必須測試物件互動後的系統狀態。

最後,記住以下幾點:

  • 建立測試類別、專案和方法的慣例:對每個待測試類別建立對應的測試類別,對每個帶測試的專案建立一個測試專案,對每個工作單元建立至少一個測試方法。
  • 使用 [UnitOfWorkName]_[ScenarioUnderTest]_[ExpectedBehavior] 讓測試命名一針見血。
  • 使用工廠模式來重用程式
  • 盡量不要在單元測試中用到 [SetUp] 和 [TearDown],它們會讓可讀性變差,多加利用工廠模式。

這章好累好多,也好充實!(寫到我腰痛... orz )在下一章「Chapter 3: 透過虛設常式解決依賴問題」,我們要討論真實的測試情境。討論假物件、模擬物件與虛設常式的概念。

#Unit Test #單元測試的藝術







你可能感興趣的文章

[讀書筆記 Flutter 實戰 002] Flutter 簡介

[讀書筆記 Flutter 實戰 002] Flutter 簡介

CS50 TCP (Transmission Control Protocol)

CS50 TCP (Transmission Control Protocol)

[PHP] 物件導向 PHP 入門

[PHP] 物件導向 PHP 入門






留言討論