xiaohongshu
xiaohongshu
dotnet-testing-nsubstitute-mocking
使用 NSubstitute 建立測試替身(Mock、Stub、Spy)的專門技能。當需要隔離外部依賴、模擬介面行為、驗證方法呼叫時使用。涵蓋 Substitute.For、Returns、Received、Throws 等完整指引。 Keywords: mock, stub, spy, nsubstitute, 模擬, test double, 測試替身, IRepository, IService, Substitute.For, Returns, Received, Throws, Arg.Any, Arg.Is, 隔離依賴, 模擬外部服務, dependency injectio...
Full skill instructions
此技能專注於使用 NSubstitute 建立和管理測試替身,涵蓋 Test Double 五大類型、依賴隔離策略、行為設定與驗證的最佳實踐。
真實世界的程式碼通常依賴外部資源,這些依賴會讓測試變得:
測試替身(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.1.0" />
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using Xunit;
using AwesomeAssertions;
using Microsoft.Extensions.Logging;
根據 Gerard Meszaros 在《xUnit Test Patterns》中的定義:
僅用於滿足方法簽章,不會被實際使用。
public interface IEmailService
{
void SendEmail(string to, string subject, string body, ILogger logger);
}
[Fact]
public void ProcessOrder_不使用Logger_應成功處理訂單()
{
// Dummy:只是為了滿足參數要求
var dummyLogger = Substitute.For<ILogger>();
var service = new OrderService();
var result = service.ProcessOrder(order, dummyLogger);
result.Success.Should().BeTrue();
// 不關心 logger 是否被調用
}
提供預先定義的回傳值,用於測試特定情境。
[Fact]
public void GetUser_有效的使用者ID_應回傳使用者資料()
{
// Arrange - Stub:預設回傳值
var stubRepository = Substitute.For<IUserRepository>();
stubRepository.GetById(123).Returns(new User { Id = 123, Name = "John" });
var service = new UserService(stubRepository);
// Act
var actual = service.GetUser(123);
// Assert
actual.Name.Should().Be("John");
// 不關心 GetById 被呼叫了幾次
}
有實際功能但簡化的實作,通常用於整合測試。
public class FakeUserRepository : IUserRepository
{
private readonly Dictionary<int, User> _users = new();
public User GetById(int id) => _users.TryGetValue(id, out var user) ? user : null;
public void Save(User user) => _users[user.Id] = user;
public void Delete(int id) => _users.Remove(id);
}
[Fact]
public void CreateUser_建立使用者_應儲存並可查詢()
{
// Fake:有真實邏輯的簡化實作
var fakeRepository = new FakeUserRepository();
var service = new UserService(fakeRepository);
service.CreateUser(new User { Id = 1, Name = "John" });
var actual = service.GetUser(1);
actual.Name.Should().Be("John");
}
記錄被如何呼叫,可以事後驗證。
[Fact]
public void CreateUser_建立使用者_應記錄建立資訊()
{
// Arrange
var spyLogger = Substitute.For<ILogger<UserService>>();
var repository = Substitute.For<IUserRepository>();
var service = new UserService(repository, spyLogger);
// Act
service.CreateUser(new User { Name = "John" });
// Assert - Spy:驗證呼叫記錄
spyLogger.Received(1).LogInformation("User created: {Name}", "John");
}
預設期望的互動行為,測試失敗如果期望沒有滿足。
[Fact]
public void RegisterUser_註冊使用者_應發送歡迎郵件()
{
// Arrange
var mockEmailService = Substitute.For<IEmailService>();
var repository = Substitute.For<IUserRepository>();
var service = new UserService(repository, mockEmailService);
// Act
service.RegisterUser("[email protected]", "John");
// Assert - Mock:驗證特定的互動行為
mockEmailService.Received(1).SendWelcomeEmail("[email protected]", "John");
}
// 建立介面替代
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();
});
public class FileBackupService
{
private readonly IFileSystem _fileSystem;
private readonly IDateTimeProvider _dateTimeProvider;
private readonly IBackupRepository _backupRepository;
private readonly ILogger<FileBackupService> _logger;
public FileBackupService(
IFileSystem fileSystem,
IDateTimeProvider dateTimeProvider,
IBackupRepository backupRepository,
ILogger<FileBackupService> logger)
{
_fileSystem = fileSystem;
_dateTimeProvider = dateTimeProvider;
_backupRepository = backupRepository;
_logger = logger;
}
public async Task<BackupResult> BackupFileAsync(string sourcePath, string destinationPath)
{
if (!_fileSystem.FileExists(sourcePath))
{
_logger.LogWarning("Source file not found: {Path}", sourcePath);
return new BackupResult { Success = false, Message = "Source file not found" };
}
var fileInfo = _fileSystem.GetFileInfo(sourcePath);
if (fileInfo.Length > 100 * 1024 * 1024)
{
return new BackupResult { Success = false, Message = "File too large" };
}
var timestamp = _dateTimeProvider.Now.ToString("yyyyMMdd_HHmmss");
var backupFileName = $"{Path.GetFileNameWithoutExtension(sourcePath)}_{timestamp}{Path.GetExtension(sourcePath)}";
var fullBackupPath = Path.Combine(destinationPath, backupFileName);
_fileSystem.CopyFile(sourcePath, fullBackupPath);
await _backupRepository.SaveBackupHistory(sourcePath, fullBackupPath, _dateTimeProvider.Now);
_logger.LogInformation("Backup completed: {Path}", fullBackupPath);
return new BackupResult { Success = true, BackupPath = fullBackupPath };
}
}
public class FileBackupServiceTests
{
private readonly IFileSystem _fileSystem;
private readonly IDateTimeProvider _dateTimeProvider;
private readonly IBackupRepository _backupRepository;
private readonly ILogger<FileBackupService> _logger;
private readonly FileBackupService _sut; // System Under Test
public FileBackupServiceTests()
{
_fileSystem = Substitute.For<IFileSystem>();
_dateTimeProvider = Substitute.For<IDateTimeProvider>();
_backupRepository = Substitute.For<IBackupRepository>();
_logger = Substitute.For<ILogger<FileBackupService>>();
_sut = new FileBackupService(_fileSystem, _dateTimeProvider, _backupRepository, _logger);
}
[Fact]
public async Task BackupFileAsync_檔案存在且大小合理_應成功備份()
{
// Arrange
var sourcePath = @"C:\source\test.txt";
var destinationPath = @"C:\backup";
var testTime = new DateTime(2024, 1, 1, 12, 0, 0);
_fileSystem.FileExists(sourcePath).Returns(true);
_fileSystem.GetFileInfo(sourcePath).Returns(new FileInfo { Length = 1024 });
_dateTimeProvider.Now.Returns(testTime);
// Act
var result = await _sut.BackupFileAsync(sourcePath, destinationPath);
// Assert
result.Success.Should().BeTrue();
result.BackupPath.Should().Be(@"C:\backup\test_20240101_120000.txt");
_fileSystem.Received(1).CopyFile(sourcePath, result.BackupPath);
await _backupRepository.Received(1).SaveBackupHistory(
sourcePath, result.BackupPath, testTime);
}
}
[Fact]
public void CalculateDiscount_高級會員_應回傳20折扣()
{
// Stub:只關心回傳值,用於設定測試情境
var stubCustomerService = Substitute.For<ICustomerService>();
stubCustomerService.GetCustomerType(123).Returns(CustomerType.Premium);
var service = new PricingService(stubCustomerService);
// Act
var discount = service.CalculateDiscount(123, 1000);
// Assert - 只驗證結果狀態
discount.Should().Be(200); // 20% of 1000
}
[Fact]
public void ProcessPayment_成功付款_應記錄交易資訊()
{
// Mock:關心是否正確互動
var mockLogger = Substitute.For<ILogger<PaymentService>>();
var stubPaymentGateway = Substitute.For<IPaymentGateway>();
stubPaymentGateway.ProcessPayment(Arg.Any<decimal>()).Returns(PaymentResult.Success);
var service = new PaymentService(stubPaymentGateway, mockLogger);
// Act
service.ProcessPayment(100);
// Assert - 驗證互動行為
mockLogger.Received(1).LogInformation(
"Payment processed: {Amount} - Result: {Result}",
100,
PaymentResult.Success);
}
[Fact]
public async Task GetUserAsync_使用者存在_應回傳使用者資料()
{
// Arrange
var repository = Substitute.For<IUserRepository>();
repository.GetByIdAsync(123).Returns(Task.FromResult(
new User { Id = 123, Name = "John" }));
var service = new UserService(repository);
// Act
var result = await service.GetUserAsync(123);
// Assert
result.Name.Should().Be("John");
await repository.Received(1).GetByIdAsync(123);
}
[Fact]
public async Task SaveUserAsync_資料庫錯誤_應拋出例外()
{
// Arrange
var repository = Substitute.For<IUserRepository>();
repository.SaveAsync(Arg.Any<User>())
.Throws(new InvalidOperationException("Database error"));
var service = new UserService(repository);
// Act & Assert
await service.SaveUserAsync(new User { Name = "John" })
.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("Database error");
}
由於 ILogger 的擴展方法特性,需要驗證底層的 Log 方法:
[Fact]
public async Task BackupFileAsync_檔案不存在_應記錄警告()
{
// Arrange
var sourcePath = @"C:\nonexistent\test.txt";
_fileSystem.FileExists(sourcePath).Returns(false);
// Act
var result = await _sut.BackupFileAsync(sourcePath, @"C:\backup");
// Assert
result.Success.Should().BeFalse();
// 驗證 ILogger.Log 方法被正確呼叫
_logger.Received(1).Log(
LogLevel.Warning,
Arg.Any<EventId>(),
Arg.Is<object>(v => v.ToString().Contains("Source file not found")),
null,
Arg.Any<Func<object, Exception, string>>());
}
使用基底測試類別管理共用設定:
public class OrderServiceTestsBase
{
protected readonly IOrderRepository Repository;
protected readonly IEmailService EmailService;
protected readonly ILogger<OrderService> Logger;
protected readonly OrderService Sut;
protected OrderServiceTestsBase()
{
Repository = Substitute.For<IOrderRepository>();
EmailService = Substitute.For<IEmailService>();
Logger = Substitute.For<ILogger<OrderService>>();
Sut = new OrderService(Repository, EmailService, Logger);
}
protected void SetupValidOrder(int orderId = 1)
{
Repository.GetById(orderId).Returns(
new Order { Id = orderId, Status = OrderStatus.Pending });
}
protected void SetupEmailServiceSuccess()
{
EmailService.SendConfirmation(Arg.Any<string>()).Returns(true);
}
}
public class OrderServiceTests : OrderServiceTestsBase
{
[Fact]
public void ProcessOrder_有效訂單_應成功處理()
{
// Arrange
SetupValidOrder();
SetupEmailServiceSuccess();
// Act
var result = Sut.ProcessOrder(1);
// Assert
result.Success.Should().BeTrue();
}
}
[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("[email protected]", "password123");
capturedUser.Should().NotBeNull();
capturedUser.Email.Should().Be("[email protected]");
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 = "[email protected]" };
// ❌ 錯誤:無意義的資料
var user = new User { Id = 1, Name = "test", Email = "[email protected]" };
避免過度驗證
// ✅ 正確:只驗證重要的行為
_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: 行為驗證與引數匹配範例本技能內容提煉自「老派軟體工程師的測試修練 - 30 天挑戰」系列文章:
xiaohongshu
technical spec
product ux expert
database patterns
Conduct multi-agent task orchestration and workflow coordination.
Initialize project with Conductor artifacts (product definition,
Expert in web animations, transitions, and motion design using Framer Motion and CSS
Creates Mermaid and ASCII diagrams for flowcharts, architecture, ERDs, state machines, mindmaps, and more. Use when user mentions diagram, flowchart, mermaid, ASCII diagram, text diagram, terminal diagram, visualize, C4, mindmap, architecture diagram, sequence diagram, ERD, or needs visual docume...
PostgreSQL bindings for H3 hexagonal grid system. Use when working with H3 cells in Postgres, including spatial indexing, geometry/geography integration, and raster analysis.
Context-Driven Development skill for projects using Conductor. Use this skill when you detect a `conductor/` directory in the project, when working on tasks defined in a `plan.md` file, or when the user asks about tracks, specs, or plans. Automatically applies TDD workflow, tracks task completion...
Display project status, active tracks, and next actions
Official Stakpak application containerization standard operating procedure, a step-by-step guidline to properly dockerize applications. This is a rule book curated by the Stakpak Team.
Generate, edit, and beat-sync AI video with leading models in one workspace.
The world's fastest calendar for remote work
Transform Your Design with AI Designer by ImgCreator.ai
Revolutionizing Video Production with AI-Powered Creativity
Extend an image past the frame and let AI fill the new aspect ratio.
Discover your celebrity doppelgänger with StarByFace!
ChainClarity explains 700+ crypto whitepapers in plain English, with layered summaries, comparisons, research tools, alerts, and a $4.99 Pro plan.
Opus.ai: Revolutionize Your Web Experience