$npx -y skills add kevintsengtw/dotnet-testing-agent-skills --skill dotnet-testing-test-output-loggingxUnit 測試輸出與記錄完整指南。當需要在 xUnit 測試中實作測試輸出、診斷記錄或 ILogger 替代品時使用。涵蓋 ITestOutputHelper 注入、AbstractLogger 模式、結構化輸出設計。包含 XUnitLogger、CompositeLogger、效能測試診斷工具實作。 Make sure to use this skill whenever the user mentions ITestOutputHelper, test output, test logging, XUnitLogger, AbstractLogge
| 1 | # 測試輸出與記錄專家指南 |
| 2 | |
| 3 | 本技能協助您在 .NET xUnit 測試專案中實作高品質的測試輸出與記錄機制。 |
| 4 | |
| 5 | ## 核心原則 |
| 6 | |
| 7 | ### 1. ITestOutputHelper 使用原則 |
| 8 | |
| 9 | **正確的注入方式** |
| 10 | |
| 11 | - 透過建構式注入 `ITestOutputHelper` |
| 12 | - 每個測試類別的實例與測試方法綁定 |
| 13 | - 不可在靜態方法或跨測試方法間共用 |
| 14 | |
| 15 | ```csharp |
| 16 | public class MyTests |
| 17 | { |
| 18 | private readonly ITestOutputHelper _output; |
| 19 | |
| 20 | public MyTests(ITestOutputHelper testOutputHelper) |
| 21 | { |
| 22 | _output = testOutputHelper; |
| 23 | } |
| 24 | } |
| 25 | ``` |
| 26 | |
| 27 | **常見錯誤** |
| 28 | |
| 29 | - 靜態存取:`private static ITestOutputHelper _output` |
| 30 | - 在非同步測試中未等待即使用 |
| 31 | - 嘗試在 Dispose 方法中使用 |
| 32 | |
| 33 | ### 2. 結構化輸出格式設計 |
| 34 | |
| 35 | **建議的輸出結構** |
| 36 | |
| 37 | ```csharp |
| 38 | private void LogSection(string title) |
| 39 | { |
| 40 | _output.WriteLine($"\n=== {title} ==="); |
| 41 | } |
| 42 | |
| 43 | private void LogKeyValue(string key, object value) |
| 44 | { |
| 45 | _output.WriteLine($"{key}: {value}"); |
| 46 | } |
| 47 | |
| 48 | private void LogTimestamp(DateTime time) |
| 49 | { |
| 50 | _output.WriteLine($"執行時間: {time:yyyy-MM-dd HH:mm:ss.fff}"); |
| 51 | } |
| 52 | ``` |
| 53 | |
| 54 | **輸出時機** |
| 55 | |
| 56 | - 測試開始時:記錄測試設置與輸入資料 |
| 57 | - 執行過程中:記錄重要的狀態變化 |
| 58 | - 斷言前:記錄預期值與實際值 |
| 59 | - 測試結束時:記錄執行時間與結果摘要 |
| 60 | |
| 61 | ### 3. ILogger 測試策略 |
| 62 | |
| 63 | **挑戰:擴充方法無法直接 Mock** |
| 64 | |
| 65 | `ILogger.LogError()` 是擴充方法,NSubstitute 無法直接攔截。需要攔截底層的 `Log<TState>` 方法: |
| 66 | |
| 67 | ```csharp |
| 68 | // ❌ 錯誤:直接 Mock 擴充方法會失敗 |
| 69 | logger.Received().LogError(Arg.Any<string>()); |
| 70 | |
| 71 | // ✅ 正確:攔截底層方法 |
| 72 | logger.Received().Log( |
| 73 | LogLevel.Error, |
| 74 | Arg.Any<EventId>(), |
| 75 | Arg.Is<object>(o => o.ToString().Contains("預期訊息")), |
| 76 | Arg.Any<Exception>(), |
| 77 | Arg.Any<Func<object, Exception, string>>() |
| 78 | ); |
| 79 | ``` |
| 80 | |
| 81 | **解決方案:使用抽象層** |
| 82 | |
| 83 | 建立 `AbstractLogger<T>` 來簡化測試: |
| 84 | |
| 85 | ```csharp |
| 86 | public abstract class AbstractLogger<T> : ILogger<T> |
| 87 | { |
| 88 | public IDisposable BeginScope<TState>(TState state) |
| 89 | => null; |
| 90 | |
| 91 | public bool IsEnabled(LogLevel logLevel) |
| 92 | => true; |
| 93 | |
| 94 | public void Log<TState>( |
| 95 | LogLevel logLevel, |
| 96 | EventId eventId, |
| 97 | TState state, |
| 98 | Exception exception, |
| 99 | Func<TState, Exception, string> formatter) |
| 100 | { |
| 101 | Log(logLevel, exception, state?.ToString() ?? string.Empty); |
| 102 | } |
| 103 | |
| 104 | public abstract void Log(LogLevel logLevel, Exception ex, string information); |
| 105 | } |
| 106 | ``` |
| 107 | |
| 108 | **測試時使用** |
| 109 | |
| 110 | ```csharp |
| 111 | var logger = Substitute.For<AbstractLogger<MyService>>(); |
| 112 | // 現在可以簡單驗證 |
| 113 | logger.Received().Log(LogLevel.Error, Arg.Any<Exception>(), Arg.Is<string>(s => s.Contains("錯誤訊息"))); |
| 114 | ``` |
| 115 | |
| 116 | ### 4. 診斷工具整合 |
| 117 | |
| 118 | **XUnitLogger:將記錄導向測試輸出** |
| 119 | |
| 120 | ```csharp |
| 121 | public class XUnitLogger<T> : ILogger<T> |
| 122 | { |
| 123 | private readonly ITestOutputHelper _testOutputHelper; |
| 124 | |
| 125 | public XUnitLogger(ITestOutputHelper testOutputHelper) |
| 126 | { |
| 127 | _testOutputHelper = testOutputHelper; |
| 128 | } |
| 129 | |
| 130 | public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, |
| 131 | Exception exception, Func<TState, Exception, string> formatter) |
| 132 | { |
| 133 | var message = formatter(state, exception); |
| 134 | _testOutputHelper.WriteLine($"[{DateTime.Now:HH:mm:ss.fff}] [{logLevel}] [{typeof(T).Name}] {message}"); |
| 135 | if (exception != null) |
| 136 | { |
| 137 | _testOutputHelper.WriteLine($"Exception: {exception}"); |
| 138 | } |
| 139 | } |
| 140 | |
| 141 | // 其他必要的介面實作... |
| 142 | } |
| 143 | ``` |
| 144 | |
| 145 | **CompositeLogger:同時支援驗證與輸出** |
| 146 | |
| 147 | ```csharp |
| 148 | public class CompositeLogger<T> : ILogger<T> |
| 149 | { |
| 150 | private readonly ILogger<T>[] _loggers; |
| 151 | |
| 152 | public CompositeLogger(params ILogger<T>[] loggers) |
| 153 | { |
| 154 | _loggers = loggers; |
| 155 | } |
| 156 | |
| 157 | public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, |
| 158 | Exception exception, Func<TState, Exception, string> formatter) |
| 159 | { |
| 160 | foreach (var logger in _loggers) |
| 161 | { |
| 162 | logger.Log(logLevel, eventId, state, exception, formatter); |
| 163 | } |
| 164 | } |
| 165 | |
| 166 | // 其他介面實作會委派給所有內部 logger... |
| 167 | } |
| 168 | ``` |
| 169 | |
| 170 | **使用方式** |
| 171 | |
| 172 | ```csharp |
| 173 | // 同時進行行為驗證與測試輸出 |
| 174 | var mockLogger = Substitute.For<AbstractLogger<MyService>>(); |
| 175 | var xunitLogger = new XUnitLogger<MyService>(_output); |
| 176 | var compositeLogger = new CompositeLogger<MyService>(mockLogger, xunitLogger); |
| 177 | |
| 178 | var service = new MyService(compositeLogger); |
| 179 | ``` |
| 180 | |
| 181 | ## 實作指南 |
| 182 | |
| 183 | ### 效能測試中的時間點記錄 |
| 184 | |
| 185 | ```csharp |
| 186 | [Fact] |
| 187 | public async Task ProcessLargeDataSet_效能測試() |
| 188 | { |
| 189 | // Arrange |
| 190 | var stopwatch = Stopwatch.StartNew(); |
| 191 | var checkpoints = new List<(string Stage, TimeSpan Elapsed)>(); |
| 192 | |
| 193 | _output.WriteLine("開始處理大型資料集..."); |
| 194 | |
| 195 | // Act & Monitor |
| 196 | await processor.LoadData(dataSet); |
| 197 | checkpoints.Add(("資料載入", stopwatch.Elapsed)); |
| 198 | _output.WriteLine($"資料載入完成: {stopwatch.Elapsed.TotalMilliseconds:F2} ms"); |
| 199 | |
| 200 | await processor.ProcessData(); |
| 201 | checkpoints.Add(("資料處理", stopwatch.Elapsed)); |
| 202 | _output.WriteLine($"資料處理完成: {stopwatch.Elapsed.TotalMilliseco |