本章帶領讀者從零開始,使用 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 模式:
- Arrange(準備):建立物件、設定初始狀態
- Act(執行):呼叫待測方法
- 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) 來解決這個問題。重構步驟:
- 將
[Test]替換為[TestCase] - 把硬編碼的值抽成方法參數
- 將測試值移到
[TestCase(param1, param2)]屬性中 - 重新命名方法為更通用的名稱
- 為每組測試資料加上一個
[TestCase]屬性 - 刪除重複的測試方法
[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 method(MakeCalc())來建立物件,而不是 [SetUp]。好處是:
- 程式碼更簡短、可讀性更高
- 物件的初始化方式一目了然
- 如果建構式簽章改變,只需改一處
總結#
本章介紹了使用 NUnit 撰寫單元測試的完整流程。以下是關鍵要點:
- 每個被測試的類別對應一個測試類別,每個被測試的專案對應一個測試專案,每個 unit of work 至少一個測試方法
- 測試命名遵循
[UnitOfWork]_[Scenario]_[ExpectedBehavior]模式 - 使用 factory method 來建立和初始化測試物件,避免使用
[SetUp]和[TearDown] - 使用
Assert.Catch而非[ExpectedException]來測試例外 - 使用
[TestCase]進行參數化測試,減少重複程式碼 - 除了測試回傳值,也要學會測試系統狀態變化
下一章將進入更真實的場景:待測程式碼存在外部依賴(檔案系統、資料庫、web service),需要使用 fakes、mocks、stubs 等技巧來隔離這些依賴。