--- name: dotnet-testing-complex-object-comparison description: | 處理複雜物件比對與深層驗證的專門技能。 當需要比對深層物件、排除特定屬性、處理循環參照、驗證 DTO/Entity 時使用。 涵蓋 BeEquivalentTo、Excluding、Including、自訂比對規則等。 triggers: # 核心關鍵字 - object comparison - 物件比對 - deep comparison - 深層比對 - BeEquivalentTo - complex object # 使用情境 - DTO 比對 - Entity 驗證 - 深層物件比對 - 排除屬性 - exclude property - 循環參照 - circular reference # 技術術語 - Excluding - Including - ExcludingNestedObjects - RespectingRuntimeTypes - WithStrictOrdering - object graph # 常見問題 - 比對複雜物件 - 忽略時間戳記 - 忽略 ID - exclude timestamp - exclude auto-generated - 物件比對效能 license: MIT metadata: author: Kevin Tseng version: "1.0.0" tags: ".NET, testing, object comparison, BeEquivalentTo, AwesomeAssertions" --- # Complex Object Comparison Skill ## 技能說明 此技能專注於 .NET 測試中的複雜物件比對場景,使用 AwesomeAssertions 的 `BeEquivalentTo` API 處理各種進階比對需求。 ## 核心使用場景 ### 1. 深層物件結構比對 (Object Graph Comparison) 當需要比對包含多層巢狀屬性的複雜物件時: ```csharp [Fact] public void ComplexObject_深層結構比對_應完全相符() { var expected = new Order { Id = 1, Customer = new Customer { Name = "John Doe", Address = new Address { Street = "123 Main St", City = "Seattle", ZipCode = "98101" } }, Items = new[] { new OrderItem { ProductName = "Laptop", Quantity = 1, Price = 999.99m }, new OrderItem { ProductName = "Mouse", Quantity = 2, Price = 29.99m } } }; var actual = orderService.GetOrder(1); // 深層物件比對 actual.Should().BeEquivalentTo(expected); } ``` ### 2. 循環參照處理 (Circular Reference Handling) 處理物件之間存在循環參照的情況: ```csharp [Fact] public void TreeStructure_循環參照_應正確處理() { // 建立具有父子雙向參照的樹狀結構 var parent = new TreeNode { Value = "Root" }; var child1 = new TreeNode { Value = "Child1", Parent = parent }; var child2 = new TreeNode { Value = "Child2", Parent = parent }; parent.Children = new[] { child1, child2 }; var actualTree = treeService.GetTree("Root"); // 處理循環參照 actualTree.Should().BeEquivalentTo(parent, options => options.IgnoringCyclicReferences() .WithMaxRecursionDepth(10) ); } ``` ### 3. 動態欄位排除 (Dynamic Field Exclusion) #### 3.1 排除時間戳記與自動生成欄位 ```csharp [Fact] public void Entity_排除自動欄位_應驗證業務欄位() { var originalEntity = new UserEntity { Id = 1, Name = "John Doe", Email = "john@example.com", CreatedAt = DateTime.Now.AddDays(-1), UpdatedAt = DateTime.Now.AddDays(-1), Version = 1 }; var updatedEntity = userService.UpdateUser(1, new UpdateUserRequest { Name = "John Doe", Email = "john@example.com" }); // 排除自動更新的欄位 updatedEntity.Should().BeEquivalentTo(originalEntity, options => options.Excluding(e => e.UpdatedAt) .Excluding(e => e.Version) .Excluding(e => e.LastModifiedBy) ); // 單獨驗證動態欄位 updatedEntity.UpdatedAt.Should().BeAfter(originalEntity.UpdatedAt); updatedEntity.Version.Should().Be(originalEntity.Version + 1); } ``` #### 3.2 使用智慧型排除擴充方法 ```csharp // 定義可重複使用的排除策略 public static class SmartExclusionExtensions { public static EquivalencyOptions ExcludingAutoGeneratedFields( this EquivalencyOptions options) { return options .Excluding(ctx => ctx.Path.EndsWith("Id") && ctx.SelectedMemberInfo.Name.StartsWith("Generated")) .Excluding(ctx => ctx.Path.EndsWith("At")) .Excluding(ctx => ctx.Path.EndsWith("Time")) .Excluding(ctx => ctx.Path.Contains("Version")) .Excluding(ctx => ctx.Path.Contains("Timestamp")); } public static EquivalencyOptions ExcludingAuditFields( this EquivalencyOptions options) { return options .Excluding(ctx => ctx.Path.Contains("CreatedBy")) .Excluding(ctx => ctx.Path.Contains("CreatedAt")) .Excluding(ctx => ctx.Path.Contains("ModifiedBy")) .Excluding(ctx => ctx.Path.Contains("ModifiedAt")); } } [Fact] public void Entity_使用智慧排除_應簡化測試() { var user = userService.CreateUser("test@example.com"); var retrievedUser = userService.GetUser(user.Id); // 使用智慧排除擴充方法 retrievedUser.Should().BeEquivalentTo(user, options => options.ExcludingAutoGeneratedFields() .ExcludingAuditFields() ); } ``` ### 4. 巢狀物件欄位排除 (Nested Object Exclusion) ```csharp [Fact] public void ComplexEntity_排除巢狀時間戳記_應正常運作() { var order = new Order { Id = 1, CustomerName = "John Doe", CreatedAt = DateTime.Now, Items = new[] { new OrderItem { Id = 1, ProductName = "Laptop", AddedAt = DateTime.Now } }, AuditInfo = new AuditInfo { CreatedBy = "system", CreatedAt = DateTime.Now } }; var retrievedOrder = orderService.GetOrder(1); // 使用路徑模式排除所有時間戳記 retrievedOrder.Should().BeEquivalentTo(order, options => options.Excluding(ctx => ctx.Path.EndsWith("At")) .Excluding(ctx => ctx.Path.EndsWith("Time")) ); } ``` ### 5. 大量資料比對效能最佳化 #### 5.1 選擇性屬性比對 ```csharp [Fact] public void LargeDataSet_選擇性比對_應高效執行() { var largeDataset = Enumerable.Range(1, 100000) .Select(i => new DataRecord { Id = i, Value = $"Record_{i}", Timestamp = DateTime.Now }) .ToList(); var processed = dataProcessor.Process(largeDataset); // 只比對關鍵屬性,忽略非關鍵欄位 processed.Should().BeEquivalentTo(largeDataset, options => options.Including(x => x.Id) .Including(x => x.Value) .Excluding(x => x.Timestamp) ); } ``` #### 5.2 抽樣驗證策略 ```csharp [Fact] public void LargeCollection_抽樣驗證_應平衡效能與準確性() { var largeDataset = GenerateLargeDataSet(100000); var processed = service.ProcessLargeDataset(largeDataset); // 驗證數量 processed.Should().HaveCount(largeDataset.Count); // 抽樣驗證 var sampleSize = Math.Min(1000, processed.Count / 10); var sampleIndices = Enumerable.Range(0, sampleSize) .Select(i => Random.Shared.Next(processed.Count)) .Distinct() .ToList(); foreach (var index in sampleIndices) { processed[index].Should().BeEquivalentTo(largeDataset[index], options => options.ExcludingAutoGeneratedFields() ); } // 統計驗證 processed.Count(r => r.IsProcessed).Should().Be(processed.Count); } ``` #### 5.3 關鍵屬性快速比對 ```csharp public static class PerformanceOptimizedAssertions { public static void AssertKeyPropertiesOnly( T actual, T expected, params Expression>[] keySelectors) { foreach (var selector in keySelectors) { var actualValue = selector.Compile()(actual); var expectedValue = selector.Compile()(expected); actualValue.Should().Be(expectedValue, $"關鍵屬性 {selector} 應該相符"); } } } [Fact] public void Order_關鍵屬性驗證_應快速完成() { var expected = new Order { Id = 1, CustomerName = "John", TotalAmount = 999.99m, CreatedAt = DateTime.Now }; var actual = orderService.GetOrder(1); // 只比對關鍵屬性,忽略時間戳記 PerformanceOptimizedAssertions.AssertKeyPropertiesOnly( actual, expected, o => o.Id, o => o.CustomerName, o => o.TotalAmount ); } ``` ### 6. 嚴格順序與寬鬆比對 ```csharp [Fact] public void Collection_順序控制_應符合需求() { var expected = new[] { "A", "B", "C" }; var actualStrict = service.GetOrderedList(); var actualLoose = service.GetUnorderedList(); // 嚴格順序比對 actualStrict.Should().BeEquivalentTo(expected, options => options.WithStrictOrdering() ); // 寬鬆比對(不考慮順序) actualLoose.Should().BeEquivalentTo(expected, options => options.WithoutStrictOrdering() ); } ``` ## 比對選項速查表 | 選項方法 | 用途 | 適用場景 | | ---------------------------- | -------------- | -------------------------- | | `Excluding(x => x.Property)` | 排除特定屬性 | 排除時間戳記、自動生成欄位 | | `Including(x => x.Property)` | 只包含特定屬性 | 關鍵屬性驗證 | | `IgnoringCyclicReferences()` | 忽略循環參照 | 樹狀結構、雙向關聯 | | `WithMaxRecursionDepth(n)` | 限制遞迴深度 | 深層巢狀結構 | | `WithStrictOrdering()` | 嚴格順序比對 | 陣列/集合順序重要時 | | `WithoutStrictOrdering()` | 寬鬆順序比對 | 陣列/集合順序不重要時 | | `WithTracing()` | 啟用追蹤 | 除錯複雜比對失敗 | ## 常見比對模式與解決方案 ### 模式 1:Entity Framework 實體比對 ```csharp [Fact] public void EFEntity_資料庫實體_應排除導航屬性() { var expected = new Product { Id = 1, Name = "Laptop", Price = 999 }; var actual = dbContext.Products.Find(1); actual.Should().BeEquivalentTo(expected, options => options.ExcludingMissingMembers() // 排除 EF 追蹤屬性 .Excluding(p => p.CreatedAt) .Excluding(p => p.UpdatedAt) ); } ``` ### 模式 2:API Response 比對 ```csharp [Fact] public void ApiResponse_JSON反序列化_應忽略額外欄位() { var expected = new UserDto { Id = 1, Username = "john_doe" }; var response = await httpClient.GetAsync("/api/users/1"); var actual = await response.Content.ReadFromJsonAsync(); actual.Should().BeEquivalentTo(expected, options => options.ExcludingMissingMembers() // 忽略 API 額外欄位 ); } ``` ### 模式 3:測試資料建構器比對 ```csharp [Fact] public void Builder_測試資料_應匹配預期結構() { var expected = new OrderBuilder() .WithId(1) .WithCustomer("John Doe") .WithItems(3) .Build(); var actual = orderService.CreateOrder(orderRequest); actual.Should().BeEquivalentTo(expected, options => options.Excluding(o => o.OrderNumber) // 系統生成 .Excluding(o => o.CreatedAt) ); } ``` ## 錯誤訊息最佳化 ### 提供有意義的錯誤訊息 ```csharp [Fact] public void Comparison_錯誤訊息_應清楚說明差異() { var expected = new User { Name = "John", Age = 30 }; var actual = userService.GetUser(1); // 使用 because 參數提供上下文 actual.Should().BeEquivalentTo(expected, options => options.Excluding(u => u.Id) .Because("ID 是系統自動生成的,不應納入比對") ); } ``` ### 使用 AssertionScope 進行批次驗證 ```csharp [Fact] public void MultipleComparisons_批次驗證_應一次顯示所有失敗() { var users = userService.GetAllUsers(); using (new AssertionScope()) { foreach (var user in users) { user.Id.Should().BeGreaterThan(0); user.Name.Should().NotBeNullOrEmpty(); user.Email.Should().MatchRegex(@"^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$"); } } // 所有失敗會一起報告,而非遇到第一個失敗就停止 } ``` ## 與其他技能整合 此技能可與以下技能組合使用: - **awesome-assertions-guide**: 基礎斷言語法與常用 API - **autofixture-data-generation**: 自動生成測試資料 - **test-data-builder-pattern**: 建構複雜測試物件 - **unit-test-fundamentals**: 單元測試基礎與 3A 模式 ## 最佳實踐建議 ### ✅ 推薦做法 1. **優先使用屬性排除而非包含**:除非只需驗證少數屬性,否則使用 `Excluding` 更清楚 2. **建立可重用的排除擴充方法**:避免在每個測試重複排除邏輯 3. **為大量資料比對設定合理策略**:平衡效能與驗證完整性 4. **使用 AssertionScope 進行批次驗證**:一次看到所有失敗原因 5. **提供有意義的 because 說明**:幫助未來維護者理解測試意圖 ### ❌ 避免做法 1. **避免過度依賴完整物件比對**:考慮只驗證關鍵屬性 2. **避免忽略循環參照問題**:使用 `IgnoringCyclicReferences()` 明確處理 3. **避免在每個測試重複排除邏輯**:提取為擴充方法 4. **避免對大量資料做完整深度比對**:使用抽樣或關鍵屬性驗證 ## 疑難排解 ### Q1: BeEquivalentTo 效能很慢怎麼辦? **A:** 使用以下策略優化: - 使用 `Including` 只比對關鍵屬性 - 對大量資料採用抽樣驗證 - 使用 `WithMaxRecursionDepth` 限制遞迴深度 - 考慮使用 `AssertKeyPropertiesOnly` 快速比對關鍵欄位 ### Q2: 如何處理 StackOverflowException? **A:** 通常由循環參照引起: ```csharp options.IgnoringCyclicReferences() .WithMaxRecursionDepth(10) ``` ### Q3: 如何排除所有時間相關欄位? **A:** 使用路徑模式匹配: ```csharp options.Excluding(ctx => ctx.Path.EndsWith("At")) .Excluding(ctx => ctx.Path.EndsWith("Time")) .Excluding(ctx => ctx.Path.Contains("Timestamp")) ``` ### Q4: 比對失敗但看不出差異? **A:** 啟用詳細追蹤: ```csharp options.WithTracing() // 產生詳細的比對追蹤資訊 ``` ## 範本檔案參考 本技能提供以下範本檔案: - `templates/comparison-patterns.cs`: 常見比對模式範例 - `templates/exclusion-strategies.cs`: 欄位排除策略與擴充方法 ## 參考資源 ### 原始文章 本技能內容提煉自「老派軟體工程師的測試修練 - 30 天挑戰」系列文章: - **Day 05 - AwesomeAssertions 進階技巧與複雜情境應用** - 鐵人賽文章:https://ithelp.ithome.com.tw/articles/10374425 - 範例程式碼:https://github.com/kevintsengtw/30Days_in_Testing_Samples/tree/main/day05 ### 官方文件 - [AwesomeAssertions GitHub](https://github.com/AwesomeAssertions/AwesomeAssertions) - [AwesomeAssertions Documentation](https://awesomeassertions.org/) ### 相關技能 - `awesome-assertions-guide` - AwesomeAssertions 基礎與進階用法 - `unit-test-fundamentals` - 單元測試基礎