--- name: dotnet-testing-fluentvalidation-testing description: | 測試 FluentValidation 驗證器的專門技能。 當需要為 Validator 類別建立測試、驗證業務規則、測試錯誤訊息時使用。 涵蓋 FluentValidation.TestHelper 完整使用、ShouldHaveValidationErrorFor、非同步驗證、跨欄位邏輯等。 triggers: # 核心關鍵字 - validator - 驗證器 - fluentvalidation - validation testing # 常見類別名稱 - CreateUserValidator - UpdateUserValidator - UserValidator - CreateOrderValidator - UpdateProductValidator # 技術術語 - 驗證測試 - 驗證規則 - TestHelper - ShouldHaveValidationErrorFor - ShouldNotHaveValidationErrorFor - TestValidate - TestValidateAsync # 動作詞 - 測試驗證器 - test validator - validate rules - 驗證業務規則 license: MIT metadata: author: Kevin Tseng version: "1.0.0" tags: ".NET, testing, FluentValidation, validator, validation" --- # FluentValidation Testing Skill ## 技能說明 此技能專注於使用 FluentValidation.TestHelper 測試資料驗證邏輯,涵蓋基本驗證、複雜業務規則、非同步驗證和測試最佳實踐。 ## 為什麼要測試驗證器? 驗證器是應用程式的第一道防線,測試驗證器能: 1. **確保資料完整性** - 防止無效資料進入系統 2. **業務規則文件化** - 測試即活文件,清楚展示業務規則 3. **安全性保障** - 防止惡意或不當資料輸入 4. **重構安全網** - 業務規則變更時提供保障 5. **跨欄位邏輯驗證** - 確保複雜邏輯正確運作 ## 前置需求 ### 套件安裝 ```xml ``` ### 基本 using 指令 ```csharp using FluentValidation; using FluentValidation.TestHelper; using Microsoft.Extensions.Time.Testing; using NSubstitute; using Xunit; using AwesomeAssertions; ``` ## 核心測試模式 ### 模式 1:基本欄位驗證 #### 驗證器範例 ```csharp public class UserValidator : AbstractValidator { public UserValidator() { RuleFor(x => x.Username) .NotEmpty().WithMessage("使用者名稱不可為 null 或空白") .Length(3, 20).WithMessage("使用者名稱長度必須在 3 到 20 個字元之間") .Matches(@"^[a-zA-Z0-9_]+$").WithMessage("使用者名稱只能包含字母、數字和底線"); RuleFor(x => x.Email) .NotEmpty().WithMessage("電子郵件不可為 null 或空白") .EmailAddress().WithMessage("電子郵件格式不正確") .MaximumLength(100).WithMessage("電子郵件長度不能超過 100 個字元"); RuleFor(x => x.Age) .GreaterThanOrEqualTo(18).WithMessage("年齡必須大於或等於 18 歲") .LessThanOrEqualTo(120).WithMessage("年齡必須小於或等於 120 歲"); } } ``` #### 測試範例 ```csharp public class UserValidatorTests { private readonly UserValidator _validator; public UserValidatorTests() { _validator = new UserValidator(); } [Fact] public void Validate_有效使用者名稱_應該通過驗證() { // Arrange var request = new UserRegistrationRequest { Username = "valid_user123" }; // Act var result = _validator.TestValidate(request); // Assert result.ShouldNotHaveValidationErrorFor(x => x.Username); } [Fact] public void Validate_空白使用者名稱_應該驗證失敗() { // Arrange var request = new UserRegistrationRequest { Username = "" }; // Act var result = _validator.TestValidate(request); // Assert result.ShouldHaveValidationErrorFor(x => x.Username) .WithErrorMessage("使用者名稱不可為 null 或空白"); } } ``` ### 模式 2:參數化測試 ```csharp [Theory] [InlineData("", "使用者名稱不可為 null 或空白")] [InlineData("ab", "使用者名稱長度必須在 3 到 20 個字元之間")] [InlineData("a_very_long_username_exceeds_limit", "使用者名稱長度必須在 3 到 20 個字元之間")] [InlineData("user@name", "使用者名稱只能包含字母、數字和底線")] public void Validate_無效使用者名稱_應該回傳對應錯誤(string username, string expectedError) { // Arrange var request = new UserRegistrationRequest { Username = username }; // Act var result = _validator.TestValidate(request); // Assert result.ShouldHaveValidationErrorFor(x => x.Username) .WithErrorMessage(expectedError); } [Theory] [InlineData("user123")] [InlineData("valid_user")] [InlineData("TEST_User_99")] public void Validate_有效使用者名稱_應該通過驗證(string username) { // Arrange var request = new UserRegistrationRequest { Username = username }; // Act var result = _validator.TestValidate(request); // Assert result.ShouldNotHaveValidationErrorFor(x => x.Username); } ``` ### 模式 3:跨欄位驗證 #### 密碼與確認密碼 ```csharp public class UserValidator : AbstractValidator { public UserValidator() { RuleFor(x => x.Password) .NotEmpty().WithMessage("密碼不可為 null 或空白") .Length(8, 50).WithMessage("密碼長度必須在 8 到 50 個字元之間") .Must(BeComplexPassword).WithMessage("密碼必須包含大小寫字母和數字"); RuleFor(x => x.ConfirmPassword) .Equal(x => x.Password).WithMessage("確認密碼必須與密碼相同"); } private bool BeComplexPassword(string password) { return !string.IsNullOrEmpty(password) && Regex.IsMatch(password, @"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$"); } } ``` #### 測試範例 ```csharp [Fact] public void Validate_密碼與確認密碼不一致_應該驗證失敗() { // Arrange var request = new UserRegistrationRequest { Password = "Password123", ConfirmPassword = "DifferentPass456" }; // Act var result = _validator.TestValidate(request); // Assert result.ShouldHaveValidationErrorFor(x => x.ConfirmPassword) .WithErrorMessage("確認密碼必須與密碼相同"); } [Theory] [InlineData("weak", "密碼長度必須在 8 到 50 個字元之間")] [InlineData("weakpass", "密碼必須包含大小寫字母和數字")] [InlineData("WEAKPASS123", "密碼必須包含大小寫字母和數字")] public void Validate_弱密碼_應該驗證失敗(string password, string expectedError) { // Arrange var request = new UserRegistrationRequest { Password = password, ConfirmPassword = password }; // Act var result = _validator.TestValidate(request); // Assert result.ShouldHaveValidationErrorFor(x => x.Password) .WithErrorMessage(expectedError); } ``` ### 模式 4:時間相依驗證 #### 年齡與生日一致性驗證 ```csharp public class UserValidator : AbstractValidator { private readonly TimeProvider _timeProvider; public UserValidator(TimeProvider timeProvider) { _timeProvider = timeProvider; RuleFor(x => x.BirthDate) .Must((request, birthDate) => IsAgeConsistentWithBirthDate(birthDate, request.Age)) .WithMessage("生日與年齡不一致"); } private bool IsAgeConsistentWithBirthDate(DateTime birthDate, int age) { var currentDate = _timeProvider.GetLocalNow().Date; var calculatedAge = currentDate.Year - birthDate.Year; if (birthDate.Date > currentDate.AddYears(-calculatedAge)) { calculatedAge--; } return calculatedAge == age; } } ``` #### 測試範例 ```csharp public class UserValidatorTests { private readonly FakeTimeProvider _fakeTimeProvider; private readonly UserValidator _validator; public UserValidatorTests() { _fakeTimeProvider = new FakeTimeProvider(); _fakeTimeProvider.SetUtcNow(new DateTime(2024, 1, 1)); _validator = new UserValidator(_fakeTimeProvider); } [Fact] public void Validate_年齡與生日一致_應該通過驗證() { // Arrange var request = new UserRegistrationRequest { BirthDate = new DateTime(1990, 1, 1), Age = 34 // 2024 - 1990 = 34 }; // Act var result = _validator.TestValidate(request); // Assert result.ShouldNotHaveValidationErrorFor(x => x.BirthDate); } [Fact] public void Validate_年齡與生日不一致_應該驗證失敗() { // Arrange var request = new UserRegistrationRequest { BirthDate = new DateTime(1990, 1, 1), Age = 25 // 錯誤的年齡 }; // Act var result = _validator.TestValidate(request); // Assert result.ShouldHaveValidationErrorFor(x => x.BirthDate) .WithErrorMessage("生日與年齡不一致"); } [Fact] public void Validate_生日尚未到達_年齡計算應該正確() { // Arrange _fakeTimeProvider.SetUtcNow(new DateTime(2024, 2, 1)); var validator = new UserValidator(_fakeTimeProvider); var request = new UserRegistrationRequest { BirthDate = new DateTime(1990, 6, 15), // 生日在今年尚未到達 Age = 33 // 2024 - 1990 - 1 = 33 }; // Act var result = validator.TestValidate(request); // Assert result.ShouldNotHaveValidationErrorFor(x => x.BirthDate); } } ``` ### 模式 5:條件式驗證 #### 驗證器定義 ```csharp public class UserValidator : AbstractValidator { public UserValidator() { // 電話號碼為可選,但如果有填就必須是有效格式 RuleFor(x => x.PhoneNumber) .Matches(@"^09\d{8}$").WithMessage("電話號碼格式不正確") .When(x => !string.IsNullOrWhiteSpace(x.PhoneNumber)); } } ``` #### 測試範例 ```csharp [Fact] public void Validate_電話號碼為空_應該跳過驗證() { // Arrange var request = new UserRegistrationRequest { PhoneNumber = null }; // Act var result = _validator.TestValidate(request); // Assert result.ShouldNotHaveValidationErrorFor(x => x.PhoneNumber); } [Fact] public void Validate_電話號碼格式錯誤_應該驗證失敗() { // Arrange var request = new UserRegistrationRequest { PhoneNumber = "123456789" }; // Act var result = _validator.TestValidate(request); // Assert result.ShouldHaveValidationErrorFor(x => x.PhoneNumber) .WithErrorMessage("電話號碼格式不正確"); } [Theory] [InlineData("0912345678")] [InlineData("0987654321")] public void Validate_有效電話號碼_應該通過驗證(string phoneNumber) { // Arrange var request = new UserRegistrationRequest { PhoneNumber = phoneNumber }; // Act var result = _validator.TestValidate(request); // Assert result.ShouldNotHaveValidationErrorFor(x => x.PhoneNumber); } ``` ### 模式 6:非同步驗證 #### 驗證器定義 ```csharp public interface IUserService { Task IsUsernameAvailableAsync(string username); Task IsEmailRegisteredAsync(string email); } public class UserAsyncValidator : AbstractValidator { private readonly IUserService _userService; public UserAsyncValidator(IUserService userService) { _userService = userService; RuleFor(x => x.Username) .MustAsync(async (username, cancellation) => await _userService.IsUsernameAvailableAsync(username)) .WithMessage("使用者名稱已被使用"); RuleFor(x => x.Email) .MustAsync(async (email, cancellation) => !await _userService.IsEmailRegisteredAsync(email)) .WithMessage("此電子郵件已被註冊"); } } ``` #### 測試範例 ```csharp public class UserAsyncValidatorTests { private readonly IUserService _mockUserService; private readonly UserAsyncValidator _validator; public UserAsyncValidatorTests() { _mockUserService = Substitute.For(); _validator = new UserAsyncValidator(_mockUserService); } [Fact] public async Task ValidateAsync_使用者名稱可用_應該通過驗證() { // Arrange var request = new UserRegistrationRequest { Username = "newuser123" }; _mockUserService.IsUsernameAvailableAsync("newuser123") .Returns(Task.FromResult(true)); // Act var result = await _validator.TestValidateAsync(request); // Assert result.ShouldNotHaveValidationErrorFor(x => x.Username); await _mockUserService.Received(1).IsUsernameAvailableAsync("newuser123"); } [Fact] public async Task ValidateAsync_使用者名稱已被使用_應該驗證失敗() { // Arrange var request = new UserRegistrationRequest { Username = "existinguser" }; _mockUserService.IsUsernameAvailableAsync("existinguser") .Returns(Task.FromResult(false)); // Act var result = await _validator.TestValidateAsync(request); // Assert result.ShouldHaveValidationErrorFor(x => x.Username) .WithErrorMessage("使用者名稱已被使用"); await _mockUserService.Received(1).IsUsernameAvailableAsync("existinguser"); } [Fact] public async Task ValidateAsync_外部服務拋出例外_應該正確處理() { // Arrange var request = new UserRegistrationRequest { Username = "testuser" }; _mockUserService.IsUsernameAvailableAsync("testuser") .Returns(Task.FromException(new TimeoutException("服務逾時"))); // Act & Assert await _validator.TestValidateAsync(request) .Should().ThrowAsync(); } } ``` ### 模式 7:集合驗證 ```csharp public class UserValidator : AbstractValidator { public UserValidator() { RuleFor(x => x.Roles) .NotEmpty().WithMessage("角色清單不可為 null 或空陣列") .Must(roles => roles == null || roles.All(role => IsValidRole(role))) .WithMessage("包含無效的角色"); } private bool IsValidRole(string role) { var validRoles = new[] { "User", "Admin", "Manager", "Support" }; return validRoles.Contains(role); } } ``` #### 測試範例 ```csharp [Fact] public void Validate_空的角色清單_應該驗證失敗() { // Arrange var request = new UserRegistrationRequest { Roles = new List() }; // Act var result = _validator.TestValidate(request); // Assert result.ShouldHaveValidationErrorFor(x => x.Roles) .WithErrorMessage("角色清單不可為 null 或空陣列"); } [Theory] [InlineData("InvalidRole")] [InlineData("SuperUser")] public void Validate_無效角色_應該驗證失敗(string invalidRole) { // Arrange var request = new UserRegistrationRequest { Roles = new List { "User", invalidRole } }; // Act var result = _validator.TestValidate(request); // Assert result.ShouldHaveValidationErrorFor(x => x.Roles) .WithErrorMessage("包含無效的角色"); } [Theory] [InlineData(new[] { "User" })] [InlineData(new[] { "Admin" })] [InlineData(new[] { "User", "Manager" })] public void Validate_有效角色_應該通過驗證(string[] roles) { // Arrange var request = new UserRegistrationRequest { Roles = roles.ToList() }; // Act var result = _validator.TestValidate(request); // Assert result.ShouldNotHaveValidationErrorFor(x => x.Roles); } ``` ## FluentValidation.TestHelper 核心 API ### 測試方法 | 方法 | 用途 | 範例 | | -------------------------- | -------------- | --------------------------------------------- | | `TestValidate(model)` | 執行同步驗證 | `_validator.TestValidate(request)` | | `TestValidateAsync(model)` | 執行非同步驗證 | `await _validator.TestValidateAsync(request)` | ### 斷言方法 | 方法 | 用途 | 範例 | | -------------------------------------------------- | ------------------------ | ------------------------------------------------------ | | `ShouldHaveValidationErrorFor(x => x.Property)` | 斷言該屬性應該有錯誤 | `result.ShouldHaveValidationErrorFor(x => x.Username)` | | `ShouldNotHaveValidationErrorFor(x => x.Property)` | 斷言該屬性不應該有錯誤 | `result.ShouldNotHaveValidationErrorFor(x => x.Email)` | | `ShouldNotHaveAnyValidationErrors()` | 斷言整個物件沒有任何錯誤 | `result.ShouldNotHaveAnyValidationErrors()` | ### 錯誤訊息驗證 | 方法 | 用途 | 範例 | | -------------------------- | ---------------- | ----------------------------------------- | | `WithErrorMessage(string)` | 驗證錯誤訊息內容 | `.WithErrorMessage("使用者名稱不可為空")` | | `WithErrorCode(string)` | 驗證錯誤代碼 | `.WithErrorCode("NOT_EMPTY")` | ## 測試最佳實踐 ### ✅ 推薦做法 1. **使用參數化測試** - 用 Theory 測試多種輸入組合 2. **測試邊界值** - 特別注意邊界條件 3. **控制時間** - 使用 FakeTimeProvider 處理時間相依 4. **Mock 外部依賴** - 使用 NSubstitute 隔離外部服務 5. **建立輔助方法** - 統一管理測試資料 6. **清楚的測試命名** - 使用 `方法_情境_預期結果` 格式 7. **測試錯誤訊息** - 確保使用者看到正確的錯誤訊息 ### ❌ 避免做法 1. **避免使用 DateTime.Now** - 會導致測試不穩定 2. **避免測試過度耦合** - 每個測試只驗證一個規則 3. **避免硬編碼測試資料** - 使用輔助方法建立 4. **避免忽略邊界條件** - 邊界值是最容易出錯的地方 5. **避免跳過錯誤訊息驗證** - 錯誤訊息是使用者體驗的一部分 ## 常見測試場景 ### 場景 1:Email 格式驗證 ```csharp [Theory] [InlineData("", "電子郵件不可為 null 或空白")] [InlineData("invalid", "電子郵件格式不正確")] [InlineData("@example.com", "電子郵件格式不正確")] public void Validate_無效Email_應該驗證失敗(string email, string expectedError) { var request = new UserRegistrationRequest { Email = email }; var result = _validator.TestValidate(request); result.ShouldHaveValidationErrorFor(x => x.Email).WithErrorMessage(expectedError); } ``` ### 場景 2:年齡範圍驗證 ```csharp [Theory] [InlineData(17, "年齡必須大於或等於 18 歲")] [InlineData(121, "年齡必須小於或等於 120 歲")] public void Validate_無效年齡_應該驗證失敗(int age, string expectedError) { var request = new UserRegistrationRequest { Age = age }; var result = _validator.TestValidate(request); result.ShouldHaveValidationErrorFor(x => x.Age).WithErrorMessage(expectedError); } ``` ### 場景 3:必填欄位驗證 ```csharp [Fact] public void Validate_未同意條款_應該驗證失敗() { var request = new UserRegistrationRequest { AgreeToTerms = false }; var result = _validator.TestValidate(request); result.ShouldHaveValidationErrorFor(x => x.AgreeToTerms) .WithErrorMessage("必須同意使用條款"); } ``` ## 測試輔助工具 ### 測試資料建構器 ```csharp public static class TestDataBuilder { public static UserRegistrationRequest CreateValidRequest() { return new UserRegistrationRequest { Username = "testuser123", Email = "test@example.com", Password = "TestPass123", ConfirmPassword = "TestPass123", BirthDate = new DateTime(1990, 1, 1), Age = 34, PhoneNumber = "0912345678", Roles = new List { "User" }, AgreeToTerms = true }; } public static UserRegistrationRequest WithUsername(this UserRegistrationRequest request, string username) { request.Username = username; return request; } public static UserRegistrationRequest WithEmail(this UserRegistrationRequest request, string email) { request.Email = email; return request; } } // 使用範例 var request = TestDataBuilder.CreateValidRequest() .WithUsername("newuser") .WithEmail("new@example.com"); ``` ## 與其他技能整合 此技能可與以下技能組合使用: - **unit-test-fundamentals**: 單元測試基礎與 3A 模式 - **test-naming-conventions**: 測試命名規範 - **nsubstitute-mocking**: Mock 外部服務依賴 - **test-data-builder-pattern**: 建構複雜測試資料 - **datetime-testing-timeprovider**: 時間相依測試 ## 疑難排解 ### Q1: 如何測試需要資料庫查詢的驗證? **A:** 使用 Mock 隔離資料庫依賴: ```csharp _mockUserService.IsUsernameAvailableAsync("username") .Returns(Task.FromResult(false)); ``` ### Q2: 如何處理時間相關的驗證? **A:** 使用 FakeTimeProvider 控制時間: ```csharp _fakeTimeProvider.SetUtcNow(new DateTime(2024, 1, 1)); ``` ### Q3: 如何測試複雜的跨欄位驗證? **A:** 分別測試每個條件,確保完整覆蓋: ```csharp // 測試生日已過的情況 // 測試生日未到的情況 // 測試邊界日期 ``` ### Q4: 應該測試到什麼程度? **A:** 重點測試: - 每個驗證規則至少一個測試 - 邊界值和特殊情況 - 錯誤訊息正確性 - 跨欄位邏輯的所有組合 ## 範本檔案參考 本技能提供以下範本檔案: - `templates/validator-test-template.cs`: 完整的驗證器測試範例 - `templates/async-validator-examples.cs`: 非同步驗證範例 ## 參考資源 ### 原始文章 本技能內容提煉自「老派軟體工程師的測試修練 - 30 天挑戰」系列文章: - **Day 18 - 驗證測試:FluentValidation Test Extensions** - 鐵人賽文章:https://ithelp.ithome.com.tw/articles/10376147 - 範例程式碼:https://github.com/kevintsengtw/30Days_in_Testing_Samples/tree/main/day18 ### 官方文件 - [FluentValidation Documentation](https://docs.fluentvalidation.net/) - [FluentValidation.TestHelper](https://docs.fluentvalidation.net/en/latest/testing.html) - [FluentValidation GitHub](https://github.com/FluentValidation/FluentValidation) ### 相關技能 - `unit-test-fundamentals` - 單元測試基礎 - `nsubstitute-mocking` - 測試替身與模擬