メインコンテンツまでスキップ

FluentAssertions 測試斷言指南

用途:讓單元測試的斷言(Assert)語法像讀英文一樣自然,並提供精準的失敗訊息

NuGetFluentAssertions(建議使用 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.

差異在於:

  1. 失敗訊息包含變數名稱:知道是哪個值出了問題
  2. 可以附上 because 理由:說明為什麼這個斷言重要
  3. 語法自然temperature.Should().BeInRange(20, 30)Assert.InRange(temperature, 20, 30) 更好讀
  4. 鏈式呼叫:一行可以做多個斷言

授權說明

重要:v7 vs v8 授權差異

FluentAssertions 從 v8.0 開始改為商用付費授權。

版本授權費用
v7.xApache-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.

延伸閱讀