本章帶領讀者從零開始,使用 NUnit 框架撰寫第一個單元測試。從框架的選擇、安裝,到測試的撰寫、命名、重構,最後擴展到測試系統狀態變化,涵蓋了入門所需的全部基礎知識。

單元測試框架#

手動測試既費時又容易出錯——你寫完程式碼後在 debugger 中執行、手動按鍵驗證,下次修改時又得重複一遍。單元測試框架就是為了解決這個問題而生的工具,它讓開發者能夠快速撰寫、自動執行、並檢閱測試結果。

框架解決的痛點#

在沒有框架的情況下,測試往往存在以下問題:

  • 缺乏結構:每次測試都得重新發明輪子,有的用 console app,有的用 web form,不統一也不好維護
  • 無法重複執行:團隊成員無法輕鬆重跑你寫過的測試,無法持續抓到迴歸 bug
  • 覆蓋不足:因為寫測試太麻煩,開發者傾向少寫,導致重要邏輯沒被覆蓋

Figure 2.1: Unit tests are written as code, using libraries from the unit testing framework

框架提供的三大能力#

能力框架如何幫助
撰寫測試提供 base class、attribute、assertion 方法等結構化工具
執行測試提供 test runner(GUI 或 console),自動辨識並執行測試
檢閱結果顯示通過/失敗數量、失敗原因、stack trace 等資訊

xUnit 框架家族#

這類框架統稱為 xUnit 框架,因為命名慣例是在語言名稱前加上字母:CppUnit(C++)、JUnit(Java)、NUnit(.NET)、HUnit(Haskell)。本書使用的是 NUnit,它源自 JUnit 的移植,但後來在設計和可用性上做了大幅改進。

使用單元測試框架不代表你寫出的測試就一定是好的。測試是否 可讀(readable)、可維護(maintainable)、可信賴(trustworthy),需要額外的設計功夫,這是本書後續章節的重點。

LogAn 專案介紹#

本書用來示範測試技巧的專案叫做 LogAn(Log and Notification 的縮寫)。情境是:公司有許多產品會在客戶端產生 log 檔案,這些 log 使用專有格式,無法被現成工具解析。LogAn 的任務就是分析這些 log 檔案,找出特殊事件並通知相關人員。

這個專案從一個簡單的類別開始,隨著章節推進會逐步擴展新的類別和功能。

NUnit 初體驗#

安裝 NUnit#

最簡單的安裝方式是透過 NuGet(Visual Studio 的套件管理工具):

Install-Package NUnit

安裝完成後,專案會自動加入 NUnit.Framework.dll 的參考。若需要 NUnit GUI runner,可以額外安裝 NUnit.Runners

Figure 2.2: The NUnit GUI is divided into three main parts

載入方案與待測程式碼#

本章的待測類別是一個簡單的 LogAnalyzer,其中有一個方法 IsValidLogFileName 用來判斷檔案名稱是否為合法的 log 檔(以副檔名 .SLF 判斷):

public class LogAnalyzer
{
    public bool IsValidLogFileName(string fileName)
    {
        if (fileName.EndsWith(".SLF"))
        {
            return false;  // 故意留了 bug:少了 !
        }
        return true;
    }
}

作者故意在程式碼中留了一個 bug(if 條件少了 !),讓讀者能看到測試失敗時 test runner 的呈現方式。

使用 NUnit 屬性#

NUnit 透過 attribute(屬性) 來辨識和載入測試,就像書籤幫框架標記哪些是測試類別、哪些是測試方法。最基本的兩個屬性:

  • [TestFixture]:標記一個類別包含自動化測試
  • [Test]:標記一個方法是可被執行的測試方法
[TestFixture]
public class LogAnalyzerTests
{
    [Test]
    public void IsValidFileName_BadExtension_ReturnsFalse()
    {
    }
}

NUnit 要求測試方法必須是 public、回傳 void、且不接受參數(基本情況下)。但後面會看到參數化測試是例外。

撰寫第一個測試#

一個單元測試通常包含三個步驟,即 AAA 模式

  1. Arrange(準備):建立物件、設定初始狀態
  2. Act(執行):呼叫待測方法
  3. Assert(驗證):確認結果符合預期
flowchart LR
    A["<b>Arrange</b><br/>準備物件與狀態"] --> B["<b>Act</b><br/>呼叫待測方法"]
    B --> C["<b>Assert</b><br/>驗證結果"]
[Test]
public void IsValidFileName_BadExtension_ReturnsFalse()
{
    LogAnalyzer analyzer = new LogAnalyzer();

    bool result = analyzer.IsValidLogFileName("filewithbadextension.foo");

    Assert.False(result);
}

Assert 類別#

Assert 類別位於 NUnit.Framework 命名空間,是測試程式碼與框架之間的橋樑。常用方法包括:

  • Assert.True(condition) / Assert.False(condition):驗證布林值
  • Assert.AreEqual(expected, actual):驗證兩個值相等
  • Assert.AreSame(expected, actual):驗證兩個參考指向同一物件

所有 assert 方法都有一個可選的 message 參數,但請不要使用它。你的測試名稱本身就應該清楚說明測試意圖。寫 “test failed” 或 “expected x instead of y” 這種訊息毫無意義——框架本身就會提供這些資訊。

執行測試與觀察結果#

執行測試的方式有多種:NUnit GUI、Visual Studio Test Runner(搭配 NUnit Test Adapter)、ReSharper、TestDriven.NET 等。

Figure 2.3: NUnit test failures are shown in three places

當測試失敗時,NUnit GUI 會在三個地方呈現:左側樹狀結構變紅、上方進度條變紅、右側顯示錯誤訊息。

正面測試(Positive Tests)#

修正 bug 後,還需要加入正面測試來驗證合法的檔案名稱(包含大小寫):

[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);
}

為了讓小寫的測試也能通過,production code 需要改用不區分大小寫的比對:

if (!fileName.EndsWith(".SLF", StringComparison.CurrentCultureIgnoreCase))
{
    return false;
}

從紅到綠:通過測試#

NUnit GUI 的設計理念是 Red-Green-Refactor:先寫一個失敗的測試(紅),修正程式碼使其通過(綠),再重構讓程式碼更好。這是測試驅動開發(TDD)的核心循環。

測試命名風格#

測試方法的命名應採用三段式結構: [UnitOfWork]_[Scenario]_[ExpectedBehavior]

  • UnitOfWork:被測試的方法或功能群組名稱
  • Scenario:測試的前置條件或輸入情境(如 “bad login”、“empty filename”)
  • ExpectedBehavior:預期結果(回傳值、狀態改變、或呼叫第三方)

測試程式碼風格#

  • 測試名稱可以很長,用底線分隔各段,確保包含所有重要資訊
  • Arrange、Act、Assert 三個階段之間用空行分隔,增加可讀性
  • 盡量把 assert 與 act 分開——先把結果存到變數,再 assert 該變數

重構為參數化測試#

當多個測試只有輸入值不同、邏輯完全一致時,維護成本會隨測試數量增加。例如修改 LogAnalyzer 的建構式簽章,所有測試都得跟著改。

NUnit 提供 參數化測試(Parameterized Tests) 來解決這個問題。重構步驟:

  1. [Test] 替換為 [TestCase]
  2. 把硬編碼的值抽成方法參數
  3. 將測試值移到 [TestCase(param1, param2)] 屬性中
  4. 重新命名方法為更通用的名稱
  5. 為每組測試資料加上一個 [TestCase] 屬性
  6. 刪除重複的測試方法
[TestCase("filewithgoodextension.SLF")]
[TestCase("filewithgoodextension.slf")]
public void IsValidLogFileName_ValidExtensions_ReturnsTrue(string file)
{
    LogAnalyzer analyzer = new LogAnalyzer();

    bool result = analyzer.IsValidLogFileName(file);

    Assert.True(result);
}

進一步可以加入多個參數,把正面和負面測試合併:

[TestCase("filewithgoodextension.SLF", true)]
[TestCase("filewithgoodextension.slf", true)]
[TestCase("filewithbadextension.foo", false)]
public void IsValidLogFileName_VariousExtensions_ChecksThem(
    string file, bool expected)
{
    LogAnalyzer analyzer = new LogAnalyzer();

    bool result = analyzer.IsValidLogFileName(file);

    Assert.AreEqual(expected, result);
}

過度合併會讓測試名稱變得過於泛化,難以一眼看出測試意圖。參數值本身必須簡單且自我說明,讓讀者不用深入程式碼就能理解每個 test case 在驗證什麼。

更多 NUnit 屬性#

Setup 與 Teardown#

每個測試都應該從乾淨的狀態開始。如果前一個測試殘留的資料影響了下一個測試,會導致難以追蹤的 bug(測試在單獨執行時通過,但和其他測試一起跑就失敗)。

Figure 2.4: NUnit performs setup and teardown actions before and after each test

NUnit 提供兩個屬性來控制測試的生命週期:

  • [SetUp]:在每個測試方法執行之前呼叫
  • [TearDown]:在每個測試方法執行之後呼叫
using NUnit.Framework;

[TestFixture]
public class LogAnalyzerTests
{
    private LogAnalyzer m_analyzer = null;

    [SetUp]
    public void 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!");
    }

    [TearDown]
    public void TearDown()
    {
        m_analyzer = null;  // 反模式,實務上不需要這樣做
    }
}

Figure 2.5: How NUnit calls SetUp and TearDown with multiple unit tests in the same class

作者建議在實務中不要使用 [SetUp] 來初始化物件。過度使用 SetUp 會降低測試的可讀性,因為讀者必須在兩個不同的程式碼區塊之間來回跳轉才能理解測試。建議改用 factory method 來建立待測物件。

同樣地,在單元測試專案中幾乎不需要 [TearDown]。如果你發現需要清理檔案系統或資料庫,那很可能你寫的是整合測試,不是單元測試。

另外還有 [TestFixtureSetUp][TestFixtureTearDown],它們在整個 test fixture(類別)的層級只執行一次,適用於耗時的初始化工作。

預期例外測試#

有時需要驗證程式碼在特定情況下會拋出例外。NUnit 提供兩種方式:

方式一:[ExpectedException] 屬性(不建議使用)

[Test]
[ExpectedException(typeof(ArgumentException),
    ExpectedMessage = "filename has to be provided")]
public void IsValidFileName_EmptyFileName_ThrowsException()
{
    m_analyzer.IsValidLogFileName(string.Empty);
}

這種方式的問題在於:框架會用一個大的 try-catch 包住整個測試方法,你無法確定是哪一行拋出了例外。如果建構式有 bug 也拋出同類型的例外,測試照樣會通過,但這完全不是你要測試的東西。

方式二:Assert.Catch(推薦)

[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);
}

推薦使用 Assert.Catch 而非 [ExpectedException],原因:

  • 可以精確控制哪一行程式碼應該拋出例外
  • 可以取得例外物件,進一步驗證其 Message 等屬性
  • 使用 StringAssert.Contains 而非 Assert.AreEqual 比對訊息,讓測試更不容易因為訊息微調而壞掉

忽略測試#

當某個測試暫時壞掉但需要先 check in 程式碼時,可以用 [Ignore] 屬性標記:

[Test]
[Ignore("there is a problem with this test")]
public void IsValidFileName_ValidFile_ReturnsTrue()
{
    /// ...
}

Figure 2.6: In NUnit, an ignored test is marked in yellow

被忽略的測試在 NUnit GUI 中會以黃色顯示,提醒開發者這是需要修復的技術債。

Fluent Syntax#

NUnit 也支援 fluent 語法,以 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"));
}

作者個人偏好傳統的 Assert.Something() 語法而非 fluent 語法,認為前者更簡潔。但最重要的是在整個測試專案中保持一致

測試分類(Test Categories)#

可以用 [Category] 屬性將測試分組,例如區分快速測試和慢速測試:

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

Figure 2.7: You can set up categories of tests and choose a particular category to run

在 NUnit GUI 中切換到 Categories 分頁,就可以選擇只執行特定分類的測試。

測試系統狀態變化#

到目前為止,我們測試的都是方法的回傳值。但如果方法不回傳任何東西,而是改變了物件的內部狀態呢?這時就需要用 State-based testing(狀態驗證)

狀態驗證(State-based testing / State verification):透過檢查被測系統及其協作者在方法執行後的行為變化,來判斷方法是否正確運作。

LogAn 範例:驗證屬性變化#

假設 LogAnalyzer 新增了一個屬性 WasLastFileNameValid,用來記錄上次驗證結果:

public class LogAnalyzer
{
    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;
    }
}

測試時,我們不是檢查回傳值,而是檢查物件的屬性狀態

[TestCase("badfile.foo", false)]
[TestCase("goodfile.slf", true)]
public void IsValidFileName_WhenCalled_ChangesWasLastFileNameValid(
    string file, bool expected)
{
    LogAnalyzer la = MakeAnalyzer();

    la.IsValidLogFileName(file);

    Assert.AreEqual(expected, la.WasLastFileNameValid);
}

MemCalculator 範例:驗證累計狀態#

另一個例子是 MemCalculator 類別,它的 Sum() 方法回傳累加結果後會重置內部狀態:

[Test]
public void Sum_ByDefault_ReturnsZero()
{
    MemCalculator calc = MakeCalc();

    int lastSum = calc.Sum();

    Assert.AreEqual(0, lastSum);
}

[Test]
public void Add_WhenCalled_ChangesSum()
{
    MemCalculator calc = MakeCalc();

    calc.Add(1);
    int sum = calc.Sum();

    Assert.AreEqual(1, sum);
}

測試命名的情境詞彙建議:

  • ByDefault:沒有前置動作時的預設回傳值(如 Sum_ByDefault_ReturnsZero
  • WhenCalled / Always:狀態變化或第三方呼叫沒有特殊前置條件時使用

注意這裡使用了 factory methodMakeCalc())來建立物件,而不是 [SetUp]。好處是:

  • 程式碼更簡短、可讀性更高
  • 物件的初始化方式一目了然
  • 如果建構式簽章改變,只需改一處

總結#

本章介紹了使用 NUnit 撰寫單元測試的完整流程。以下是關鍵要點:

  • 每個被測試的類別對應一個測試類別,每個被測試的專案對應一個測試專案,每個 unit of work 至少一個測試方法
  • 測試命名遵循 [UnitOfWork]_[Scenario]_[ExpectedBehavior] 模式
  • 使用 factory method 來建立和初始化測試物件,避免使用 [SetUp][TearDown]
  • 使用 Assert.Catch 而非 [ExpectedException] 來測試例外
  • 使用 [TestCase] 進行參數化測試,減少重複程式碼
  • 除了測試回傳值,也要學會測試系統狀態變化

下一章將進入更真實的場景:待測程式碼存在外部依賴(檔案系統、資料庫、web service),需要使用 fakes、mocks、stubs 等技巧來隔離這些依賴。