A specialized skill for creating test doubles (Mock, Stub, Spy) using NSubstitute. Use it when you need to isolate external dependencies, simulate interface behavior, or verify method calls. Covers complete guidance on Substitute.For, Returns, Received, Throws, and more. Keywords: mock, stub, spy, nsubstitute, mocking, test double, repository (IRepository), service (IService), Substitute.For, Returns, Received, Throws, Arg.Any, Arg.Is, dependency isolation, mocking external services, dependency injection testing
真實世界的程式碼通常依賴外部資源,這些依賴會讓測試變得:
測試替身(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 天挑戰」系列文章:
npx skills add kevintsengtw/.NET 测试 - NSubstitute 模拟下载完整 Skill 目录,包含 SKILL.md 及所有相关文件
Search for places (restaurants, cafes, etc.) via Google Places API proxy on localhost.
Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries.
Create or update AgentSkills. Use when designing, structuring, or packaging skills with scripts, references, and assets.
Start voice calls via the OpenClaw voice-call plugin.
Notion API for creating and managing pages, databases, and blocks.
Gemini CLI for one-shot Q&A, summaries, and generation.
Category:developer