Why Do We Need Test Doubles?
Real-world code usually depends on external resources, and these dependencies make tests:
- Slow - Requires actually operating databases, file systems, networks
- Unstable - External service anomalies cause test failures
- Hard to reproduce - Time and random numbers cause inconsistent results
- Environment-dependent - Requires specific external environment configuration
- Development blocking - Must wait for external systems to be ready
Test Doubles allow us to isolate these dependencies and focus on testing business logic.
Prerequisites
Package Installation
<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" />
Basic using Directives
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using Xunit;
using AwesomeAssertions;
using Microsoft.Extensions.Logging;
The Five Major Types of Test Doubles
According to Gerard Meszaros's definition in "xUnit Test Patterns," test doubles are divided into five types:
| Type | Purpose | NSubstitute Equivalent |
|---|---|---|
| Dummy | Filler object that only satisfies method signatures | Substitute.For<T>() Do not configure any behavior |
| Stub | Provides default return values, sets up test scenarios | .Returns(value) |
| Fake | Simplified implementation with real logic | Manually implement the interface (e.g., FakeUserRepository) |
| Spy | Records calls, verifies afterward | .Received() Verify |
| Mock | Expects interactions by default, test fails if not satisfied | .Received(n) Strict verification |
For complete code examples of each type, see references/test-double-types.md
NSubstitute Core Features
Basic Substitute Syntax
// 建立介面替代
var substitute = Substitute.For<IUserRepository>();
// 建立類別替代(需要虛擬成員)
var classSubstitute = Substitute.For<BaseService>();
// 建立多重介面替代
var multiSubstitute = Substitute.For<IService, IDisposable>();
Return Value Configuration
Basic Return Values
// 精確參數匹配
_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);
Conditional Return Values
// 使用委派計算回傳值
_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");
Throwing Exceptions
// 同步方法拋出例外
_service.RiskyOperation()
.Throws(new InvalidOperationException("Something went wrong"));
// 非同步方法拋出例外
_service.RiskyOperationAsync()
.Throws(new InvalidOperationException("Async operation failed"));
Argument Matchers
// 任意值
_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");
Call Verification
// 驗證被呼叫(至少一次)
_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();
});
Practical Patterns
Covers five common NSubstitute practical patterns, including complete code examples:
| Pattern | Description |
|---|---|
| Pattern 1: Dependency Injection and Test Setup | Complete FileBackupService example, including constructor injection and SUT setup |
| Pattern 2: Mock vs Stub Differences | Stub focuses on state return values vs Mock focuses on interaction behavior verification |
| Pattern 3: Asynchronous Method Testing | Returns(Task.FromResult(...)) and .Throws() patterns |
| Pattern 4: ILogger Verification | Verify the underlying Log method to bypass extension method limitations |
| Pattern 5: Complex Configuration Management | Base test class manages shared Substitute configuration |
For complete code examples, see references/practical-patterns.md
Advanced Argument Matching Techniques
Complex Object Matching
[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));
}
Argument Capture and Verification
[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);
}
Common Pitfalls and Best Practices
Recommended Practices
-
Create Substitutes for interfaces rather than implementations
// 正確:針對介面 var repository = Substitute.For<IUserRepository>(); // 錯誤:針對具體類別(除非有虛擬成員) var repository = Substitute.For<UserRepository>(); -
Use meaningful test data
// 正確:清楚表達意圖 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" }; -
Avoid over-verification
// 正確:只驗證重要的行為 _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>()); -
Clearly distinguish between Mock and 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>());
Practices to Avoid
-
Avoid mocking value types
// 錯誤:DateTime 是值類型 var badDate = Substitute.For<DateTime>(); // 正確:抽象時間提供者 var dateTimeProvider = Substitute.For<IDateTimeProvider>(); dateTimeProvider.Now.Returns(new DateTime(2024, 1, 1)); -
Avoid tight coupling between tests and implementation
// 錯誤:測試實作細節 _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); -
Avoid overly complex setup
// 錯誤:過多的 Substitute(可能違反 SRP) var sub1 = Substitute.For<IService1>(); var sub2 = Substitute.For<IService2>(); var sub3 = Substitute.For<IService3>(); var sub4 = Substitute.For<IService4>(); // 正確:重新思考類別職責 // 考慮是否違反單一職責原則,需要重構
Identify Dependencies That Need to Be Substituted
Should Be Substituted
- External API calls (IHttpClient, IApiClient)
- Database operations (IRepository, IDbContext)
- File system operations (IFileSystem)
- Network communication (IEmailService, IMessageQueue)
- Time dependencies (IDateTimeProvider, TimeProvider)
- Random number generation (IRandom)
- Expensive computations (IComplexCalculator)
- Logging services (ILogger)
Should Not Be Substituted
- Value objects (DateTime, string, int)
- Simple data transfer objects (DTO)
- Pure function utilities (such as AutoMapper's IMapper; consider using a real instance)
- Framework core classes (unless there is a clear need)
Troubleshooting
Q1: How do I test a class that has no interface?
A: Ensure the member to be mocked is virtual:
public class BaseService
{
public virtual string GetData() => "real data";
}
var substitute = Substitute.For<BaseService>();
substitute.GetData().Returns("test data");
Q2: How do I verify the order in which methods are called?
A: Use Received.InOrder():
Received.InOrder(() =>
{
_service.Start();
_service.Process();
_service.Stop();
});
Q3: How do I handle out parameters?
A: Use Returns() with a delegate:
_service.TryGetValue("key", out Arg.Any<string>())
.Returns(x =>
{
x[1] = "value";
return true;
});
Q4: How should I choose between NSubstitute and Moq?
A: NSubstitute advantages:
- More concise and intuitive syntax
- Gentle learning curve
- No privacy controversy
- Sufficient for most testing scenarios
Choose NSubstitute unless:
- The project already uses Moq
- You need Moq-specific advanced features
- The team is already familiar with Moq syntax
Integration with Other Skills
This skill can be used in combination with the following skills:
- unit-test-fundamentals: Unit testing fundamentals and the 3A pattern
- dependency-injection-testing: Dependency injection testing strategies
- test-naming-conventions: Test naming conventions
- test-output-logging: ITestOutputHelper and ILogger integration
- datetime-testing-timeprovider: TimeProvider for abstracting time dependencies
- filesystem-testing-abstractions: File system dependency abstraction
Template File Reference
This skill provides the following template files:
templates/mock-patterns.cs: Complete Mock/Stub/Spy pattern examplestemplates/verification-examples.cs: Behavior verification and argument matching examplesreferences/practical-patterns.md: Complete code for five practical patternsreferences/test-double-types.md: Detailed examples of the five major types of Test Double
Output Format
- Generate an xUnit test class file using NSubstitute (
*Tests.cs) - Test methods follow the 3A (Arrange-Act-Assert) pattern
- Substitute setup is centralized in the test constructor or the Arrange section
- Verification calls use
Received()/DidNotReceive()explicit assertions - Use AwesomeAssertions for state verification
Reference Resources
Original Articles
The content of this skill is distilled from the article series "Old-School Software Engineer's Testing Training - 30-Day Challenge":
- Day 07 - Getting Started with Dependency Substitution: Using NSubstitute
- Ironman series article:https://ithelp.ithome.com.tw/articles/10374593
- Sample code:https://github.com/kevintsengtw/30Days_in_Testing_Samples/tree/main/day07