上一篇談 SDD 時,我把規格比喻成地圖:動手開發前,先寫清楚要去哪裡,以及怎樣才算抵達。程式寫完後,還需要一種方法替我們反覆核對結果,這就是自動化測試要做的事。
剛開始寫程式時,我常用「打開網頁、按幾個按鈕,看起來沒問題」當作測試。這種做法不是完全沒用,但每改一次程式就得全部重按,而且很容易忘記某個不起眼的舊功能。單元測試比較像一份會自己改作業的驗收清單:我先寫好題目、輸入和正確答案,電腦每次都照同一套規則檢查。
為了把注意力放在單元測試上,我準備了一個小型教學專案:UnitTestSample。它不是正式系統,只負責示範如何建立被測試的類別、撰寫測試,再從終端機確認結果。文中的檔名、命名空間與指令都能在這個 repository 找到,可以一邊閱讀,一邊實際操作。
.NET 有 NUnit、xUnit 與 MSTest 等測試框架。這次選擇 NUnit,再搭配 Fluent Assertions。兩者不是競爭對手,工作也不相同。
| 角色 | 負責的事情 |
|---|---|
dotnet test |
建置測試專案,啟動測試執行流程 |
| NUnit | 找出標有 [Test] 的方法,執行並判定測試是否通過 |
| Fluent Assertions | 表達「實際結果應該符合什麼條件」 |
可以把 NUnit 想成監考老師。它負責發考卷、計時和收卷;Fluent Assertions 則是答案卡,負責把「50 分才是正確答案」寫得清楚。只安裝 Fluent Assertions 並不會讓測試自己跑起來,還是要有 NUnit 這類測試框架。
本文使用 .NET 10,套件版本固定如下:
| 套件 | 版本 |
|---|---|
| NUnit | 4.3.2 |
| NUnit3TestAdapter | 5.0.0 |
| NUnit.Analyzers | 4.7.0 |
| Microsoft.NET.Test.Sdk | 17.14.0 |
| FluentAssertions | 7.2.2 |
我刻意使用 Fluent Assertions 7.2.2。Fluent Assertions 8 之後仍允許開源與非商業用途免費使用,但商業使用需要付費授權;公司專案升級前,應先確認目前授權是否符合使用情境。
電腦需要先安裝 .NET 10 SDK。接著開啟終端機,執行:
git clone https://github.com/JJDing-Louis/UnitTestSample.git
cd UnitTestSample
dotnet test UnitTestSample.slnx --configuration Release
專案刻意分成來源與測試兩部分:
UnitTestSample/
├── src/UnitTestSample/
│ ├── WorkItem.cs
│ ├── WorkItemStatus.cs
│ └── WorkItemProgressCalculator.cs
└── tests/UnitTestSample.Tests/
└── WorkItemProgressCalculatorTests.cs
src 放真正要交付的程式,tests 放負責檢查它的程式。測試專案透過 ProjectReference 參考來源專案,不需要把正式程式碼複製一份。
假設有一份工作清單,我們想知道目前完成了多少。清單裡有四個工作項目,其中兩個已完成,完成率就應該是 50%。這條規則不複雜,計算結果也很明確,很適合拿來認識單元測試。
先定義工作狀態。
src/UnitTestSample/WorkItemStatus.cs
namespace UnitTestSample;
public enum WorkItemStatus
{
Pending,
InProgress,
Blocked,
Completed
}
每個工作項目有編號、標題和狀態。
src/UnitTestSample/WorkItem.cs
namespace UnitTestSample;
public sealed class WorkItem
{
public int Id { get; init; }
public string Title { get; init; } = string.Empty;
public WorkItemStatus Status { get; init; }
}
最後是完成率計算器。
src/UnitTestSample/WorkItemProgressCalculator.cs
namespace UnitTestSample;
public sealed class WorkItemProgressCalculator
{
public decimal CalculateCompletionRate(IReadOnlyCollection<WorkItem> items)
{
ArgumentNullException.ThrowIfNull(items);
if (items.Count == 0)
{
return 0m;
}
int completedCount = items.Count(
item => item.Status == WorkItemStatus.Completed);
return completedCount * 100m / items.Count;
}
}
寫測試前,先把預期結果整理清楚:有資料時要算出正確比例;清單沒有任何工作時回傳 0;如果連清單都沒有傳進來,也就是收到 null,程式要丟出 ArgumentNullException。接下來的三個測試,就分別檢查這三種情況。
測試通常依 Arrange、Act、Assert 排列,也就是「準備資料、執行動作、核對答案」。可以想成先在桌上擺好四張工作卡,交給計算器統計,最後再拿預期答案來比對。
tests/UnitTestSample.Tests/WorkItemProgressCalculatorTests.cs
using FluentAssertions;
namespace UnitTestSample.Tests;
[TestFixture]
public sealed class WorkItemProgressCalculatorTests
{
[Test]
public void CalculateCompletionRate_WithTwoOfFourCompleted_ReturnsFiftyPercent()
{
// Arrange
WorkItem[] items =
[
new() { Id = 1, Title = "整理需求", Status = WorkItemStatus.Completed },
new() { Id = 2, Title = "設計 API", Status = WorkItemStatus.Completed },
new() { Id = 3, Title = "開發功能", Status = WorkItemStatus.InProgress },
new() { Id = 4, Title = "執行測試", Status = WorkItemStatus.Pending }
];
WorkItemProgressCalculator sut = new();
// Act
decimal result = sut.CalculateCompletionRate(items);
// Assert
result.Should().Be(50m);
}
}
sut 是 System Under Test 的縮寫,指這次真正要接受測試的物件。方法名稱雖然長,卻直接說明了三件事:測哪個方法、在什麼情況下執行、預期得到什麼結果。測試失敗時,只看名稱通常就知道是哪條規則出問題。
若只使用 NUnit,最後一行可以寫成:
Assert.That(result, Is.EqualTo(50m));
NUnit 4 建議使用這種 Constraint Model。第一個參數是實際結果,Is.EqualTo 描述預期條件。
Fluent Assertions 的寫法則是:
result.Should().Be(50m);
這一行接近「結果應該等於 50」的語序。只驗證一個數字時,兩種寫法的差距還不大;碰到集合、物件或例外,可讀性的差異就會比較明顯。例如測試準備了兩筆資料,並要檢查其中只有一筆完成,可以寫成:
WorkItem[] sampleItems =
[
new() { Status = WorkItemStatus.Completed },
new() { Status = WorkItemStatus.Pending }
];
sampleItems.Should()
.HaveCount(2)
.And.ContainSingle(item =>
item.Status == WorkItemStatus.Completed);
不過,能串在一起不代表什麼都要塞進同一條鏈。若兩個 Assert 在驗證不同業務規則,我會拆成不同測試。這樣某個測試失敗時,錯誤訊息才不會像一張同時圈了五題的答案卡,還得猜老師究竟在改哪一題。
只有順利情境還不夠。專案剛建立、尚未加入工作項目時,完成率應該顯示 0,而不是讓程式除以零。
[Test]
public void CalculateCompletionRate_WithNoItems_ReturnsZero()
{
// Arrange
WorkItemProgressCalculator sut = new();
// Act
decimal result = sut.CalculateCompletionRate([]);
// Assert
result.Should().Be(0m);
}
這就是邊界案例。它不一定經常發生,卻是程式容易出錯的地方。
空集合代表「目前有一份清單,只是裡面沒有資料」;null 則代表「連清單都沒有傳進來」。兩者意思不同,處理方式也不同。
[Test]
public void CalculateCompletionRate_WithNullItems_ThrowsArgumentNullException()
{
// Arrange
WorkItemProgressCalculator sut = new();
// Act
Action act = () => sut.CalculateCompletionRate(null!);
// Assert
act.Should()
.Throw<ArgumentNullException>()
.WithParameterName("items");
}
先把呼叫包成 Action,測試就能觀察它會丟出什麼例外。這裡不只檢查「有錯」,還確認例外型別與參數名稱。若只寫 Throw<Exception>(),條件太寬,程式即使因為另一個意外原因壞掉,測試也可能照樣通過。
我在 macOS 使用 .NET SDK 10.0.201 執行以下指令:
dotnet test UnitTestSample.slnx --configuration Release
實際結果為:
已通過! - 失敗: 0,通過: 3,略過: 0,總計: 3
我也另外執行 Release 非增量建置,結果是 0 個警告、0 個錯誤。這三個測試都不連資料庫、不讀寫檔案,也不呼叫網路服務,因此能快速、獨立且重複執行。
這份結果只證明目前範例在上述環境與版本通過,不代表所有 .NET 專案都能直接複製同一份設定。若你的專案使用不同 SDK、測試框架或套件版本,仍要以自己的專案檔與實際執行結果為準。
Test1 這類看不出用途的名稱。Fluent Assertions 能讓答案卡比較好讀,但它不會替我們決定該考哪些題目。真正重要的仍是回到 Day13 的規格,找出正常情境、邊界和錯誤條件,再把它們寫成可以重複執行的測試。
單元測試把規格中的預期行為變成可執行的檢查。NUnit 負責找到並執行測試,Fluent Assertions 則讓驗證條件更接近人平常閱讀句子的方式。
UnitTestSample 到這裡只示範一個不依賴外部服務的計算器。下一篇會把情境往前推,讓 Service 開始和 Repository、通知服務合作。為了不讓單元測試真的連資料庫或寄 Email,我們會用 Moq 建立替身,再用 Bogus 準備測試資料。