進階用法與公司場景
條件回傳(Arg.Any、Arg.Is)
根據不同參數回傳不同結果,模擬真實設備的多種回應:
var modbusClient = Substitute.For<IModbusClient>();
// 讀取不同 Register 區段回傳不同值
// Address 0~9:溫度區
modbusClient.ReadHoldingRegistersAsync(
Arg.Is<int>(addr => addr >= 0 && addr < 10),
Arg.Any<int>())
.Returns(new ushort[] { 253 }); // 25.3°C
// Address 10~19:壓力區
modbusClient.ReadHoldingRegistersAsync(
Arg.Is<int>(addr => addr >= 10 && addr < 20),
Arg.Any<int>())
.Returns(new ushort[] { 1013 }); // 101.3 kPa
// 超出範圍:Modbus Exception
modbusClient.ReadHoldingRegistersAsync(
Arg.Is<int>(addr => addr >= 100),
Arg.Any<int>())
.ThrowsAsync(new ModbusException("Illegal data address"));
例外模擬:通訊斷線
測試系統在通訊失敗時的行為:
[Fact]
public async Task PollOnce_WhenPlcTimeout_ShouldRaiseCriticalAlarm()
{
// Arrange — 模擬 PLC 通訊逾時
var modbusClient = Substitute.For<IModbusClient>();
modbusClient.ReadHoldingRegistersAsync(Arg.Any<int>(), Arg.Any<int>())
.ThrowsAsync(new TimeoutException("PLC communication timeout"));
var alarmService = Substitute.For<IAlarmService>();
var sut = new MonitorService(modbusClient, alarmService);
// Act
await sut.PollOnceAsync();
// Assert — 應該觸發 Critical 級別的通訊警報
await alarmService.Received(1).RaiseAlarmAsync(
Arg.Is<Alarm>(a =>
a.Severity == AlarmSeverity.Critical &&
a.Source == "PLC"));
}
[Fact]
public async Task Connect_WhenRefused_ShouldThrowDeviceConnectionException()
{
var modbusClient = Substitute.For<IModbusClient>();
modbusClient.ConnectAsync(Arg.Any<string>(), Arg.Any<int>())
.ThrowsAsync(new SocketException((int)SocketError.ConnectionRefused));
var sut = new DeviceConnectionService(modbusClient);
await Assert.ThrowsAsync<DeviceConnectionException>(
() => sut.ConnectToPlcAsync("192.168.1.10"));
}
回呼(Callback):驗證寫入值
當你需要驗證方法被呼叫時傳入了什麼參數,Arg.Do 比 Received 更靈活:
[Fact]
public async Task SetTemperature_ShouldWriteScaledValueToRegister()
{
var writtenValues = new List<ushort[]>();
var modbusClient = Substitute.For<IModbusClient>();
// Arg.Do 在方法被呼叫時執行回呼,擷取傳入的值
await modbusClient.WriteMultipleRegistersAsync(
Arg.Any<int>(),
Arg.Do<ushort[]>(values => writtenValues.Add(values)));
var sut = new TemperatureController(modbusClient);
// Act — 設定目標溫度 25.5°C
await sut.SetTargetTemperatureAsync(25.5);
// Assert — 驗證寫入的暫存器值是 255(25.5 / 0.1)
Assert.Single(writtenValues);
Assert.Equal(255, writtenValues[0][0]);
}
也可以用 When...Do 語法處理 void 方法:
var receivedAlarms = new List<Alarm>();
alarmService
.When(x => x.RaiseAlarmAsync(Arg.Any<Alarm>()))
.Do(call => receivedAlarms.Add(call.Arg<Alarm>()));
順序回傳:模擬設備狀態變化
真實設備的狀態會隨時間變化。用多個 .Returns() 參數模擬狀態轉換:
[Fact]
public async Task WaitForProcessComplete_ShouldPollUntilDone()
{
var modbusClient = Substitute.For<IModbusClient>();
// 模擬設備狀態變化序列
modbusClient.ReadHoldingRegistersAsync(100, 1)
.Returns(
new ushort[] { 0 }, // 第 1 次:Idle
new ushort[] { 1 }, // 第 2 次:Running
new ushort[] { 1 }, // 第 3 次:Running
new ushort[] { 2 }); // 第 4 次:Complete
var sut = new ProcessController(modbusClient);
// Act
var result = await sut.WaitForCompletionAsync(maxPolls: 10);
// Assert
Assert.True(result.IsCompleted);
Assert.Equal(4, result.PollCount);
// 驗證讀取了 4 次
await modbusClient.Received(4).ReadHoldingRegistersAsync(100, 1);
}
也可以用 Lambda 實作更複雜的狀態邏輯:
var callCount = 0;
modbusClient.ReadHoldingRegistersAsync(100, 1)
.Returns(_ =>
{
callCount++;
return callCount switch
{
<= 2 => new ushort[] { 1 }, // Running
3 => new ushort[] { 3 }, // Error
_ => new ushort[] { 0 } // Idle
};
});
部分替身(Partial Substitute)
當你需要測試一個類別,但只想替換其中部分方法時:
public abstract class DeviceHandlerBase
{
public virtual async Task<bool> ProcessAsync()
{
var data = await ReadDataAsync(); // 想替換這個
return await ValidateAsync(data); // 想測試這個
}
public abstract Task<byte[]> ReadDataAsync();
public virtual async Task<bool> ValidateAsync(byte[] data)
{
// 真實的驗證邏輯 — 這是我們要測試的
return data.Length > 0 && data[0] != 0xFF;
}
}
[Fact]
public async Task Validate_WhenDataStartsWithFF_ShouldReturnFalse()
{
// 部分替身:ReadDataAsync 被替換,ValidateAsync 保留真實邏輯
var handler = Substitute.ForPartsOf<DeviceHandlerBase>();
handler.ReadDataAsync().Returns(new byte[] { 0xFF, 0x01, 0x02 });
var result = await handler.ProcessAsync();
Assert.False(result);
}
使用限制
部分替身只能替換 virtual 或 abstract 方法。非虛擬方法會執行真實程式碼。這也是為什麼可測試的設計應該把外部依賴的操作標記為 virtual 或抽到介面。
搭配 FluentAssertions
NSubstitute 負責設定替身和驗證呼叫,FluentAssertions 負責讓斷言更清楚:
[Fact]
public async Task GetTemperature_ShouldReturnCorrectlyScaledValue()
{
// Arrange
var modbusClient = Substitute.For<IModbusClient>();
modbusClient.ReadHoldingRegistersAsync(0, 1)
.Returns(new ushort[] { 253 });
var sut = new DeviceMonitorService(modbusClient);
// Act
var temperature = await sut.GetTemperatureAsync();
// Assert — FluentAssertions 語法
temperature.Should().Be(25.3);
temperature.Should().BeInRange(20.0, 30.0, "溫度應在安全範圍內");
}
[Fact]
public async Task GetAllDeviceStatuses_ShouldReturnConnectedDevices()
{
var deviceService = Substitute.For<IDeviceService>();
deviceService.GetAllStatusesAsync().Returns(new[]
{
new DeviceStatus { Id = "CMP-01", IsConnected = true },
new DeviceStatus { Id = "CVD-01", IsConnected = true },
new DeviceStatus { Id = "ETH-01", IsConnected = false }
});
var sut = new DashboardViewModel(deviceService);
await sut.RefreshAsync();
// FluentAssertions 集合斷言
sut.ConnectedDevices.Should().HaveCount(2);
sut.ConnectedDevices.Should().OnlyContain(d => d.IsConnected);
sut.ConnectedDevices.Should().Contain(d => d.Id == "CMP-01");
}
搭配 ReactiveUI:Mock IObservable<T> 來源
測試 ReactiveUI ViewModel 時,可以用 Subject<T> 模擬 Observable 資料流:
[Fact]
public void ViewModel_WhenTemperatureExceedsLimit_ShouldShowWarning()
{
// 用 Subject 模擬溫度資料流
var temperatureSubject = new Subject<double>();
var deviceService = Substitute.For<IDeviceService>();
deviceService.TemperatureStream.Returns(temperatureSubject.AsObservable());
var sut = new MonitorViewModel(deviceService);
sut.Activator.Activate();
// 推送溫度值
temperatureSubject.OnNext(25.0); // 正常
Assert.False(sut.IsOverTemperature);
temperatureSubject.OnNext(85.0); // 超溫
Assert.True(sut.IsOverTemperature);
}
測試命名規範
建議遵循 MethodName_Scenario_ExpectedResult 格式:
// ✅ 清楚表達測試目的
GetTemperature_WhenRegisterReturns253_ShouldReturn25Point3()
CheckTemperature_WhenExceedsLimit_ShouldRaiseCriticalAlarm()
Connect_WhenPlcOffline_ShouldThrowTimeoutException()
WaitForCompletion_WhenProcessCompletes_ShouldReturnSuccessResult()
// ❌ 模糊不清
TestGetTemperature()
TemperatureTest1()
ShouldWork()
什麼不該 Mock
| 不該 Mock | 原因 | 替代方案 |
|---|---|---|
| DTO / Entity | 純資料物件,沒有行為 | 直接 new 建立 |
| 簡單的自己寫的類別 | 邏輯簡單且穩定 | 直接使用真實物件 |
| Collection / List | 標準函式庫,行為確定 | 直接 new List<T>() |
| static 方法 | NSubstitute 無法替身 | 抽出介面再 Mock |
只 Mock 外部依賴的介面:IModbusClient、ISecsGemClient、IAlarmService、IFileService、IRecipeRepository 等。
原則:如果你需要 Mock 一個東西才能測試,代表那個東西是一個應該被抽象成介面的外部依賴。如果你發現自己在 Mock 自己寫的簡單類別,那可能是設計上的壞味道 — 考慮直接用真實物件。
延伸閱讀
- 概觀 — 基礎概念、核心 API、安裝
- FluentAssertions 概觀 — 更好的斷言語法
- ReactiveUI 響應式 MVVM — ViewModel 測試搭配