FluentAssertions 測試斷言指南
用途:讓單元測試的斷言(Assert)語法像讀英文一樣自然,並提供精準的失敗訊息
NuGet:
FluentAssertions(建議使用 v7.x)授權:v7.x Apache-2.0(免費)/ v8.x 商用付費
支援:.NET 6+、.NET Framework 4.7+
為什麼斷言要 Fluent
看一組同樣的測試,比較內建 Assert 和 FluentAssertions:
// ❌ xUnit 內建 Assert:失敗訊息不明確
Assert.Equal(25.3, temperature);
// 失敗訊息:Assert.Equal() Failure
// Expected: 25.3
// Actual: 85.1
// ✅ FluentAssertions:失敗訊息帶有上下文
temperature.Should().Be(25.3);
// 失敗訊息:Expected temperature to be 25.3, but found 85.1.
// ✅ 加上理由更清楚
temperature.Should().BeInRange(20.0, 30.0,
"設備溫度應在安全運作範圍內");
// 失敗訊息:Expected temperature to be between 20.0 and 30.0
// because 設備溫度應在安全運作範圍內, but found 85.1.
差異在於:
- 失敗訊息包含變數名稱:知道是哪個值出了問題
- 可以附上
because理由:說明為什麼這個斷言重要 - 語法自然:
temperature.Should().BeInRange(20, 30)比Assert.InRange(temperature, 20, 30)更好讀 - 鏈式呼叫:一行可以做多個斷言
授權說明
重要:v7 vs v8 授權差異
FluentAssertions 從 v8.0 開始改為商用付費授權。
| 版本 | 授權 | 費用 |
|---|---|---|
| v7.x | Apache-2.0 | 免費 |
| v8.x | 商業授權 | 年費制(依團隊規模) |
公司建議:使用 v7.x(最新為 7.0.0),功能已經非常完整。v8 的新功能(改進的 equivalency 比較、更好的 nullable 支援)在公司場景中不是剛需。如果未來需要 v8 功能,再評估授權成本。
安裝時鎖定版本:
dotnet add package FluentAssertions --version 7.0.0
安裝
# 建議鎖定 v7 版本
dotnet add package FluentAssertions --version 7.0.0
# 如果使用 xUnit(公司標準)
dotnet add package xunit
dotnet add package FluentAssertions # 自動整合 xUnit
核心語法
所有斷言都從 .Should() 開始,接上特定的斷言方法。FluentAssertions 針對不同類型提供了專用的斷言 API。
數值斷言
var temperature = 25.3;
var pressure = 101.3;
var errorCount = 0;
// 精確比較
temperature.Should().Be(25.3);
errorCount.Should().Be(0);
// 範圍比較
temperature.Should().BeGreaterThan(20.0);
temperature.Should().BeLessThanOrEqualTo(30.0);
temperature.Should().BeInRange(20.0, 30.0, "溫度應在安全範圍內");
// 近似比較(浮點數避免精度問題)
pressure.Should().BeApproximately(101.325, precision: 0.1);
// 正負判斷
temperature.Should().BePositive();
errorCount.Should().Be(0);
字串斷言
var deviceId = "CMP-01-BAY3";
var errorMessage = "PLC communication timeout at 192.168.1.10:502";
string nullString = null;
// 精確比較
deviceId.Should().Be("CMP-01-BAY3");
// 包含
errorMessage.Should().Contain("timeout");
errorMessage.Should().Contain("192.168.1.10");
// 前後綴
deviceId.Should().StartWith("CMP");
deviceId.Should().EndWith("BAY3");
// 正規表達式
deviceId.Should().MatchRegex(@"^[A-Z]{3}-\d{2}-BAY\d+$");
// Null / Empty
nullString.Should().BeNull();
deviceId.Should().NotBeNullOrEmpty();
布林斷言
var isConnected = true;
var hasError = false;
isConnected.Should().BeTrue("設備應該已連線");
hasError.Should().BeFalse("不應有錯誤狀態");
日期時間斷言
var lastMaintenanceDate = new DateTime(2026, 3, 15);
var now = DateTime.Now;
lastMaintenanceDate.Should().BeBefore(now);
lastMaintenanceDate.Should().BeAfter(new DateTime(2026, 1, 1));
// 近似比較(允許誤差)
var timestamp = DateTime.UtcNow;
timestamp.Should().BeCloseTo(DateTime.UtcNow, precision: TimeSpan.FromSeconds(1));
// 日期部分比較
lastMaintenanceDate.Should().HaveYear(2026);
lastMaintenanceDate.Should().HaveMonth(3);
Null 斷言
DeviceStatus status = null;
DeviceStatus activeStatus = new DeviceStatus();
status.Should().BeNull();
activeStatus.Should().NotBeNull();
鏈式斷言
FluentAssertions 的設計核心是鏈式 API。每次 .Should() 取得一個 Assertion 物件,斷言方法執行後可透過 .And 繼續同一個 Subject 的斷言,或透過 .Which 進入子物件繼續鏈下去:
兩個關鍵詞的差別:
.And:繼續對同一個 Subject 加更多斷言(最常見).Which:取上一個斷言的回傳值(通常是子物件)作為新的 Subject
對應的程式碼:
// .And 用法:對同一個字串加多個斷言
var deviceName = "CMP-01";
deviceName.Should()
.NotBeNullOrEmpty()
.And.StartWith("CMP")
.And.HaveLength(6);
// .Which 用法:對子物件繼續斷言
var devices = new[] {
new Device { Id = "CMP-01", Status = "Running" },
new Device { Id = "CVD-01", Status = "Idle" }
};
devices.Should()
.Contain(d => d.Id == "CMP-01")
.Which.Status.Should().Be("Running");
這比寫三行 Assert. 更簡潔,而且一旦第一個斷言失敗就會立即回報,不會浪費時間執行後續的斷言。
錯誤訊息範例
FluentAssertions 最大的價值在於失敗時的診斷訊息。失敗時的訊息組裝路徑大致如下:
關鍵點:
- Subject 名稱來自表達式樹:FluentAssertions 透過 caller-info 取得「
temperature」這個變數名稱,所以失敗訊息能告訴你是哪個變數出問題 because不是給人看的註解:它會直接拼進失敗訊息中,建議寫為什麼這個斷言重要而不是重述斷言內容- 失敗即停:訊息組好就拋例外,鏈中後續的
.And/.Which不會再執行
比較內建 Assert 與 FluentAssertions 的失敗訊息:
// xUnit Assert 失敗訊息
Assert.Equal() Failure
Expected: 25.3
Actual: 85.1
// FluentAssertions 失敗訊息
Expected temperature to be 25.3 because 溫度應在安全範圍內, but found 85.1.
Difference of 59.8 with a tolerance of 0.
集合斷言的失敗訊息更有價值:
// xUnit
Assert.Contains() Failure
Collection: [DeviceStatus { Id = "CMP-01" }, DeviceStatus { Id = "CVD-01" }]
Expected item: DeviceStatus { Id = "ETH-01" }
// FluentAssertions
Expected sut.Devices {DeviceStatus { Id = "CMP-01" }, DeviceStatus { Id = "CVD-01" }}
to contain an item matching (d.Id == "ETH-01") because 所有設備應該被監控,
but no such item was found.
延伸閱讀
- 進階用法與公司場景 — 集合斷言、物件斷言、例外斷言、執行時間、自訂斷言、搭配 NSubstitute
- NSubstitute 測試替身 — 搭配使用的 Mock 框架