$npx -y skills add kevintsengtw/dotnet-testing-agent-skills --skill dotnet-testing-nsubstitute-mocking使用 NSubstitute 建立測試替身(Mock、Stub、Spy)的專門技能。當需要隔離外部依賴、模擬介面行為、驗證方法呼叫時使用。涵蓋 Substitute.For、Returns、Received、Throws 等完整指引。 Make sure to use this skill whenever the user mentions mock, stub, spy, NSubstitute, test double, 測試替身, Substitute.For, Returns, Received, or dependency isolation
| 1 | # NSubstitute 測試替身指南 |
| 2 | |
| 3 | ## 為什麼需要測試替身? |
| 4 | |
| 5 | 真實世界的程式碼通常依賴外部資源,這些依賴會讓測試變得: |
| 6 | |
| 7 | 1. **緩慢** - 需要實際操作資料庫、檔案系統、網路 |
| 8 | 2. **不穩定** - 外部服務異常導致測試失敗 |
| 9 | 3. **難以重複** - 時間、隨機數導致結果不一致 |
| 10 | 4. **環境依賴** - 需要特定的外部環境設定 |
| 11 | 5. **開發阻塞** - 必須等待外部系統準備就緒 |
| 12 | |
| 13 | 測試替身(Test Double)讓我們能夠隔離這些依賴,專注測試業務邏輯。 |
| 14 | |
| 15 | ## 前置需求 |
| 16 | |
| 17 | ### 套件安裝 |
| 18 | |
| 19 | ```xml |
| 20 | <PackageReference Include="NSubstitute" Version="5.3.0" /> |
| 21 | <PackageReference Include="xunit" Version="2.9.3" /> |
| 22 | <PackageReference Include="Microsoft.Extensions.Logging" Version="9.0.0" /> |
| 23 | <PackageReference Include="AwesomeAssertions" Version="9.4.0" /> |
| 24 | ``` |
| 25 | |
| 26 | ### 基本 using 指令 |
| 27 | |
| 28 | ```csharp |
| 29 | using NSubstitute; |
| 30 | using NSubstitute.ExceptionExtensions; |
| 31 | using Xunit; |
| 32 | using AwesomeAssertions; |
| 33 | using Microsoft.Extensions.Logging; |
| 34 | ``` |
| 35 | |
| 36 | ## Test Double 五大類型 |
| 37 | |
| 38 | 根據 Gerard Meszaros 在《xUnit Test Patterns》中的定義,測試替身分為五種類型: |
| 39 | |
| 40 | | 類型 | 用途 | NSubstitute 對應 | |
| 41 | |------|------|-------------------| |
| 42 | | **Dummy** | 填充物件,僅滿足方法簽章 | `Substitute.For<T>()` 不設定任何行為 | |
| 43 | | **Stub** | 提供預設回傳值,設定測試情境 | `.Returns(value)` | |
| 44 | | **Fake** | 簡化實作,有真實邏輯 | 手動實作介面(如 `FakeUserRepository`) | |
| 45 | | **Spy** | 記錄呼叫,事後驗證 | `.Received()` 驗證 | |
| 46 | | **Mock** | 預設期望互動,未滿足則測試失敗 | `.Received(n)` 嚴格驗證 | |
| 47 | |
| 48 | > 各類型的完整程式碼範例請參閱 [references/test-double-types.md](references/test-double-types.md) |
| 49 | |
| 50 | ## NSubstitute 核心功能 |
| 51 | |
| 52 | ### 基本替代語法 |
| 53 | |
| 54 | ```csharp |
| 55 | // 建立介面替代 |
| 56 | var substitute = Substitute.For<IUserRepository>(); |
| 57 | |
| 58 | // 建立類別替代(需要虛擬成員) |
| 59 | var classSubstitute = Substitute.For<BaseService>(); |
| 60 | |
| 61 | // 建立多重介面替代 |
| 62 | var multiSubstitute = Substitute.For<IService, IDisposable>(); |
| 63 | ``` |
| 64 | |
| 65 | ### 回傳值設定 |
| 66 | |
| 67 | #### 基本回傳值 |
| 68 | |
| 69 | ```csharp |
| 70 | // 精確參數匹配 |
| 71 | _repository.GetById(1).Returns(new User { Id = 1, Name = "John" }); |
| 72 | |
| 73 | // 任意參數匹配 |
| 74 | _service.Process(Arg.Any<string>()).Returns("processed"); |
| 75 | |
| 76 | // 回傳序列值 |
| 77 | _generator.GetNext().Returns(1, 2, 3, 4, 5); |
| 78 | ``` |
| 79 | |
| 80 | #### 條件回傳值 |
| 81 | |
| 82 | ```csharp |
| 83 | // 使用委派計算回傳值 |
| 84 | _calculator.Add(Arg.Any<int>(), Arg.Any<int>()) |
| 85 | .Returns(x => (int)x[0] + (int)x[1]); |
| 86 | |
| 87 | // 條件匹配 |
| 88 | _service.Process(Arg.Is<string>(x => x.StartsWith("test"))) |
| 89 | .Returns("test-result"); |
| 90 | ``` |
| 91 | |
| 92 | #### 拋出例外 |
| 93 | |
| 94 | ```csharp |
| 95 | // 同步方法拋出例外 |
| 96 | _service.RiskyOperation() |
| 97 | .Throws(new InvalidOperationException("Something went wrong")); |
| 98 | |
| 99 | // 非同步方法拋出例外 |
| 100 | _service.RiskyOperationAsync() |
| 101 | .Throws(new InvalidOperationException("Async operation failed")); |
| 102 | ``` |
| 103 | |
| 104 | ### 引數匹配器 |
| 105 | |
| 106 | ```csharp |
| 107 | // 任意值 |
| 108 | _service.Process(Arg.Any<string>()).Returns("result"); |
| 109 | |
| 110 | // 特定條件 |
| 111 | _service.Process(Arg.Is<string>(x => x.Length > 5)).Returns("long-result"); |
| 112 | |
| 113 | // 引數擷取 |
| 114 | string capturedArg = null; |
| 115 | _service.Process(Arg.Do<string>(x => capturedArg = x)).Returns("result"); |
| 116 | _service.Process("test"); |
| 117 | capturedArg.Should().Be("test"); |
| 118 | |
| 119 | // 引數檢查 |
| 120 | _service.Process(Arg.Is<string>(x => |
| 121 | { |
| 122 | x.Should().StartWith("prefix"); |
| 123 | return true; |
| 124 | })).Returns("result"); |
| 125 | ``` |
| 126 | |
| 127 | ### 呼叫驗證 |
| 128 | |
| 129 | ```csharp |
| 130 | // 驗證被呼叫(至少一次) |
| 131 | _service.Received().Process("test"); |
| 132 | |
| 133 | // 驗證呼叫次數 |
| 134 | _service.Received(2).Process(Arg.Any<string>()); |
| 135 | |
| 136 | // 驗證未被呼叫 |
| 137 | _service.DidNotReceive().Delete(Arg.Any<int>()); |
| 138 | |
| 139 | // 驗證任意參數呼叫 |
| 140 | _service.ReceivedWithAnyArgs().Process(default); |
| 141 | |
| 142 | // 驗證呼叫順序 |
| 143 | Received.InOrder(() => |
| 144 | { |
| 145 | _service.Start(); |
| 146 | _service.Process(); |
| 147 | _service.Stop(); |
| 148 | }); |
| 149 | ``` |
| 150 | |
| 151 | ## 實戰模式 |
| 152 | |
| 153 | 涵蓋五種常見的 NSubstitute 實戰模式,包含完整程式碼範例: |
| 154 | |
| 155 | | 模式 | 說明 | |
| 156 | |------|------| |
| 157 | | 模式 1:依賴注入與測試設定 | FileBackupService 完整範例,含建構式注入與 SUT 設定 | |
| 158 | | 模式 2:Mock vs Stub 差異 | Stub 關注狀態回傳值 vs Mock 關注互動行為驗證 | |
| 159 | | 模式 3:非同步方法測試 | `Returns(Task.FromResult(...))` 與 `.Throws()` 模式 | |
| 160 | | 模式 4:ILogger 驗證 | 驗證底層 `Log` 方法繞過擴展方法限制 | |
| 161 | | 模式 5:複雜設定管理 | 基底測試類別管理共用 Substitute 設定 | |
| 162 | |
| 163 | > 完整程式碼範例請參閱 [references/practical-patterns.md](references/practical-patterns.md) |
| 164 | |
| 165 | ## 引數匹配進階技巧 |
| 166 | |
| 167 | ### 複雜物件匹配 |
| 168 | |
| 169 | ```csharp |
| 170 | [Fact] |
| 171 | public void CreateOrder_建立訂單_應儲存正確的訂單資料() |
| 172 | { |
| 173 | var repository = Substitute.For<IOrderRepository>(); |
| 174 | var service = new OrderService(repository); |
| 175 | |
| 176 | service.CreateOrder("Product A", 5, 100); |
| 177 | |
| 178 | // 驗證物件屬性 |
| 179 | repository.Received(1).Save(Arg.Is<Order>(o => |
| 180 | o.ProductName == "Product A" && |
| 181 | o.Quantity == 5 && |
| 182 | o.Price == 100)); |
| 183 | } |
| 184 | ``` |
| 185 | |
| 186 | ### 引數擷取與驗證 |
| 187 | |
| 188 | ```csharp |
| 189 | [Fact] |
| 190 | public void RegisterUser_註冊使用者_應產生正確的雜湊密碼() |
| 191 | { |
| 192 | var repository = Substitute.For<IUserRepository>(); |
| 193 | var service = new UserService(repository); |
| 194 | |
| 195 | User capturedUser = null; |
| 196 | repository.Save(Arg.Do<User>(u => capturedUser = u)); |
| 197 | |
| 198 | service.RegisterUser("john@example.com", "password123"); |
| 199 | |
| 200 | capturedUser.Should().NotBeNull(); |
| 201 | capturedUser.Email.Should().Be("john@example.com"); |
| 202 | capturedUser.PasswordHash.Should().NotBe("password123"); // 應該被雜湊 |
| 203 | capturedUser.PasswordHash.Length.Should().BeGreaterThan |