--- name: dotnet-testing-awesome-assertions-guide description: | 使用 AwesomeAssertions 進行流暢且可讀的測試斷言技能。 當需要撰寫清晰的斷言、比對物件、驗證集合、處理複雜比對時使用。 涵蓋 Should()、BeEquivalentTo()、Contain()、ThrowAsync() 等完整 API。 triggers: # 核心關鍵字 - assertions - awesome assertions - fluent assertions - 斷言 - 流暢斷言 - should - assert # 方法名稱 - Should() - Be() - BeEquivalentTo - Contain - ThrowAsync - NotBeNull - BeTrue - BeFalse # 使用情境 - 斷言寫法 - 物件比對 - 集合驗證 - 例外斷言 - assertion syntax - object comparison - collection assertion - exception testing # 技術術語 - AwesomeAssertions - FluentAssertions - assertion methods - fluent syntax - 流暢語法 license: MIT metadata: author: Kevin Tseng version: "1.0.0" tags: ".NET, testing, AwesomeAssertions, FluentAssertions, assertions" --- # AwesomeAssertions 流暢斷言指南 本技能提供使用 AwesomeAssertions 進行高品質測試斷言的完整指南,涵蓋基礎語法、進階技巧與最佳實踐。 ## 關於 AwesomeAssertions **AwesomeAssertions** 是 FluentAssertions 的社群分支版本,使用 **Apache 2.0** 授權,完全免費且無商業使用限制。 ### 核心特色 - ✅ **完全免費**:Apache 2.0 授權,適合商業專案使用 - 🔗 **流暢語法**:支援方法鏈結的自然語言風格 - 📦 **豐富斷言**:涵蓋物件、集合、字串、數值、例外等各種類型 - 💬 **優秀錯誤訊息**:提供詳細且易理解的失敗資訊 - ⚡ **高性能**:優化的實作確保測試執行效率 - 🔧 **可擴展**:支援自訂 Assertions 方法 ### 與 FluentAssertions 的關係 AwesomeAssertions 是 FluentAssertions 的社群 fork,主要差異: | 項目 | FluentAssertions | AwesomeAssertions | | -------------- | ------------------ | ---------------------- | | **授權** | 商業專案需付費 | Apache 2.0(完全免費) | | **命名空間** | `FluentAssertions` | `AwesomeAssertions` | | **API 相容性** | 原版 | 高度相容 | | **社群支援** | 官方維護 | 社群維護 | --- ## 安裝與設定 ### NuGet 套件安裝 ```bash # .NET CLI dotnet add package AwesomeAssertions # Package Manager Console Install-Package AwesomeAssertions ``` ### csproj 設定(推薦) ```xml ``` ### 命名空間引用 ```csharp using AwesomeAssertions; using Xunit; ``` --- ## 核心 Assertions 語法 ### 1. 物件斷言(Object Assertions) #### 基本檢查 ```csharp [Fact] public void Object_基本斷言_應正常運作() { var user = new User { Id = 1, Name = "John", Email = "john@example.com" }; // 空值檢查 user.Should().NotBeNull(); // 類型檢查 user.Should().BeOfType(); user.Should().BeAssignableTo(); // 相等性檢查 var anotherUser = new User { Id = 1, Name = "John", Email = "john@example.com" }; user.Should().BeEquivalentTo(anotherUser); } ``` #### 屬性驗證 ```csharp [Fact] public void Object_屬性驗證_應正常運作() { var user = new User { Id = 1, Name = "John", Email = "john@example.com" }; // 單一屬性驗證 user.Id.Should().Be(1); user.Name.Should().Be("John"); user.Email.Should().Contain("@"); // 多屬性驗證 user.Should().BeEquivalentTo(new { Id = 1, Name = "John" }); } ``` ### 2. 字串斷言(String Assertions) #### 內容驗證 ```csharp [Fact] public void String_內容驗證_應正常運作() { var text = "Hello World"; // 基本檢查 text.Should().NotBeNullOrEmpty(); text.Should().NotBeNullOrWhiteSpace(); // 內容檢查 text.Should().Contain("Hello"); text.Should().StartWith("Hello"); text.Should().EndWith("World"); // 精確匹配 text.Should().Be("Hello World"); text.Should().BeEquivalentTo("hello world"); // 忽略大小寫 } ``` #### 模式匹配 ```csharp [Fact] public void String_模式匹配_應正常運作() { var email = "user@example.com"; // 正規表示式匹配 email.Should().MatchRegex(@"^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$"); // 長度驗證 email.Should().HaveLength(16); email.Should().HaveLengthGreaterThan(10); email.Should().HaveLengthLessThanOrEqualTo(50); } ``` ### 3. 數值斷言(Numeric Assertions) #### 範圍與比較 ```csharp [Fact] public void Numeric_範圍檢查_應正常運作() { var value = 10; // 比較運算 value.Should().BeGreaterThan(5); value.Should().BeLessThan(15); value.Should().BeGreaterThanOrEqualTo(10); value.Should().BeLessThanOrEqualTo(10); // 範圍檢查 value.Should().BeInRange(5, 15); value.Should().BeOneOf(8, 9, 10, 11); } ``` #### 浮點數處理 ```csharp [Fact] public void Numeric_浮點數精度_應正常運作() { var pi = 3.14159; // 精度比較 pi.Should().BeApproximately(3.14, 0.01); // 特殊值檢查 double.NaN.Should().Be(double.NaN); double.PositiveInfinity.Should().BePositiveInfinity(); // 符號檢查 pi.Should().BePositive(); (-5.5).Should().BeNegative(); } ``` ### 4. 集合斷言(Collection Assertions) #### 基本檢查 ```csharp [Fact] public void Collection_基本驗證_應正常運作() { var numbers = new[] { 1, 2, 3, 4, 5 }; // 數量檢查 numbers.Should().NotBeEmpty(); numbers.Should().HaveCount(5); numbers.Should().HaveCountGreaterThan(3); // 內容檢查 numbers.Should().Contain(3); numbers.Should().ContainSingle(x => x == 3); numbers.Should().NotContain(0); // 完整比對 numbers.Should().Equal(1, 2, 3, 4, 5); numbers.Should().BeEquivalentTo(new[] { 5, 4, 3, 2, 1 }); // 忽略順序 } ``` #### 順序與唯一性 ```csharp [Fact] public void Collection_順序驗證_應正常運作() { var numbers = new[] { 1, 2, 3, 4, 5 }; // 順序檢查 numbers.Should().BeInAscendingOrder(); numbers.Should().BeInDescendingOrder(); // 唯一性檢查 numbers.Should().OnlyHaveUniqueItems(); // 子集檢查 numbers.Should().BeSubsetOf(new[] { 1, 2, 3, 4, 5, 6, 7 }); numbers.Should().Contain(x => x > 3); } ``` #### 複雜物件集合 ```csharp [Fact] public void Collection_複雜物件_應正常運作() { var users = new[] { new User { Id = 1, Name = "John", Age = 30 }, new User { Id = 2, Name = "Jane", Age = 25 }, new User { Id = 3, Name = "Bob", Age = 35 } }; // 條件過濾 users.Should().Contain(u => u.Name == "John"); users.Should().OnlyContain(u => u.Age >= 18); // 全部滿足 users.Should().AllSatisfy(u => { u.Id.Should().BeGreaterThan(0); u.Name.Should().NotBeNullOrEmpty(); }); // LINQ 整合 users.Where(u => u.Age > 30).Should().HaveCount(1); } ``` ### 5. 例外斷言(Exception Assertions) #### 基本例外處理 ```csharp [Fact] public void Exception_基本驗證_應正常運作() { var service = new UserService(); // 預期拋出例外 Action act = () => service.GetUser(-1); act.Should().Throw() .WithMessage("*User ID*") .And.ParamName.Should().Be("userId"); } ``` #### 不應拋出例外 ```csharp [Fact] public void Exception_不應拋出_應正常運作() { var calculator = new Calculator(); // 不應拋出任何例外 Action act = () => calculator.Add(1, 2); act.Should().NotThrow(); // 不應拋出特定例外 act.Should().NotThrow(); } ``` #### 巢狀例外 ```csharp [Fact] public void Exception_巢狀例外_應正常運作() { var service = new DatabaseService(); Action act = () => service.Connect("invalid"); act.Should().Throw() .WithInnerException() .WithMessage("*connection string*"); } ``` ### 6. 非同步斷言(Async Assertions) #### Task 完成驗證 ```csharp [Fact] public async Task Async_任務完成_應正常運作() { var service = new UserService(); // 等待任務完成 var task = service.GetUserAsync(1); await task.Should().CompleteWithinAsync(TimeSpan.FromSeconds(5)); // 驗證結果 task.Result.Should().NotBeNull(); task.Result.Id.Should().Be(1); } ``` #### 非同步例外 ```csharp [Fact] public async Task Async_例外處理_應正常運作() { var service = new ApiService(); Func act = async () => await service.CallInvalidEndpointAsync(); await act.Should().ThrowAsync() .WithMessage("*404*"); } ``` --- ## 進階技巧:複雜物件比對 ### 深度物件比較 #### 完整物件比對 ```csharp [Fact] public void ComplexObject_深度比較_應正常運作() { var expected = new Order { Id = 1, CustomerName = "John Doe", Items = new[] { new OrderItem { ProductId = 1, Quantity = 2, Price = 10.5m }, new OrderItem { ProductId = 2, Quantity = 1, Price = 25.0m } }, TotalAmount = 46.0m, CreatedAt = DateTime.Now }; var actual = orderService.CreateOrder(orderRequest); // 深度物件比較 actual.Should().BeEquivalentTo(expected); } ``` #### 排除特定屬性 ```csharp [Fact] public void ComplexObject_排除屬性_應正常運作() { var user = userService.CreateUser("john@example.com"); user.Should().BeEquivalentTo(new { Email = "john@example.com", IsActive = true }, options => options .Excluding(u => u.Id) // 排除自動生成的 ID .Excluding(u => u.CreatedAt) // 排除時間戳記 .Excluding(u => u.UpdatedAt) ); } ``` #### 動態欄位排除 ```csharp [Fact] public void ComplexObject_動態排除_應正常運作() { var entity = entityService.CreateEntity(data); // 使用模式排除所有時間相關欄位 entity.Should().BeEquivalentTo(expectedEntity, options => options .Excluding(ctx => ctx.Path.EndsWith("At")) .Excluding(ctx => ctx.Path.EndsWith("Time")) .Excluding(ctx => ctx.Path.Contains("Timestamp")) ); } ``` ### 循環參考處理 ```csharp [Fact] public void ComplexObject_循環參考_應正常運作() { var parent = new TreeNode { Value = "Root" }; var child = new TreeNode { Value = "Child", Parent = parent }; parent.Children = new[] { child }; var actualTree = treeService.GetTree("Root"); // 處理循環參考 actualTree.Should().BeEquivalentTo(parent, options => options .IgnoringCyclicReferences() .WithMaxRecursionDepth(10) ); } ``` --- ## 進階技巧:自訂 Assertions 擴展 ### 領域特定 Assertions 建立專案特定的斷言方法,提升測試可讀性與可維護性。 #### 範例:電商領域 Assertions 參考 [templates/custom-assertions-template.cs](templates/custom-assertions-template.cs) 瞭解完整實作。 ```csharp public static class ECommerceAssertions { public static AndConstraint BeValidProduct( this ObjectAssertions assertions) { var product = assertions.Subject as Product; product.Should().NotBeNull(); product!.Id.Should().BeGreaterThan(0); product.Name.Should().NotBeNullOrEmpty(); product.Price.Should().BeGreaterThan(0); return new AndConstraint(assertions); } public static AndConstraint BeValidOrder( this ObjectAssertions assertions) { var order = assertions.Subject as Order; order.Should().NotBeNull(); order!.Items.Should().NotBeNullOrEmpty(); order.TotalAmount.Should().BeGreaterThan(0); return new AndConstraint(assertions); } } ``` #### 使用自訂 Assertions ```csharp [Fact] public void Product_建立產品_應為有效產品() { var product = productService.Create("Laptop", 999.99m); // 使用領域特定斷言 product.Should().BeValidProduct(); product.Name.Should().Be("Laptop"); } ``` ### 可重用排除擴展 ```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.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")); } } ``` 使用範例: ```csharp [Fact] public void Entity_比對_應使用智慧排除() { var user = userService.CreateUser("test@example.com"); var retrieved = userService.GetUser(user.Id); retrieved.Should().BeEquivalentTo(user, options => options .ExcludingAutoGeneratedFields() .ExcludingAuditFields() ); } ``` --- ## 效能最佳化策略 ### 大量資料斷言 處理大量資料時的最佳實踐: ```csharp [Fact] public void LargeCollection_效能優化_應快速執行() { var largeDataset = Enumerable.Range(1, 100000) .Select(i => new DataRecord { Id = i, Value = $"Record_{i}" }) .ToList(); var processed = dataProcessor.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().NotBeNull(); processed[index].Id.Should().BeGreaterThan(0); } } ``` ### 選擇性屬性比對 ```csharp [Fact] public void ComplexObject_選擇性比對_應提升效能() { var order = orderService.CreateOrder(request); // 只比對關鍵屬性,而非全物件掃描 order.Should().BeEquivalentTo(new { CustomerId = 123, TotalAmount = 999.99m, Status = "Pending" }, options => options .ExcludingMissingMembers() ); } ``` --- ## 最佳實踐與團隊標準 ### 測試命名規範 遵循 `方法_情境_預期結果` 模式: ```csharp public class UserServiceTests { [Fact] public void CreateUser_有效電子郵件_應回傳啟用的使用者() { // Arrange var email = "john@example.com"; // Act var user = userService.CreateUser(email); // Assert user.Should().NotBeNull(); user.Email.Should().Be(email); user.IsActive.Should().BeTrue(); } [Theory] [InlineData("", "Email cannot be empty")] [InlineData(null, "Email cannot be null")] public void CreateUser_無效電子郵件_應拋出參數例外( string invalidEmail, string expectedMessage) { Action act = () => userService.CreateUser(invalidEmail); act.Should().Throw() .WithMessage($"*{expectedMessage}*"); } } ``` ### 錯誤訊息優化 提供清晰的失敗上下文: ```csharp [Fact] public void Payment_無效金額_應提供詳細錯誤() { var payment = new PaymentRequest { Amount = -100 }; var result = paymentService.ProcessPayment(payment); // 提供詳細的失敗原因 result.IsSuccess.Should().BeFalse( "because negative payment amounts are not allowed"); result.ErrorMessage.Should().Contain("amount", "because error message should specify the problematic field"); result.ErrorCode.Should().Be("INVALID_AMOUNT", "because specific error codes help with troubleshooting"); } ``` ### AssertionScope 使用 收集多個失敗訊息: ```csharp [Fact] public void User_完整驗證_應收集所有失敗() { var user = userService.CreateUser(testData); using (new AssertionScope()) { user.Should().NotBeNull("User creation should not fail"); user.Id.Should().BeGreaterThan(0, "User should have valid ID"); user.Email.Should().NotBeNullOrEmpty("Email is required"); user.IsActive.Should().BeTrue("New users should be active"); } // 所有失敗的斷言會一次顯示 } ``` --- ## 常見情境與解決方案 ### 情境 1:API 回應驗證 ```csharp [Fact] public void API_使用者資料_應符合規格() { var response = apiClient.GetUserProfile(userId); response.StatusCode.Should().Be(200); response.Content.Should().NotBeNullOrEmpty(); var user = JsonSerializer.Deserialize(response.Content); user.Should().BeEquivalentTo(new { Id = userId, Email = expectedEmail }, options => options .Including(u => u.Id) .Including(u => u.Email) ); } ``` ### 情境 2:資料庫實體驗證 ```csharp [Fact] public void Database_儲存實體_應正確持久化() { var user = new User { Name = "John", Email = "john@example.com" }; dbContext.Users.Add(user); dbContext.SaveChanges(); var saved = dbContext.Users.Find(user.Id); saved.Should().BeEquivalentTo(user, options => options .Excluding(u => u.CreatedAt) .Excluding(u => u.UpdatedAt) .Excluding(u => u.RowVersion) ); } ``` ### 情境 3:事件驗證 ```csharp [Fact] public void Event_發佈事件_應包含正確資料() { var eventRaised = false; OrderCreatedEvent? capturedEvent = null; eventBus.Subscribe(e => { eventRaised = true; capturedEvent = e; }); orderService.CreateOrder(orderRequest); eventRaised.Should().BeTrue("Order creation should raise event"); capturedEvent.Should().NotBeNull(); capturedEvent!.OrderId.Should().BeGreaterThan(0); capturedEvent.TotalAmount.Should().Be(expectedAmount); } ``` --- ## 疑難排解 ### 問題 1:BeEquivalentTo 失敗但物件看起來相同 **原因**:可能包含自動生成欄位或時間戳記 **解決方案**: ```csharp // 排除動態欄位 actual.Should().BeEquivalentTo(expected, options => options .Excluding(x => x.Id) .Excluding(x => x.CreatedAt) .Excluding(x => x.UpdatedAt) ); ``` ### 問題 2:集合順序不同導致失敗 **原因**:集合順序不同 **解決方案**: ```csharp // 使用 BeEquivalentTo 忽略順序 actual.Should().BeEquivalentTo(expected); // 不檢查順序 // 或明確指定需要檢查順序 actual.Should().Equal(expected); // 檢查順序 ``` ### 問題 3:浮點數比較失敗 **原因**:浮點數精度問題 **解決方案**: ```csharp // 使用精度容差 actualValue.Should().BeApproximately(expectedValue, 0.001); ``` --- ## 何時使用此技能 ### 適用情境 ✅ 撰寫單元測試或整合測試時 ✅ 需要驗證複雜物件結構時 ✅ 比對 API 回應或資料庫實體時 ✅ 需要清晰的失敗訊息時 ✅ 建立領域特定測試標準時 ### 不適用情境 ❌ 效能測試(使用專用 benchmarking 工具) ❌ 負載測試(使用 K6、JMeter 等) ❌ UI 測試(使用 Playwright、Selenium) --- ## 與其他技能的配合 ### 與 unit-test-fundamentals 搭配 先使用 `unit-test-fundamentals` 建立測試結構,再使用本技能撰寫斷言: ```csharp [Fact] public void Calculator_Add_兩個正數_應回傳總和() { // Arrange - 遵循 3A Pattern var calculator = new Calculator(); // Act var result = calculator.Add(2, 3); // Assert - 使用 AwesomeAssertions result.Should().Be(5); } ``` ### 與 test-naming-conventions 搭配 使用 `test-naming-conventions` 的命名規範,搭配本技能的斷言: ```csharp [Fact] public void CreateUser_有效資料_應回傳啟用使用者() { var user = userService.CreateUser("test@example.com"); user.Should().NotBeNull() .And.BeOfType(); user.IsActive.Should().BeTrue(); } ``` ### 與 xunit-project-setup 搭配 在 `xunit-project-setup` 建立的專案中安裝並使用 AwesomeAssertions。 --- ## 參考資源 ### 原始文章 本技能內容提煉自「老派軟體工程師的測試修練 - 30 天挑戰」系列文章: - **Day 04 - AwesomeAssertions 基礎應用與實戰技巧** - 鐵人賽文章:https://ithelp.ithome.com.tw/articles/10374188 - 範例程式碼:https://github.com/kevintsengtw/30Days_in_Testing_Samples/tree/main/day04 - **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 官方文件**:https://awesomeassertions.org/ ### 相關文章 - **Fluent Assertions 授權變化討論**:https://www.dotblogs.com.tw/mrkt/2025/04/19/152408 --- ## 總結 AwesomeAssertions 提供了強大且可讀的斷言語法,是撰寫高品質測試的重要工具。透過: 1. **流暢語法**:讓測試程式碼更易讀 2. **豐富斷言**:涵蓋各種資料類型 3. **自訂擴展**:建立領域特定斷言 4. **效能優化**:處理大量資料情境 5. **完全免費**:Apache 2.0 授權無商業限制 記住:好的斷言不僅能驗證結果,更能清楚表達預期行為,並在失敗時提供有用的診斷資訊。 參考 [templates/assertion-examples.cs](templates/assertion-examples.cs) 查看更多實用範例。