home / skills / kevintsengtw / dotnet-testing-agent-skills / dotnet-testing-complex-object-comparison

dotnet-testing-complex-object-comparison skill

/skills/dotnet-testing-complex-object-comparison

This skill helps you perform deep object comparisons in .NET using BeEquivalentTo with Excluding, Including, and custom rules for DTOs and entities.

npx playbooks add skill kevintsengtw/dotnet-testing-agent-skills --skill dotnet-testing-complex-object-comparison

Review the files below or copy the command above to add this skill to your agents.

Files (4)
SKILL.md
9.1 KB
---
name: dotnet-testing-complex-object-comparison
description: |
  處理複雜物件比對與深層驗證的專門技能。當需要比對深層物件、排除特定屬性、處理循環參照、驗證 DTO/Entity 時使用。涵蓋 BeEquivalentTo、Excluding、Including、自訂比對規則等。
  Keywords: object comparison, 物件比對, deep comparison, 深層比對, BeEquivalentTo, DTO 比對, Entity 驗證, 排除屬性, 循環參照, Excluding, Including, ExcludingNestedObjects, RespectingRuntimeTypes, WithStrictOrdering, 忽略時間戳記, exclude timestamp
license: MIT
metadata:
  author: Kevin Tseng
  version: "1.0.0"
  tags: ".NET, testing, object comparison, BeEquivalentTo, AwesomeAssertions"
  related_skills: "awesome-assertions-guide, autofixture-basics, test-data-builder-pattern"
---

# 複雜物件比對指南(Complex Object Comparison)

## 適用情境

此技能專注於 .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-6. 進階比對模式

FluentAssertions 還提供多種進階比對模式:動態欄位排除(排除時間戳記、自動生成欄位)、巢狀物件欄位排除、大量資料效能最佳化比對(選擇性屬性比對、抽樣驗證策略)、以及嚴格/寬鬆排序控制。

> 📖 完整程式碼範例請參閱 [references/detailed-comparison-patterns.md](references/detailed-comparison-patterns.md)

## 比對選項速查表

| 選項方法                     | 用途           | 適用場景                   |
| ---------------------------- | -------------- | -------------------------- |
| `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<UserDto>();

    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` - 單元測試基礎

Overview

This skill helps .NET developers perform reliable deep object comparisons and validations for complex DTOs, entities, and nested graphs. It focuses on advanced comparison patterns like BeEquivalentTo, property exclusion/inclusion, cyclic reference handling, and custom comparison rules to make tests precise and maintainable.

How this skill works

The skill inspects object graphs and generates FluentAssertions-style comparison options to control recursion depth, ignore cyclic references, and include or exclude properties by expression or path. It produces reusable exclusion strategies, performance-minded sampling approaches for large collections, and clear error-context using Because and tracing options.

When to use it

  • Validating complex nested DTOs or domain entities returned from services or APIs
  • Comparing objects with cyclic references such as parent/child models or graph structures
  • Ignoring generated fields like timestamps, IDs, or audit columns in assertions
  • Asserting collection contents where ordering may be strict or non-strict
  • Optimizing comparisons for large datasets using sampling or key-property checks

Best practices

  • Prefer Excluding specific properties over Including all unless only a few fields matter
  • Create reusable exclusion/including extension methods to avoid repeated test code
  • Use IgnoringCyclicReferences and WithMaxRecursionDepth for graph-like structures
  • Use AssertionScope for batch assertions so all failures are reported at once
  • Balance completeness and performance: sample large collections or assert key properties

Example use cases

  • Compare an API response DTO while excluding server-generated timestamps and extra JSON fields
  • Validate an EF Core entity while excluding navigation/tracking properties and audit columns
  • Assert a tree structure with parent/child references using IgnoringCyclicReferences
  • Verify objects constructed by a test-data builder but ignore generated OrderNumber and CreatedAt
  • Apply WithStrictOrdering when order matters, or WithoutStrictOrdering for unordered collections

FAQ

BeEquivalentTo is slow for large collections—what can I do?

Limit work by Including only key properties, sampling items for full comparison, or set WithMaxRecursionDepth. Extract an AssertKeyPropertiesOnly helper to quickly validate essentials.

How do I avoid StackOverflow from cyclic references?

Enable IgnoringCyclicReferences() and set WithMaxRecursionDepth(n). Those options prevent infinite recursion and bound traversal depth.

How can I exclude all timestamp-like fields without listing each name?

Use path-based exclusions, e.g. Excluding(ctx => ctx.Path.EndsWith("At") || ctx.Path.EndsWith("Time") || ctx.Path.Contains("Timestamp")).