Skip to main content

進階用法與公司場景

集合斷言

集合(List、Array、IEnumerable)是工業場景中最常需要驗證的類型 — Recipe 清單、警報列表、設備狀態等。

var recipes = new List<Recipe>
{
new() { Name = "Standard Clean", CreatedAt = new DateTime(2026, 1, 10) },
new() { Name = "Deep Clean", CreatedAt = new DateTime(2026, 2, 15) },
new() { Name = "Quick Rinse", CreatedAt = new DateTime(2026, 3, 20) },
};

// 數量
recipes.Should().HaveCount(3);
recipes.Should().NotBeEmpty();
recipes.Should().HaveCountGreaterThan(1);

// 包含判斷
recipes.Should().Contain(r => r.Name == "Standard Clean");
recipes.Should().NotContain(r => r.Name == "Deprecated Process");

// 全部符合條件
recipes.Should().OnlyContain(r => r.CreatedAt.Year == 2026);

// 排序
recipes.Should().BeInAscendingOrder(r => r.CreatedAt);

// 唯一性
recipes.Select(r => r.Name).Should().OnlyHaveUniqueItems();

警報列表驗證

[Fact]
public void ActiveAlarms_ShouldOnlyContainCriticalAndWarning()
{
var alarms = sut.GetActiveAlarms();

alarms.Should().NotBeEmpty("設備運作中應有監控警報");
alarms.Should().OnlyContain(
a => a.Severity >= AlarmSeverity.Warning,
"Active 警報不應包含 Info 級別");
alarms.Should().BeInDescendingOrder(a => a.Severity,
"應按嚴重程度排序,最嚴重的在最前面");
}

集合元素逐一驗證

var registerValues = await modbusClient.ReadHoldingRegistersAsync(0, 3);

registerValues.Should().SatisfyRespectively(
first => first.Should().BeInRange((ushort)200, (ushort)300, "溫度暫存器"),
second => second.Should().BeInRange((ushort)900, (ushort)1100, "壓力暫存器"),
third => third.Should().Be((ushort)1, "狀態暫存器應為 Running")
);

物件斷言

驗證複雜物件時,BeEquivalentTo 是最強大的工具 — 它做的是結構比較而非參考比較。

[Fact]
public async Task GetDeviceStatus_ShouldReturnCorrectStatus()
{
var status = await sut.GetStatusAsync("CMP-01");

// 驗證物件結構(不是同一個 reference,而是值相等)
status.Should().BeEquivalentTo(new
{
DeviceId = "CMP-01",
IsConnected = true,
Mode = OperationMode.Auto,
Temperature = 25.5
}, options => options
.Excluding(s => s.LastUpdated) // 排除時間戳(每次不同)
.Excluding(s => s.InternalState)); // 排除內部狀態
}

排除與自訂比較

// 排除特定屬性
actualRecipe.Should().BeEquivalentTo(expectedRecipe, options => options
.Excluding(r => r.Id) // DB 自動生成
.Excluding(r => r.CreatedAt) // 時間戳
.Excluding(r => r.UpdatedAt));

// 集合元素的排除
actualDevices.Should().BeEquivalentTo(expectedDevices, options => options
.For(d => d.Alarms)
.Exclude(a => a.Timestamp));

// 允許數值誤差
actualPosition.Should().BeEquivalentTo(expectedPosition, options => options
.Using<double>(ctx =>
ctx.Subject.Should().BeApproximately(ctx.Expectation, 0.001))
.WhenTypeIs<double>());

例外斷言

驗證方法在特定條件下是否正確拋出例外:

[Fact]
public async Task Connect_WithInvalidIp_ShouldThrowDeviceConnectionException()
{
var act = () => sut.ConnectAsync("invalid-ip");

await act.Should().ThrowAsync<DeviceConnectionException>()
.WithMessage("*connection refused*")
.Where(ex => ex.DeviceId == "CMP-01");
}

[Fact]
public void SetTemperature_BelowAbsoluteZero_ShouldThrowArgumentException()
{
var act = () => sut.SetTargetTemperature(-300);

act.Should().Throw<ArgumentOutOfRangeException>()
.WithParameterName("temperature");
}

[Fact]
public async Task ReadRegister_WhenConnected_ShouldNotThrow()
{
var act = () => sut.ReadRegisterAsync(0);

await act.Should().NotThrowAsync();
}

巢狀例外

[Fact]
public async Task Initialize_WhenPlcFails_ShouldWrapException()
{
var act = () => sut.InitializeAsync();

await act.Should().ThrowAsync<EquipmentInitException>()
.WithInnerException<TimeoutException>()
.WithMessage("*PLC*timeout*");
}

執行時間斷言(效能測試)

驗證操作是否在預期時間內完成,適合測試通訊效能和回應時間:

[Fact]
public async Task ReadRegisters_ShouldCompleteWithin2Seconds()
{
var act = () => sut.ReadRegistersAsync(0, 100);

await act.Should().CompleteWithinAsync(TimeSpan.FromSeconds(2),
"Modbus 批次讀取 100 個暫存器應在 2 秒內完成");
}

[Fact]
public void RecipeValidation_ShouldBeFast()
{
var recipe = CreateLargeRecipe(steps: 500);
var act = () => sut.Validate(recipe);

act.ExecutionTime().Should().BeLessThan(TimeSpan.FromMilliseconds(100),
"500 步 Recipe 的驗證不應超過 100ms");
}
使用場景

執行時間斷言不是取代正式的效能測試工具(如 BenchmarkDotNet),而是作為防護網 — 確保關鍵操作不會因為程式碼變更而意外變慢。設定合理的寬鬆上限(例如 2x 預期時間),避免在 CI 環境因硬體差異造成 flaky test。


自訂斷言(Custom Assertions)

為公司特定的領域物件建立專用斷言,讓測試更具可讀性:

public static class AlarmAssertionExtensions
{
public static AlarmAssertions Should(this Alarm alarm)
{
return new AlarmAssertions(alarm);
}
}

public class AlarmAssertions
{
private readonly Alarm _alarm;

public AlarmAssertions(Alarm alarm)
{
_alarm = alarm;
}

public AlarmAssertions BeCritical(string because = "", params object[] becauseArgs)
{
_alarm.Severity.Should().Be(AlarmSeverity.Critical, because, becauseArgs);
return this;
}

public AlarmAssertions BeFromSource(string source, string because = "", params object[] becauseArgs)
{
_alarm.Source.Should().Be(source, because, becauseArgs);
return this;
}

public AlarmAssertions BeActive(string because = "", params object[] becauseArgs)
{
_alarm.IsActive.Should().BeTrue(because, becauseArgs);
return this;
}
}

使用自訂斷言:

[Fact]
public async Task WhenPlcDisconnects_ShouldRaiseCriticalAlarm()
{
var alarm = await sut.GetLatestAlarmAsync();

// 自訂斷言:讀起來像自然語言
alarm.Should()
.BeCritical("PLC 斷線應為 Critical 級別")
.And.BeFromSource("PLC-Communication")
.And.BeActive();
}

搭配 NSubstitute:完整測試範例

結合 NSubstitute(建立替身)和 FluentAssertions(斷言),寫出完整的設備控制測試:

public class MonitorServiceTests
{
private readonly IModbusClient _plc;
private readonly IAlarmService _alarmService;
private readonly ILogger<MonitorService> _logger;
private readonly MonitorService _sut;

public MonitorServiceTests()
{
_plc = Substitute.For<IModbusClient>();
_alarmService = Substitute.For<IAlarmService>();
_logger = Substitute.For<ILogger<MonitorService>>();
_sut = new MonitorService(_plc, _alarmService, _logger);
}

[Fact]
public async Task PollOnce_WhenPlcDisconnects_ShouldRaiseCriticalAlarm()
{
// Arrange — PLC 通訊逾時
_plc.ReadHoldingRegistersAsync(Arg.Any<int>(), Arg.Any<int>())
.ThrowsAsync(new TimeoutException("PLC timeout"));

// Act
await _sut.PollOnceAsync();

// Assert — 驗證警報被觸發(NSubstitute)
await _alarmService.Received(1)
.RaiseAlarmAsync(Arg.Is<Alarm>(a =>
a.Severity == AlarmSeverity.Critical &&
a.Source == "PLC"));
}

[Fact]
public async Task PollOnce_ShouldReturnAllSensorReadings()
{
// Arrange — 模擬三個感測器的暫存器值
_plc.ReadHoldingRegistersAsync(0, 3)
.Returns(new ushort[] { 253, 1013, 500 });

// Act
var readings = await _sut.PollOnceAsync();

// Assert — FluentAssertions 驗證回傳值
readings.Should().NotBeNull();
readings.Temperature.Should().BeApproximately(25.3, 0.01,
"溫度 = register[0] * 0.1");
readings.Pressure.Should().BeApproximately(101.3, 0.01,
"壓力 = register[1] * 0.1");
readings.FlowRate.Should().Be(500,
"流量 = register[2] 原始值");
}

[Fact]
public async Task PollOnce_WhenTemperatureNormal_ShouldNotRaiseAlarm()
{
// Arrange — 正常溫度
_plc.ReadHoldingRegistersAsync(Arg.Any<int>(), Arg.Any<int>())
.Returns(new ushort[] { 250, 1013, 500 });

// Act
await _sut.PollOnceAsync();

// Assert — 不應觸發任何警報
await _alarmService.DidNotReceive()
.RaiseAlarmAsync(Arg.Any<Alarm>());
}

[Fact]
public async Task GetDeviceReport_ShouldReturnStructuredData()
{
// Arrange
_plc.ReadHoldingRegistersAsync(0, 10)
.Returns(new ushort[] { 253, 1013, 500, 1, 0, 0, 0, 0, 0, 0 });
_plc.IsConnected.Returns(true);

// Act
var report = await _sut.GetDeviceReportAsync();

// Assert — 物件結構比較
report.Should().BeEquivalentTo(new
{
IsOnline = true,
SensorCount = 3,
Mode = OperationMode.Auto
}, options => options
.Excluding(r => r.Timestamp)
.Excluding(r => r.RawData));

report.Sensors.Should().HaveCount(3)
.And.SatisfyRespectively(
temp => temp.Name.Should().Be("Temperature"),
press => press.Name.Should().Be("Pressure"),
flow => flow.Name.Should().Be("FlowRate"));
}
}

測試輸出格式比較

最後用一個完整範例展示 FluentAssertions 在測試失敗時的訊息品質:

xUnit 內建 Assert

Xunit.Sdk.EqualException
Assert.Equal() Failure
↓ (pos 0)
Expected: [DeviceStatus { Id = "CMP-01", IsConnected = True, Mode = Auto },
DeviceStatus { Id = "CVD-01", IsConnected = True, Mode = Auto }]
Actual: [DeviceStatus { Id = "CMP-01", IsConnected = True, Mode = Manual },
DeviceStatus { Id = "CVD-01", IsConnected = False, Mode = Auto }]

FluentAssertions

Expected property report.Devices[0].Mode to be OperationMode.Auto
because 所有設備應在自動模式, but found OperationMode.Manual.

With configuration:
- Use declared types and members
- Compare enums by value
- Include all non-private properties
- Match member by name (or throw)

Context:
Expected member Devices[0].Mode to be OperationMode.Auto, but found OperationMode.Manual
Expected member Devices[1].IsConnected to be True, but found False

FluentAssertions 會告訴你哪個屬性、在哪個元素、期望什麼、實際是什麼,不用自己去比對兩個大物件的差異。


延伸閱讀