NSubstitute 測試替身指南
用途:為 .NET 單元測試建立測試替身(Test Double),隔離外部依賴
NuGet:
NSubstitute授權:BSD-3-Clause
支援:.NET 6+、.NET Framework 4.6.2+
什麼是測試替身
單元測試的核心是隔離:只測試目標類別的邏輯,不測試它的依賴(資料庫、PLC、檔案系統等)。測試替身(Test Double)就是用來替換這些外部依賴的假物件。
| 類型 | 用途 | 範例 |
|---|---|---|
| Stub | 提供罐頭回應,不驗證行為 | 模擬 PLC 回傳固定暫存器值 |
| Mock | 驗證某個方法是否被呼叫 | 驗證警報服務確實被觸發 |
| Fake | 簡化的真實實作 | 用 Dictionary 取代資料庫 |
NSubstitute 把 Stub 和 Mock 統一成一個概念 — Substitute(替身),你可以同時設定回傳值(Stub 行為)和驗證呼叫(Mock 行為)。
為什麼需要測試替身
// ❌ 沒有替身:測試依賴真實 PLC,需要設備連線才能跑測試
[Fact]
public async Task GetTemperature_ShouldReturnScaledValue()
{
var client = new ModbusTcpClient("192.168.1.10", 502); // 需要真實 PLC
var service = new DeviceMonitorService(client);
var temp = await service.GetTemperatureAsync();
Assert.InRange(temp, -50.0, 200.0); // 結果取決於當下設備狀態
}
// ✅ 用替身:完全隔離,可重複、可預測
[Fact]
public async Task GetTemperature_ShouldReturnScaledValue()
{
var client = Substitute.For<IModbusClient>();
client.ReadHoldingRegistersAsync(0, 1).Returns(new ushort[] { 253 });
var service = new DeviceMonitorService(client);
var temp = await service.GetTemperatureAsync();
Assert.Equal(25.3, temp); // 253 * 0.1 = 25.3
}
NSubstitute vs Moq vs FakeItEasy
| 比較項目 | NSubstitute | Moq | FakeItEasy |
|---|---|---|---|
| 語法風格 | 自然語法,像呼叫真實物件 | Lambda + Setup/Verify | A.CallTo 語法 |
| 學習曲線 | 最低 | 中等 | 中等 |
| 社群信任 | 穩定、乾淨 | 2023 年 SponsorLink 爭議 | 穩定 |
| API 範例 | sub.Method().Returns(x) | mock.Setup(m => m.Method()).Returns(x) | A.CallTo(() => fake.Method()).Returns(x) |
| 效能 | 快 | 快 | 快 |
為什麼選 NSubstitute
- 語法最簡潔:設定和驗證的寫法就像呼叫真實物件,幾乎不需要學新概念
- 社群穩定:Moq 在 v4.20 引入 SponsorLink(會偷偷蒐集使用者 email)引起重大爭議,許多團隊因此遷移
- 錯誤訊息清楚:當測試失敗時,NSubstitute 會告訴你它收到了什麼呼叫,幫助快速定位問題
安裝
dotnet add package NSubstitute
# 分析器(強烈建議):檢測常見誤用
dotnet add package NSubstitute.Analyzers.CSharp
NSubstitute.Analyzers 會在編譯時期抓到常見錯誤,例如對非虛擬方法建立替身、在非測試程式碼中使用 NSubstitute 等。
核心 API
Substitute.For<T>() — 建立替身
// 介面替身(最常用)
var modbusClient = Substitute.For<IModbusClient>();
// 多介面替身
var device = Substitute.For<IConnectable, IDisposable>();
// 抽象類別替身(帶建構子參數)
var handler = Substitute.For<AlarmHandlerBase>("CMP-01");
重要限制
NSubstitute 只能替身介面和虛擬方法。如果目標類別的方法不是 virtual,替身無法攔截它。這也是為什麼公司程式碼應該依賴介面(IModbusClient)而非具體類別(ModbusTcpClient)。
.Returns() — 設定回傳值
// 固定回傳值
modbusClient.ReadHoldingRegistersAsync(0, 10)
.Returns(new ushort[] { 100, 200, 300, 400, 500, 0, 0, 0, 0, 0 });
// 非同步方法也一樣(NSubstitute 自動處理 Task 包裝)
modbusClient.ReadHoldingRegistersAsync(0, 1)
.Returns(new ushort[] { 253 });
// Property 回傳值
modbusClient.IsConnected.Returns(true);
.Received() — 驗證呼叫
// 驗證方法被呼叫了 1 次
await modbusClient.Received(1).ReadHoldingRegistersAsync(0, 10);
// 驗證方法被呼叫了(至少 1 次)
await modbusClient.Received().ReadHoldingRegistersAsync(0, 10);
// 驗證方法沒有被呼叫
await modbusClient.DidNotReceive().WriteMultipleRegistersAsync(
Arg.Any<int>(), Arg.Any<ushort[]>());
Arg — 參數比對器
// Arg.Any<T>():任意值
modbusClient.ReadHoldingRegistersAsync(Arg.Any<int>(), Arg.Any<int>())
.Returns(new ushort[] { 42 });
// Arg.Is<T>():條件比對
modbusClient.ReadHoldingRegistersAsync(Arg.Is(0), Arg.Is<int>(n => n <= 10))
.Returns(new ushort[] { 42 });
// Arg.Is():精確比對
modbusClient.ReadHoldingRegistersAsync(Arg.Is(100), Arg.Is(1))
.Returns(new ushort[] { 999 });
測試流程:Arrange / Act / Assert
NSubstitute 的使用模式對應到 xUnit 的 AAA 結構。下圖以「溫度超過閾值要觸發警報」的測試為例,展示 Test、Substitute、SUT 三方在三個階段的互動:
三個階段對應的 NSubstitute API:
| 階段 | 動作 | NSubstitute API |
|---|---|---|
| Arrange | 建立替身 | Substitute.For<TInterface>() |
| Arrange | 設定回傳值 | .Returns(value) / .ReturnsForAnyArgs(value) |
| Arrange | 模擬例外 | .Throws<TException>() |
| Act | 呼叫 SUT | 任何 SUT 公開方法 |
| Assert | 驗證有呼叫 | .Received(times).Method(args) |
| Assert | 驗證沒呼叫 | .DidNotReceive().Method(args) |
| Assert | 模糊比對參數 | Arg.Any<T>() / Arg.Is<T>(predicate) |
實際的 xUnit 測試類別請見下節。
完整範例:測試設備監控服務
public interface IModbusClient
{
bool IsConnected { get; }
Task<ushort[]> ReadHoldingRegistersAsync(int address, int count);
Task WriteMultipleRegistersAsync(int address, ushort[] values);
Task ConnectAsync(string ip, int port);
}
public class DeviceMonitorService
{
private readonly IModbusClient _modbusClient;
private readonly IAlarmService _alarmService;
public DeviceMonitorService(IModbusClient modbusClient, IAlarmService alarmService)
{
_modbusClient = modbusClient;
_alarmService = alarmService;
}
public async Task<double> GetTemperatureAsync()
{
var registers = await _modbusClient.ReadHoldingRegistersAsync(0, 1);
return registers[0] * 0.1; // 暫存器值 * 縮放因子
}
public async Task CheckTemperatureAsync()
{
var temp = await GetTemperatureAsync();
if (temp > 80.0)
{
await _alarmService.RaiseAlarmAsync(new Alarm
{
Severity = AlarmSeverity.Critical,
Source = "Temperature",
Message = $"Temperature {temp}°C exceeds limit"
});
}
}
}
public class DeviceMonitorServiceTests
{
private readonly IModbusClient _modbusClient;
private readonly IAlarmService _alarmService;
private readonly DeviceMonitorService _sut; // System Under Test
public DeviceMonitorServiceTests()
{
_modbusClient = Substitute.For<IModbusClient>();
_alarmService = Substitute.For<IAlarmService>();
_sut = new DeviceMonitorService(_modbusClient, _alarmService);
}
[Fact]
public async Task GetTemperature_ShouldScaleRegisterValue()
{
// Arrange
_modbusClient.ReadHoldingRegistersAsync(0, 1)
.Returns(new ushort[] { 253 });
// Act
var temperature = await _sut.GetTemperatureAsync();
// Assert
Assert.Equal(25.3, temperature);
}
[Fact]
public async Task CheckTemperature_WhenExceedsLimit_ShouldRaiseAlarm()
{
// Arrange — 溫度 85.0°C(超過 80°C 閾值)
_modbusClient.ReadHoldingRegistersAsync(0, 1)
.Returns(new ushort[] { 850 });
// Act
await _sut.CheckTemperatureAsync();
// Assert — 驗證警報服務被呼叫,且參數正確
await _alarmService.Received(1).RaiseAlarmAsync(
Arg.Is<Alarm>(a =>
a.Severity == AlarmSeverity.Critical &&
a.Source == "Temperature"));
}
[Fact]
public async Task CheckTemperature_WhenNormal_ShouldNotRaiseAlarm()
{
// Arrange — 溫度 25.0°C(正常)
_modbusClient.ReadHoldingRegistersAsync(0, 1)
.Returns(new ushort[] { 250 });
// Act
await _sut.CheckTemperatureAsync();
// Assert — 驗證警報服務沒有被呼叫
await _alarmService.DidNotReceive().RaiseAlarmAsync(Arg.Any<Alarm>());
}
}
延伸閱讀
- 進階用法與公司場景 — 條件回傳、例外模擬、順序回傳、搭配 FluentAssertions
- FluentAssertions 測試斷言 — 更好的斷言語法