使用 NSubstitute 建立測試替身(Mock、Stub、Spy)的專門技能。當需要隔離外部依賴、模擬介面行為、驗證方法呼叫時使用。涵蓋 Substitute.For、Returns、Received、Throws 等完整指引。 Keywords: mock, stub, spy, nsubstitute, 模擬, test double, 測試替身, IRepository,...
真實世界的程式碼通常依賴外部資源,這些依賴會讓測試變得:
測試替身(Test Double)讓我們能夠隔離這些依賴,專注測試業務邏輯。
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="9.0.0" />
<PackageReference Include="AwesomeAssertions" Version="9.4.0" />
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using Xunit;
using AwesomeAssertions;
using Microsoft.Extensions.Logging;
根據 Gerard Meszaros 在《xUnit Test Patterns》中的定義,測試替身分為五種類型:
| 類型 | 用途 | NSubstitute 對應 |
|---|---|---|
| Dummy | 填充物件,僅滿足方法簽章 | Substitute.For<T>() 不設定任何行為 |
| Stub | 提供預設回傳值,設定測試情境 | .Returns(value) |
| Fake | 簡化實作,有真實邏輯 | 手動實作介面(如 FakeUserRepository) |
| Spy | 記錄呼叫,事後驗證 | .Received() 驗證 |
| Mock | 預設期望互動,未滿足則測試失敗 | .Received(n) 嚴格驗證 |
各類型的完整程式碼範例請參閱 references/test-double-types.md
// 建立介面替代
var substitute = Substitute.For<IUserRepository>();
// 建立類別替代(需要虛擬成員)
var classSubstitute = Substitute.For<BaseService>();
// 建立多重介面替代
var multiSubstitute = Substitute.For<IService, IDisposable>();
// 精確參數匹配
_repository.GetById(1).Returns(new User { Id = 1, Name = "John" });
// 任意參數匹配
_service.Process(Arg.Any<string>()).Returns("processed");
// 回傳序列值
_generator.GetNext().Returns(1, 2, 3, 4, 5);
// 使用委派計算回傳值
_calculator.Add(Arg.Any<int>(), Arg.Any<int>())
.Returns(x => (int)x[0] + (int)x[1]);
// 條件匹配
_service.Process(Arg.Is<string>(x => x.StartsWith("test")))
.Returns("test-result");
// 同步方法拋出例外
_service.RiskyOperation()
.Throws(new InvalidOperationException("Something went wrong"));
// 非同步方法拋出例外
_service.RiskyOperationAsync()
.Throws(new InvalidOperationException("Async operation failed"));
// 任意值
_service.Process(Arg.Any<string>()).Returns("result");
// 特定條件
_service.Process(Arg.Is<string>(x => x.Length > 5)).Returns("long-result");
// 引數擷取
string capturedArg = null;
_service.Process(Arg.Do<string>(x => capturedArg = x)).Returns("result");
_service.Process("test");
capturedArg.Should().Be("test");
// 引數檢查
_service.Process(Arg.Is<string>(x =>
{
x.Should().StartWith("prefix");
return true;
})).Returns("result");
// 驗證被呼叫(至少一次)
_service.Received().Process("test");
// 驗證呼叫次數
_service.Received(2).Process(Arg.Any<string>());
// 驗證未被呼叫
_service.DidNotReceive().Delete(Arg.Any<int>());
// 驗證任意參數呼叫
_service.ReceivedWithAnyArgs().Process(default);
// 驗證呼叫順序
Received.InOrder(() =>
{
_service.Start();
_service.Process();
_service.Stop();
});
涵蓋五種常見的 NSubstitute 實戰模式,包含完整程式碼範例:
| 模式 | 說明 |
|---|---|
| 模式 1:依賴注入與測試設定 | FileBackupService 完整範例,含建構式注入與 SUT 設定 |
| 模式 2:Mock vs Stub 差異 | Stub 關注狀態回傳值 vs Mock 關注互動行為驗證 |
| 模式 3:非同步方法測試 | Returns(Task.FromResult(...)) 與 .Throws() 模式 |
| 模式 4:ILogger 驗證 | 驗證底層 Log 方法繞過擴展方法限制 |
| 模式 5:複雜設定管理 | 基底測試類別管理共用 Substitute 設定 |
完整程式碼範例請參閱 references/practical-patterns.md
[Fact]
public void CreateOrder_建立訂單_應儲存正確的訂單資料()
{
var repository = Substitute.For<IOrderRepository>();
var service = new OrderService(repository);
service.CreateOrder("Product A", 5, 100);
// 驗證物件屬性
repository.Received(1).Save(Arg.Is<Order>(o =>
o.ProductName == "Product A" &&
o.Quantity == 5 &&
o.Price == 100));
}
[Fact]
public void RegisterUser_註冊使用者_應產生正確的雜湊密碼()
{
var repository = Substitute.For<IUserRepository>();
var service = new UserService(repository);
User capturedUser = null;
repository.Save(Arg.Do<User>(u => capturedUser = u));
service.RegisterUser("john@example.com", "password123");
capturedUser.Should().NotBeNull();
capturedUser.Email.Should().Be("john@example.com");
capturedUser.PasswordHash.Should().NotBe("password123"); // 應該被雜湊
capturedUser.PasswordHash.Length.Should().BeGreaterThan(20);
}
針對介面而非實作建立 Substitute
// 正確:針對介面
var repository = Substitute.For<IUserRepository>();
// 錯誤:針對具體類別(除非有虛擬成員)
var repository = Substitute.For<UserRepository>();
使用有意義的測試資料
// 正確:清楚表達意圖
var user = new User { Id = 123, Name = "John Doe", Email = "john@example.com" };
// 錯誤:無意義的資料
var user = new User { Id = 1, Name = "test", Email = "a@b.c" };
避免過度驗證
// 正確:只驗證重要的行為
_emailService.Received(1).SendWelcomeEmail(Arg.Any<string>());
// 錯誤:驗證所有內部實作細節
_repository.Received(1).GetById(123);
_repository.Received(1).Update(Arg.Any<User>());
_validator.Received(1).Validate(Arg.Any<User>());
Mock 與 Stub 的明確區分
// 正確:Stub 用於設定情境,Mock 用於驗證行為
var stubRepository = Substitute.For<IUserRepository>(); // Stub
var mockLogger = Substitute.For<ILogger>(); // Mock
stubRepository.GetById(123).Returns(user);
service.ProcessUser(123);
mockLogger.Received(1).LogInformation(Arg.Any<string>());
避免模擬值類型
// 錯誤:DateTime 是值類型
var badDate = Substitute.For<DateTime>();
// 正確:抽象時間提供者
var dateTimeProvider = Substitute.For<IDateTimeProvider>();
dateTimeProvider.Now.Returns(new DateTime(2024, 1, 1));
避免測試與實作強耦合
// 錯誤:測試實作細節
_repository.Received(1).Query(Arg.Any<string>());
_repository.Received(1).Filter(Arg.Any<Expression<Func<User, bool>>>());
// 正確:測試行為結果
var users = service.GetActiveUsers();
users.Should().HaveCount(2);
避免設定過於複雜
// 錯誤:過多的 Substitute(可能違反 SRP)
var sub1 = Substitute.For<IService1>();
var sub2 = Substitute.For<IService2>();
var sub3 = Substitute.For<IService3>();
var sub4 = Substitute.For<IService4>();
// 正確:重新思考類別職責
// 考慮是否違反單一職責原則,需要重構
A: 確保要模擬的成員是 virtual:
public class BaseService
{
public virtual string GetData() => "real data";
}
var substitute = Substitute.For<BaseService>();
substitute.GetData().Returns("test data");
A: 使用 Received.InOrder():
Received.InOrder(() =>
{
_service.Start();
_service.Process();
_service.Stop();
});
A: 使用 Returns() 配合委派:
_service.TryGetValue("key", out Arg.Any<string>())
.Returns(x =>
{
x[1] = "value";
return true;
});
A: NSubstitute 優勢:
選擇 NSubstitute,除非:
此技能可與以下技能組合使用:
本技能提供以下範本檔案:
templates/mock-patterns.cs: 完整的 Mock/Stub/Spy 模式範例templates/verification-examples.cs: 行為驗證與引數匹配範例references/practical-patterns.md: 五種實戰模式完整程式碼references/test-double-types.md: Test Double 五大類型詳細範例*Tests.cs)Received() / DidNotReceive() 明確斷言本技能內容提煉自「老派軟體工程師的測試修練 - 30 天挑戰」系列文章: