Skip to main content

NSubstitute 測試替身指南

用途:為 .NET 單元測試建立測試替身(Test Double),隔離外部依賴

NuGetNSubstitute

授權: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

比較項目NSubstituteMoqFakeItEasy
語法風格自然語法,像呼叫真實物件Lambda + Setup/VerifyA.CallTo 語法
學習曲線最低中等中等
社群信任穩定、乾淨2023 年 SponsorLink 爭議穩定
API 範例sub.Method().Returns(x)mock.Setup(m => m.Method()).Returns(x)A.CallTo(() => fake.Method()).Returns(x)
效能
為什麼選 NSubstitute
  1. 語法最簡潔:設定和驗證的寫法就像呼叫真實物件,幾乎不需要學新概念
  2. 社群穩定:Moq 在 v4.20 引入 SponsorLink(會偷偷蒐集使用者 email)引起重大爭議,許多團隊因此遷移
  3. 錯誤訊息清楚:當測試失敗時,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>());
}
}

延伸閱讀