dotnet-testing-awesome-assertions-guide
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 套件安裝
# .NET CLI
dotnet add package AwesomeAssertions
# Package Manager Console
Install-Package AwesomeAssertions
csproj 設定(推薦)
<ItemGroup>
<PackageReference Include="AwesomeAssertions" Version="9.1.0" PrivateAssets="all" />
</ItemGroup>
命名空間引用
using AwesomeAssertions;
using Xunit;
核心 Assertions 語法
1. 物件斷言(Object Assertions)
基本檢查
[Fact]
public void Object_基本斷言_應正常運作()
{
var user = new User { Id = 1, Name = "John", Email = "john@example.com" };
// 空值檢查
user.Should().NotBeNull();
// 類型檢查
user.Should().BeOfType<User>();
user.Should().BeAssignableTo<IUser>();
// 相等性檢查
var anotherUser = new User { Id = 1, Name = "John", Email = "john@example.com" };
user.Should().BeEquivalentTo(anotherUser);
}
屬性驗證
[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)
內容驗證
[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"); // 忽略大小寫
}
模式匹配
[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)
範圍與比較
[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);
}
浮點數處理
[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)
基本檢查
[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 }); // 忽略順序
}
順序與唯一性
[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);
}
複雜物件集合
[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)
基本例外處理
[Fact]
public void Exception_基本驗證_應正常運作()
{
var service = new UserService();
// 預期拋出例外
Action act = () => service.GetUser(-1);
act.Should().Throw<ArgumentException>()
.WithMessage("*User ID*")
.And.ParamName.Should().Be("userId");
}
不應拋出例外
[Fact]
public void Exception_不應拋出_應正常運作()
{
var calculator = new Calculator();
// 不應拋出任何例外
Action act = () => calculator.Add(1, 2);
act.Should().NotThrow();
// 不應拋出特定例外
act.Should().NotThrow<DivideByZeroException>();
}
巢狀例外
[Fact]
public void Exception_巢狀例外_應正常運作()
{
var service = new DatabaseService();
Action act = () => service.Connect("invalid");
act.Should().Throw<DatabaseConnectionException>()
.WithInnerException<ArgumentException>()
.WithMessage("*connection string*");
}
6. 非同步斷言(Async Assertions)
Task 完成驗證
[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);
}
非同步例外
[Fact]
public async Task Async_例外處理_應正常運作()
{
var service = new ApiService();
Func<Task> act = async () => await service.CallInvalidEndpointAsync();
await act.Should().ThrowAsync<HttpRequestException>()
.WithMessage("*404*");
}
進階技巧:複雜物件比對
深度物件比較
完整物件比對
[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);
}
排除特定屬性
[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)
);
}
動態欄位排除
[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"))
);
}
循環參考處理
[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 瞭解完整實作。
public static class ECommerceAssertions
{
public static AndConstraint<ObjectAssertions> 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<ObjectAssertions>(assertions);
}
public static AndConstraint<ObjectAssertions> 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<ObjectAssertions>(assertions);
}
}
使用自訂 Assertions
[Fact]
public void Product_建立產品_應為有效產品()
{
var product = productService.Create("Laptop", 999.99m);
// 使用領域特定斷言
product.Should().BeValidProduct();
product.Name.Should().Be("Laptop");
}
可重用排除擴展
public static class SmartExclusionExtensions
{
public static EquivalencyOptions<T> ExcludingAutoGeneratedFields<T>(
this EquivalencyOptions<T> 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<T> ExcludingAuditFields<T>(
this EquivalencyOptions<T> 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 retrieved = userService.GetUser(user.Id);
retrieved.Should().BeEquivalentTo(user, options => options
.ExcludingAutoGeneratedFields()
.ExcludingAuditFields()
);
}
效能最佳化策略
大量資料斷言
處理大量資料時的最佳實踐:
[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);
}
}
選擇性屬性比對
[Fact]
public void ComplexObject_選擇性比對_應提升效能()
{
var order = orderService.CreateOrder(request);
// 只比對關鍵屬性,而非全物件掃描
order.Should().BeEquivalentTo(new
{
CustomerId = 123,
TotalAmount = 999.99m,
Status = "Pending"
}, options => options
.ExcludingMissingMembers()
);
}
最佳實踐與團隊標準
測試命名規範
遵循 方法_情境_預期結果 模式:
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<ArgumentException>()
.WithMessage($"*{expectedMessage}*");
}
}
錯誤訊息優化
提供清晰的失敗上下文:
[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 使用
收集多個失敗訊息:
[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 回應驗證
[Fact]
public void API_使用者資料_應符合規格()
{
var response = apiClient.GetUserProfile(userId);
response.StatusCode.Should().Be(200);
response.Content.Should().NotBeNullOrEmpty();
var user = JsonSerializer.Deserialize<User>(response.Content);
user.Should().BeEquivalentTo(new
{
Id = userId,
Email = expectedEmail
}, options => options
.Including(u => u.Id)
.Including(u => u.Email)
);
}
情境 2:資料庫實體驗證
[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:事件驗證
[Fact]
public void Event_發佈事件_應包含正確資料()
{
var eventRaised = false;
OrderCreatedEvent? capturedEvent = null;
eventBus.Subscribe<OrderCreatedEvent>(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 失敗但物件看起來相同
原因:可能包含自動生成欄位或時間戳記
解決方案:
// 排除動態欄位
actual.Should().BeEquivalentTo(expected, options => options
.Excluding(x => x.Id)
.Excluding(x => x.CreatedAt)
.Excluding(x => x.UpdatedAt)
);
問題 2:集合順序不同導致失敗
原因:集合順序不同
解決方案:
// 使用 BeEquivalentTo 忽略順序
actual.Should().BeEquivalentTo(expected); // 不檢查順序
// 或明確指定需要檢查順序
actual.Should().Equal(expected); // 檢查順序
問題 3:浮點數比較失敗
原因:浮點數精度問題
解決方案:
// 使用精度容差
actualValue.Should().BeApproximately(expectedValue, 0.001);
何時使用此技能
適用情境
✅ 撰寫單元測試或整合測試時 ✅ 需要驗證複雜物件結構時 ✅ 比對 API 回應或資料庫實體時 ✅ 需要清晰的失敗訊息時 ✅ 建立領域特定測試標準時
不適用情境
❌ 效能測試(使用專用 benchmarking 工具) ❌ 負載測試(使用 K6、JMeter 等) ❌ UI 測試(使用 Playwright、Selenium)
與其他技能的配合
與 unit-test-fundamentals 搭配
先使用 unit-test-fundamentals 建立測試結構,再使用本技能撰寫斷言:
[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 的命名規範,搭配本技能的斷言:
[Fact]
public void CreateUser_有效資料_應回傳啟用使用者()
{
var user = userService.CreateUser("test@example.com");
user.Should().NotBeNull()
.And.BeOfType<User>();
user.IsActive.Should().BeTrue();
}
與 xunit-project-setup 搭配
在 xunit-project-setup 建立的專案中安裝並使用 AwesomeAssertions。
參考資源
原始文章
本技能內容提煉自「老派軟體工程師的測試修練 - 30 天挑戰」系列文章:
-
Day 04 - 驗證測試結果:提昇測試驗證的可讀性
-
Day 05 - 複雜物件的驗證:使用 FluentAssertions 的進階比對
官方資源
- AwesomeAssertions GitHub:https://github.com/AwesomeAssertions/AwesomeAssertions
- AwesomeAssertions 官方文件:https://awesomeassertions.org/
相關文章
- Fluent Assertions 授權變化討論:https://www.dotblogs.com.tw/mrkt/2025/04/19/152408
總結
AwesomeAssertions 提供了強大且可讀的斷言語法,是撰寫高品質測試的重要工具。透過:
- 流暢語法:讓測試程式碼更易讀
- 豐富斷言:涵蓋各種資料類型
- 自訂擴展:建立領域特定斷言
- 效能優化:處理大量資料情境
- 完全免費:Apache 2.0 授權無商業限制
記住:好的斷言不僅能驗證結果,更能清楚表達預期行為,並在失敗時提供有用的診斷資訊。
參考 templates/assertion-examples.cs 查看更多實用範例。