iT邦幫忙

2026 iThome 鐵人賽

DAY 15
1

Day14_使用 Fluent Assertions 改善測試可讀性

前言

上一篇談 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 與 Fluent Assertions 的差別

若只使用 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 準備測試資料。

參考資料


上一篇
Day13_什麼是規格驅動開發(SDD)?
下一篇
Day15_用 Moq 與 Bogus 產生模擬物件與資料
系列文
Codex的規格驅動開發 :30 天打造 .NET 內部專案管理系統16
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言