在前一天我們學會了如何處理檔案系統相依性的測試問題,今天我們要面對另一個重要的測試挑戰:資料驗證邏輯測試。
在開發過程中,我們經常需要處理各種資料驗證:
傳統上,我們可能會在業務邏輯中塞一堆 if
判斷來做驗證,或是寫一大串個別的驗證方法。但當驗證規則變複雜,或是需要寫完整測試時,就會碰到很多問題。
今天我們來學習如何用 FluentValidation Test Extensions 寫出完整、可靠的驗證測試,讓每條業務規則都能被徹底測試到。
讓我們看一個典型的使用者註冊驗證範例:
/// <summary>
/// 使用者註冊請求資料
/// </summary>
public class UserRegistrationRequest
{
/// <summary>
/// 使用者名稱
/// </summary>
public string Username { get; set; }
/// <summary>
/// 電子郵件地址
/// </summary>
public string Email { get; set; }
/// <summary>
/// 密碼
/// </summary>
public string Password { get; set; }
/// <summary>
/// 確認密碼
/// </summary>
public string ConfirmPassword { get; set; }
/// <summary>
/// 出生日期
/// </summary>
public DateTime BirthDate { get; set; }
/// <summary>
/// 年齡
/// </summary>
public int Age { get; set; }
/// <summary>
/// 電話號碼
/// </summary>
public string PhoneNumber { get; set; }
/// <summary>
/// 使用者角色清單
/// </summary>
public List<string> Roles { get; set; }
/// <summary>
/// 是否同意使用條款
/// </summary>
public bool AgreeToTerms { get; set; }
}
這個看起來簡單的註冊資料,可能包含以下驗證規則:
測試的複雜度:
驗證規則數量:8 個主要欄位
每個欄位的規則:平均 3-4 條
條件組合:可能的測試案例數量呈指數成長
邊界條件:空值、極值、格式邊界、業務邏輯邊界
public class UserRegistrationService
{
public ValidationResult ValidateRegistration(UserRegistrationRequest request)
{
var errors = new List<string>();
// 使用者名稱驗證
if (string.IsNullOrEmpty(request.Username))
{
errors.Add("使用者名稱不可為 null 或空白");
}
else if (request.Username.Length < 3 || request.Username.Length > 20)
{
errors.Add("使用者名稱長度必須在 3 到 20 個字元之間");
}
else if (!Regex.IsMatch(request.Username, @"^[a-zA-Z0-9_]+$"))
{
errors.Add("使用者名稱只能包含字母、數字和底線");
}
// 電子郵件驗證
if (string.IsNullOrEmpty(request.Email))
{
errors.Add("電子郵件不可為 null 或空白");
}
else if (!IsValidEmail(request.Email))
{
errors.Add("電子郵件格式不正確");
}
// 密碼驗證
if (string.IsNullOrEmpty(request.Password))
{
errors.Add("密碼不可為 null 或空白");
}
else if (request.Password.Length < 8)
{
errors.Add("密碼長度不能少於 8 個字元");
}
else if (!HasComplexPassword(request.Password))
{
errors.Add("密碼必須包含大小寫字母和數字");
}
// ... 更多驗證邏輯
return new ValidationResult(errors);
}
}
這種驗證方式在測試時會遇到:
// 年齡與生日的一致性驗證
if (request.BirthDate != default && request.Age > 0)
{
var calculatedAge = DateTime.Now.Year - request.BirthDate.Year;
if (request.BirthDate.Date > DateTime.Now.AddYears(-calculatedAge))
{
calculatedAge--;
}
if (calculatedAge != request.Age)
{
errors.Add("年齡與生日不一致");
}
}
這種跨欄位驗證邏輯:
// 電話號碼是可選的,但如果填了就必須是有效格式
if (!string.IsNullOrWhiteSpace(request.PhoneNumber))
{
if (!Regex.IsMatch(request.PhoneNumber, @"^09\d{8}$"))
{
errors.Add("電話號碼格式不正確");
}
}
條件式驗證增加了測試的複雜度,需要測試:
FluentValidation 是一個專門為 .NET 應用程式設計的驗證框架,它提供了一種優雅且強型別的方式來定義驗證規則。與傳統的驗證方式不同,FluentValidation 採用流暢介面 (Fluent Interface) 的設計模式,讓驗證規則的撰寫更加直觀和易讀。
在 ASP.NET Core 中,我們通常會使用 DataAnnotation 來做模型驗證:
/// <summary>
/// 使用者註冊請求資料
/// </summary>
public class UserRegistrationRequest
{
/// <summary>
/// 使用者名稱
/// </summary>
[Required(ErrorMessage = "使用者名稱不可為 null 或空白")]
[StringLength(20, MinimumLength = 3, ErrorMessage = "使用者名稱長度必須在 3 到 20 個字元之間")]
[RegularExpression(@"^[a-zA-Z0-9_]+$", ErrorMessage = "使用者名稱只能包含字母、數字和底線")]
public string Username { get; set; }
/// <summary>
/// 電子郵件地址
/// </summary>
[Required(ErrorMessage = "電子郵件不可為 null 或空白")]
[EmailAddress(ErrorMessage = "電子郵件格式不正確")]
[StringLength(100, ErrorMessage = "電子郵件長度不能超過 100 個字元")]
public string Email { get; set; }
/// <summary>
/// 密碼
/// </summary>
[Required(ErrorMessage = "密碼不可為 null 或空白")]
[StringLength(50, MinimumLength = 8, ErrorMessage = "密碼長度必須在 8 到 50 個字元之間")]
[RegularExpression(@"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$", ErrorMessage = "密碼必須包含大小寫字母和數字")]
public string Password { get; set; }
/// <summary>
/// 確認密碼
/// </summary>
[Required(ErrorMessage = "確認密碼不可為 null 或空白")]
[Compare("Password", ErrorMessage = "確認密碼必須與密碼相同")]
public string ConfirmPassword { get; set; }
/// <summary>
/// 年齡
/// </summary>
[Range(18, 120, ErrorMessage = "年齡必須在 18 到 120 歲之間")]
public int Age { get; set; }
/// <summary>
/// 出生日期(如何驗證年齡與生日的一致性?DataAnnotation 很困難!)
/// </summary>
public DateTime BirthDate { get; set; }
}
DataAnnotation 的問題:
public class UserRegistrationValidator : AbstractValidator<UserRegistrationRequest>
{
public UserRegistrationValidator(TimeProvider timeProvider)
{
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.Password)
.NotEmpty().WithMessage("密碼不可為 null 或空白")
.Length(8, 50).WithMessage("密碼長度必須在 8 到 50 個字元之間")
.Must(BeComplexPassword).WithMessage("密碼必須包含大小寫字母和數字");
RuleFor(x => x.ConfirmPassword)
.Equal(x => x.Password).WithMessage("確認密碼必須與密碼相同");
// 複雜的跨欄位驗證變得簡單!
RuleFor(x => x.BirthDate)
.Must((request, birthDate) => IsAgeConsistentWithBirthDate(birthDate, request.Age, timeProvider))
.WithMessage("生日與年齡不一致");
// 條件式驗證也很容易
RuleFor(x => x.PhoneNumber)
.Matches(@"^09\d{8}$").WithMessage("電話號碼格式不正確")
.When(x => !string.IsNullOrWhiteSpace(x.PhoneNumber));
}
private bool BeComplexPassword(string password)
{
return !string.IsNullOrEmpty(password) &&
Regex.IsMatch(password, @"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$");
}
private bool IsAgeConsistentWithBirthDate(DateTime birthDate, int age, TimeProvider timeProvider)
{
var currentDate = timeProvider.GetLocalNow().Date;
var calculatedAge = currentDate.Year - birthDate.Year;
if (birthDate.Date > currentDate.AddYears(-calculatedAge))
{
calculatedAge--;
}
return calculatedAge == age;
}
}
特性 | DataAnnotation | FluentValidation |
---|---|---|
複雜驗證邏輯 | 困難,需要自定義屬性 | 簡單,用方法撰寫 |
跨欄位驗證 | 非常困難 | 原生支援 |
條件式驗證 | 幾乎不可能 | 用 When 輕鬆實現 |
可讀性 | 屬性堆疊,難讀 | 流暢介面,清晰 |
可測試性 | 難以單獨測試 | 專門的測試工具 |
重用性 | 綁定在模型上 | 獨立的驗證器類別 |
客制化 | 需要寫屬性類別 | 直接寫方法邏輯 |
錯誤訊息 | 散布各處 | 集中管理 |
非同步驗證 | 不支援 | 原生支援 |
相依性注入 | 困難 | 完全支援 |
在開始使用 FluentValidation 之前,建議先了解以下資源:
FluentValidation 是個流行的 .NET 驗證框架,它有這些特色:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<!-- 主要驗證框架 -->
<PackageReference Include="FluentValidation" Version="11.11.0" />
<!-- 測試相關套件 -->
<PackageReference Include="FluentValidation.TestHelper" Version="11.11.0" />
<PackageReference Include="AwesomeAssertions" Version="9.1.0" />
<PackageReference Include="Microsoft.Extensions.Time.Testing" Version="9.0.0" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
</ItemGroup>
</Project>
// 模型定義:驗證邏輯與資料模型混合
public class UserRegistrationRequest
{
[Required(ErrorMessage = "使用者名稱不可為 null 或空白")]
[StringLength(20, MinimumLength = 3, ErrorMessage = "使用者名稱長度必須在 3 到 20 個字元之間")]
[RegularExpression(@"^[a-zA-Z0-9_]+$", ErrorMessage = "使用者名稱只能包含字母、數字和底線")]
public string Username { get; set; }
[Required(ErrorMessage = "電子郵件不可為 null 或空白")]
[EmailAddress(ErrorMessage = "電子郵件格式不正確")]
[StringLength(100, ErrorMessage = "電子郵件長度不能超過 100 個字元")]
public string Email { get; set; }
[Required(ErrorMessage = "密碼不可為 null 或空白")]
[StringLength(50, MinimumLength = 8, ErrorMessage = "密碼長度必須在 8 到 50 個字元之間")]
public string Password { get; set; }
[Required(ErrorMessage = "確認密碼不可為 null 或空白")]
[Compare("Password", ErrorMessage = "確認密碼必須與密碼相同")]
public string ConfirmPassword { get; set; }
[Range(18, 120, ErrorMessage = "年齡必須在 18 到 120 歲之間")]
public int Age { get; set; }
// DataAnnotation 無法簡單處理跨欄位驗證
public DateTime BirthDate { get; set; }
public string PhoneNumber { get; set; }
public List<string> Roles { get; set; }
public bool AgreeToTerms { get; set; }
}
// 傳統服務驗證:混亂的 if 判斷
public class UserRegistrationService
{
public ValidationResult ValidateRegistration(UserRegistrationRequest request)
{
var errors = new List<string>();
if (string.IsNullOrEmpty(request.Username))
{
errors.Add("使用者名稱不可為 null 或空白");
}
if (string.IsNullOrEmpty(request.Email))
{
errors.Add("電子郵件不可為 null 或空白");
}
// ... 更多散亂的驗證邏輯
return new ValidationResult(errors);
}
}
// 乾淨的資料模型:只專注於資料結構
/// <summary>
/// 使用者註冊請求資料
/// </summary>
public class UserRegistrationRequest
{
/// <summary>
/// 使用者名稱
/// </summary>
public string Username { get; set; }
/// <summary>
/// 電子郵件地址
/// </summary>
public string Email { get; set; }
/// <summary>
/// 密碼
/// </summary>
public string Password { get; set; }
/// <summary>
/// 確認密碼
/// </summary>
public string ConfirmPassword { get; set; }
/// <summary>
/// 出生日期
/// </summary>
public DateTime BirthDate { get; set; }
/// <summary>
/// 年齡
/// </summary>
public int Age { get; set; }
/// <summary>
/// 電話號碼
/// </summary>
public string PhoneNumber { get; set; }
/// <summary>
/// 使用者角色清單
/// </summary>
public List<string> Roles { get; set; }
/// <summary>
/// 是否同意使用條款
/// </summary>
public bool AgreeToTerms { get; set; }
}
專門的驗證器:邏輯清晰且可測試
using FluentValidation;
using Microsoft.Extensions.Time.Testing;
/// <summary>
/// 使用者註冊請求驗證器
/// </summary>
public class UserRegistrationValidator : AbstractValidator<UserRegistrationRequest>
{
private readonly TimeProvider _timeProvider;
public UserRegistrationValidator(TimeProvider timeProvider)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
SetupValidationRules();
}
/// <summary>
/// 設定所有驗證規則
/// </summary>
private void SetupValidationRules()
{
// 使用者名稱驗證
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.Password)
.NotEmpty().WithMessage("密碼不可為 null 或空白")
.Length(8, 50).WithMessage("密碼長度必須在 8 到 50 個字元之間")
.Matches(@"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$")
.WithMessage("密碼必須包含至少一個大寫字母、一個小寫字母和一個數字");
// 確認密碼驗證
RuleFor(x => x.ConfirmPassword)
.Equal(x => x.Password).WithMessage("確認密碼必須與密碼相同");
// 年齡驗證
RuleFor(x => x.Age)
.GreaterThanOrEqualTo(18).WithMessage("年齡必須大於或等於 18 歲")
.LessThanOrEqualTo(120).WithMessage("年齡必須小於或等於 120 歲");
// 生日與年齡一致性驗證
RuleFor(x => x.BirthDate)
.Must((request, birthDate) => IsAgeConsistentWithBirthDate(birthDate, request.Age))
.WithMessage("生日與年齡不一致");
// 電話號碼驗證(可選)
RuleFor(x => x.PhoneNumber)
.Matches(@"^09\d{8}$").WithMessage("電話號碼格式不正確")
.When(x => !string.IsNullOrWhiteSpace(x.PhoneNumber));
// 角色驗證
RuleFor(x => x.Roles)
.NotEmpty().WithMessage("角色清單不可為 null 或空的陣列")
.Must(roles => roles == null || roles.All(role => IsValidRole(role)))
.WithMessage("包含無效的角色");
// 同意條款驗證
RuleFor(x => x.AgreeToTerms)
.Equal(true).WithMessage("必須同意使用條款");
}
/// <summary>
/// 檢查年齡是否與生日一致
/// 注意:使用 GetLocalNow() 而非 GetUtcNow(),因為:
/// 1. 使用者輸入的生日是基於當地時區的日期
/// 2. 年齡計算應該基於當地日期,而非 UTC 日期
/// 3. 避免跨時區時的年齡計算錯誤
/// </summary>
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;
}
/// <summary>
/// 檢查角色是否有效
/// </summary>
private bool IsValidRole(string role)
{
var validRoles = new[] { "User", "Admin", "Manager", "Support" };
return validRoles.Contains(role);
}
}
在進入具體的測試實作之前,讓我們先理解為什麼驗證器需要專門的單元測試,以及這樣做有什麼好處。
驗證器是應用程式的第一道防線,它們負責:
如果驗證邏輯出錯,後果可能很嚴重:
// 想像一下這些驗證失效的後果:
- 使用者可以註冊空白的使用者名稱
- 系統接受格式錯誤的電子郵件地址
- 密碼複雜度要求被繞過
- 未成年使用者可以成功註冊成人服務
- 無效的角色權限被授予使用者
測試案例本身就是最好的業務規則文件:
[Fact]
public void Validate_未成年使用者_應該驗證失敗()
{
// 這個測試明確說明:系統不允許未成年使用者註冊
// 比任何文件都更清楚、更準確
}
[Theory]
[InlineData(17, "年齡必須大於或等於 18 歲")]
public void Validate_年齡邊界值_應該正確驗證(int age, string expectedError)
{
// 清楚定義了年齡限制的邊界條件
}
驗證器可以脫離 Web 框架、資料庫、外部服務獨立測試:
[Fact]
public void Validate_複雜密碼規則_不依賴任何外部系統()
{
// Arrange - 純粹的記憶體物件
var validator = new UserRegistrationValidator(_fakeTimeProvider);
var request = new UserRegistrationRequest { Password = "weak" };
// Act - 純粹的邏輯運算
var result = validator.TestValidate(request);
// Assert - 即時驗證結果
result.ShouldHaveValidationErrorFor(x => x.Password);
// 執行速度快、結果穩定、不受外部影響
}
驗證器測試可以系統性地測試各種邊界條件:
[Theory]
[InlineData("")] // 空字串
[InlineData(null)] // null 值
[InlineData("ab")] // 太短
[InlineData("a")] // 極短
[InlineData("very_long_username_that_exceeds_limit")] // 太長
[InlineData("user@name")] // 包含特殊字元
[InlineData("user name")] // 包含空格
public void Validate_使用者名稱邊界條件_應該正確處理(string username)
{
// 系統性地測試各種可能的輸入情況
}
當業務規則變更時,測試提供安全的重構保障:
// 原本的業務規則:年齡必須 >= 18
[Theory]
[InlineData(17, false)] // 未成年
[InlineData(18, true)] // 成年
// 如果業務規則改變:年齡必須 >= 16
// 測試會立即告訴我們哪些地方需要修改
複雜的跨欄位邏輯特別需要測試保護:
[Fact]
public void Validate_年齡與生日一致性_複雜邏輯需要測試保護()
{
// 這種邏輯容易出錯,測試提供保障
var request = CreateValidRequest();
request.BirthDate = new DateTime(1990, 6, 15); // 生日在今年尚未到達
request.Age = 33; // 正確年齡計算
var result = _validator.TestValidate(request);
result.ShouldNotHaveValidationErrorFor(x => x.Age);
}
對驗證器做完整測試覆蓋有具體的商業價值:
驗證器測試不只是技術實踐,更是保護業務邏輯完整性的重要手段。透過系統性的測試,我們可以確保應用程式的第一道防線穩固可靠,為整個系統的品質打下堅實基礎。
FluentValidation Test Extensions 有專門的測試輔助方法:
using FluentValidation.TestHelper;
using AwesomeAssertions;
using Microsoft.Extensions.Time.Testing;
/// <summary>
/// 使用者註冊驗證器測試
/// </summary>
public class UserRegistrationValidatorTests
{
private readonly FakeTimeProvider _fakeTimeProvider;
private readonly UserRegistrationValidator _validator;
public UserRegistrationValidatorTests()
{
// 設定固定的測試時間
_fakeTimeProvider = new FakeTimeProvider();
_fakeTimeProvider.SetUtcNow(new DateTime(2024, 1, 1));
_validator = new UserRegistrationValidator(_fakeTimeProvider);
}
[Fact]
public void Validate_有效的使用者名稱_應該通過驗證()
{
// Arrange
var request = CreateValidRequest();
request.Username = "validuser123";
// Act
var result = _validator.TestValidate(request);
// Assert
result.ShouldNotHaveValidationErrorFor(x => x.Username);
}
[Fact]
public void Validate_空的使用者名稱_應該驗證失敗()
{
// Arrange
var request = CreateValidRequest();
request.Username = "";
// Act
var result = _validator.TestValidate(request);
// Assert
result.ShouldHaveValidationErrorFor(x => x.Username)
.WithErrorMessage("使用者名稱不可為 null 或空白");
}
[Fact]
public void Validate_太短的使用者名稱_應該驗證失敗()
{
// Arrange
var request = CreateValidRequest();
request.Username = "ab";
// Act
var result = _validator.TestValidate(request);
// Assert
result.ShouldHaveValidationErrorFor(x => x.Username)
.WithErrorMessage("使用者名稱長度必須在 3 到 20 個字元之間");
}
[Fact]
public void Validate_太長的使用者名稱_應該驗證失敗()
{
// Arrange
var request = CreateValidRequest();
request.Username = "this_is_a_very_long_username_that_exceeds_the_limit";
// Act
var result = _validator.TestValidate(request);
// Assert
result.ShouldHaveValidationErrorFor(x => x.Username)
.WithErrorMessage("使用者名稱長度必須在 3 到 20 個字元之間");
}
[Fact]
public void Validate_包含特殊字元的使用者名稱_應該驗證失敗()
{
// Arrange
var request = CreateValidRequest();
request.Username = "user@name";
// Act
var result = _validator.TestValidate(request);
// Assert
result.ShouldHaveValidationErrorFor(x => x.Username)
.WithErrorMessage("使用者名稱只能包含字母、數字和底線");
}
/// <summary>
/// 建立有效的測試請求資料
/// </summary>
/// <returns>有效的使用者註冊請求</returns>
private UserRegistrationRequest CreateValidRequest()
{
return new UserRegistrationRequest
{
Username = "testuser123",
Email = "test@example.com",
Password = "TestPass123",
ConfirmPassword = "TestPass123",
BirthDate = new DateTime(1990, 1, 1),
Age = 34, // 2024 - 1990 = 34
PhoneNumber = "0912345678",
Roles = new List<string> { "User" },
AgreeToTerms = true
};
}
}
ShouldHaveValidationErrorFor
:
WithErrorMessage
驗證錯誤訊息WithErrorCode
驗證錯誤代碼ShouldNotHaveValidationErrorFor
:
測試多個欄位的範例:
[Fact]
public void Validate_密碼相關欄位_應該有正確的驗證結果()
{
// Arrange
var request = CreateValidRequest();
request.Password = "weak";
request.ConfirmPassword = "different";
// Act
var result = _validator.TestValidate(request);
// Assert
result.ShouldHaveValidationErrorFor(x => x.Password);
result.ShouldHaveValidationErrorFor(x => x.ConfirmPassword);
}
使用 Theory
可以有效測試多種輸入組合:
[Theory]
[InlineData("", "使用者名稱不可為 null 或空白")]
[InlineData("a", "使用者名稱長度必須在 3 到 20 個字元之間")]
[InlineData("ab", "使用者名稱長度必須在 3 到 20 個字元之間")]
[InlineData("this_is_a_very_long_username_that_exceeds_the_limit", "使用者名稱長度必須在 3 到 20 個字元之間")]
[InlineData("user@name", "使用者名稱只能包含字母、數字和底線")]
[InlineData("user name", "使用者名稱只能包含字母、數字和底線")]
public void Validate_無效的使用者名稱_應該回傳對應錯誤訊息(string username, string expectedErrorMessage)
{
// Arrange
var request = CreateValidRequest();
request.Username = username;
// Act
var result = _validator.TestValidate(request);
// Assert
result.ShouldHaveValidationErrorFor(x => x.Username)
.WithErrorMessage(expectedErrorMessage);
}
[Theory]
[InlineData("validuser123")]
[InlineData("user_name")]
[InlineData("User123")]
[InlineData("test_user_456")]
public void Validate_有效的使用者名稱_應該通過驗證(string username)
{
// Arrange
var request = CreateValidRequest();
request.Username = username;
// Act
var result = _validator.TestValidate(request);
// Assert
result.ShouldNotHaveValidationErrorFor(x => x.Username);
}
[Theory]
[InlineData("", "電子郵件不可為 null 或空白")]
[InlineData("invalid", "電子郵件格式不正確")]
[InlineData("invalid@", "電子郵件格式不正確")]
[InlineData("@example.com", "電子郵件格式不正確")]
public void Validate_無效的電子郵件_應該驗證失敗(string email, string expectedErrorMessage)
{
// Arrange
var request = CreateValidRequest();
request.Email = email;
// Act
var result = _validator.TestValidate(request);
// Assert
result.ShouldHaveValidationErrorFor(x => x.Email)
.WithErrorMessage(expectedErrorMessage);
}
[Fact]
public void Validate_過長的電子郵件_應該驗證失敗()
{
// Arrange
var request = CreateValidRequest();
request.Email = new string('a', 91) + "@test.com"; // 總長度 101
// Act
var result = _validator.TestValidate(request);
// Assert
result.ShouldHaveValidationErrorFor(x => x.Email)
.WithErrorMessage("電子郵件長度不能超過 100 個字元");
}
[Theory]
[InlineData("test@example.com")]
[InlineData("user.name@domain.co.uk")]
[InlineData("firstname+lastname@company.org")]
public void Validate_有效的電子郵件_應該通過驗證(string email)
{
// Arrange
var request = CreateValidRequest();
request.Email = email;
// Act
var result = _validator.TestValidate(request);
// Assert
result.ShouldNotHaveValidationErrorFor(x => x.Email);
}
[Theory]
[InlineData("weak", "密碼長度必須在 8 到 50 個字元之間")]
[InlineData("weakpass", "密碼必須包含至少一個大寫字母、一個小寫字母和一個數字")]
[InlineData("WEAKPASS123", "密碼必須包含至少一個大寫字母、一個小寫字母和一個數字")]
[InlineData("weakpass123", "密碼必須包含至少一個大寫字母、一個小寫字母和一個數字")]
[InlineData("WeakPass", "密碼必須包含至少一個大寫字母、一個小寫字母和一個數字")]
public void Validate_不符合複雜度要求的密碼_應該驗證失敗(string password, string expectedErrorMessage)
{
// Arrange
var request = CreateValidRequest();
request.Password = password;
request.ConfirmPassword = password;
// Act
var result = _validator.TestValidate(request);
// Assert
result.ShouldHaveValidationErrorFor(x => x.Password)
.WithErrorMessage(expectedErrorMessage);
}
[Theory]
[InlineData("Password123")]
[InlineData("MySecure1")]
[InlineData("StrongPass99")]
public void Validate_符合複雜度要求的密碼_應該通過驗證(string password)
{
// Arrange
var request = CreateValidRequest();
request.Password = password;
request.ConfirmPassword = password;
// Act
var result = _validator.TestValidate(request);
// Assert
result.ShouldNotHaveValidationErrorFor(x => x.Password);
}
[Fact]
public void Validate_密碼與確認密碼不一致_應該驗證失敗()
{
// Arrange
var request = CreateValidRequest();
request.Password = "Password123";
request.ConfirmPassword = "DifferentPass456";
// Act
var result = _validator.TestValidate(request);
// Assert
result.ShouldHaveValidationErrorFor(x => x.ConfirmPassword)
.WithErrorMessage("確認密碼必須與密碼相同");
}
[Fact]
public void Validate_年齡與生日不符_應該驗證失敗()
{
// Arrange
var request = CreateValidRequest();
request.BirthDate = new DateTime(1990, 1, 1);
request.Age = 25; // 不正確的年齡,應該是 34
// Act
var result = _validator.TestValidate(request);
// Assert
result.ShouldHaveValidationErrorFor(x => x.BirthDate)
.WithErrorMessage("生日與年齡不一致");
}
[Fact]
public void Validate_正確的年齡與生日_應該通過驗證()
{
// Arrange
var request = CreateValidRequest();
request.BirthDate = new DateTime(1990, 1, 1);
request.Age = 34; // 2024 - 1990 = 34 (正確年齡)
// Act
var result = _validator.TestValidate(request);
// Assert
result.ShouldNotHaveValidationErrorFor(x => x.Age);
result.ShouldNotHaveValidationErrorFor(x => x.BirthDate);
}
[Theory]
[InlineData(2009, 1, 1, 15)] // 15 歲
[InlineData(2010, 6, 15, 13)] // 13 歲
[InlineData(2022, 12, 31, 1)] // 1 歲
public void Validate_未成年使用者_應該驗證失敗(int birthYear, int birthMonth, int birthDay, int age)
{
// Arrange
var request = CreateValidRequest();
request.BirthDate = new DateTime(birthYear, birthMonth, birthDay);
request.Age = age;
// Act
var result = _validator.TestValidate(request);
// Assert
result.ShouldHaveValidationErrorFor(x => x.Age)
.WithErrorMessage("年齡必須大於或等於 18 歲");
}
[Theory]
[InlineData(2006, 1, 1, 18)] // 剛好 18 歲
[InlineData(1990, 6, 15, 34)] // 34 歲
[InlineData(1954, 12, 31, 69)] // 69 歲
[InlineData(1904, 1, 1, 120)] // 120 歲 (邊界值)
public void Validate_有效年齡範圍_應該通過驗證(int birthYear, int birthMonth, int birthDay, int age)
{
// Arrange
var request = CreateValidRequest();
request.BirthDate = new DateTime(birthYear, birthMonth, birthDay);
request.Age = age;
// Act
var result = _validator.TestValidate(request);
// Assert
result.ShouldNotHaveValidationErrorFor(x => x.Age);
}
在年齡驗證的實作中,我們使用 GetLocalNow()
而非 GetUtcNow()
,這是一個重要的設計決策:
雖然我們在驗證器中使用 GetLocalNow()
,但在測試時仍使用 SetUtcNow()
來設定 FakeTimeProvider 的時間。這是因為:
延伸學習:在
Day 16 – 測試日期與時間:Microsoft.Bcl.TimeProvider 取代 DateTime
中,我們介紹了SetLocalNow()
擴充方法,可以更直觀地設定本地時間:public static class FakeTimeProviderExtensions { public static void SetLocalNow(this FakeTimeProvider fakeTimeProvider, DateTime localDateTime) { fakeTimeProvider.SetLocalTimeZone(TimeZoneInfo.Local); var utcTime = TimeZoneInfo.ConvertTimeToUtc(localDateTime, TimeZoneInfo.Local); fakeTimeProvider.SetUtcNow(utcTime); } }
在 FluentValidation 測試中,我們選擇直接使用
SetUtcNow()
是為了保持範例的簡潔性,但在實際專案中,建議使用SetLocalNow()
擴充方法來提高程式碼的可讀性。
[Fact]
public void Validate_使用FakeTimeProvider_應該正確計算年齡()
{
// Arrange
// 設定測試時間為 2024 年 6 月 15 日
_fakeTimeProvider.SetUtcNow(new DateTime(2024, 6, 15));
var validator = new UserRegistrationValidator(_fakeTimeProvider);
var request = CreateValidRequest();
request.BirthDate = new DateTime(1990, 3, 10); // 生日在今年已過
request.Age = 34; // 2024 - 1990 = 34
// Act
var result = validator.TestValidate(request);
// Assert
result.ShouldNotHaveValidationErrorFor(x => x.Age);
}
[Fact]
public void Validate_生日尚未到達_年齡計算應該正確()
{
// Arrange
// 設定測試時間為 2024 年 2 月 1 日
_fakeTimeProvider.SetUtcNow(new DateTime(2024, 2, 1));
var validator = new UserRegistrationValidator(_fakeTimeProvider);
var request = CreateValidRequest();
request.BirthDate = new DateTime(1990, 6, 15); // 生日在今年尚未到達
request.Age = 33; // 2024 - 1990 - 1 = 33 (生日未到,需減一歲)
// Act
var result = validator.TestValidate(request);
// Assert
result.ShouldNotHaveValidationErrorFor(x => x.Age);
}
[Fact]
public void Validate_跨年份測試_年齡計算應該正確()
{
// Arrange
// 設定測試時間為 2024 年 12 月 31 日
_fakeTimeProvider.SetUtcNow(new DateTime(2024, 12, 31));
var validator = new UserRegistrationValidator(_fakeTimeProvider);
var request = CreateValidRequest();
request.BirthDate = new DateTime(1990, 1, 1);
request.Age = 34; // 2024 - 1990 = 34
// Act
var result = validator.TestValidate(request);
// Assert
result.ShouldNotHaveValidationErrorFor(x => x.Age);
}
當驗證需要依賴外部服務時,我們需要使用 Mock 物件:
using NSubstitute;
/// <summary>
/// 使用者相關服務介面
/// </summary>
public interface IUserService
{
/// <summary>
/// 檢查使用者名稱是否可用
/// </summary>
/// <param name="username">使用者名稱</param>
/// <returns>是否可用</returns>
Task<bool> IsUsernameAvailableAsync(string username);
/// <summary>
/// 檢查電子郵件是否已註冊
/// </summary>
/// <param name="email">電子郵件地址</param>
/// <returns>是否已註冊</returns>
Task<bool> IsEmailRegisteredAsync(string email);
}
/// <summary>
/// 支援非同步驗證的使用者註冊驗證器
/// </summary>
public class UserRegistrationAsyncValidator : AbstractValidator<UserRegistrationRequest>
{
private readonly IUserService _userService;
private readonly TimeProvider _timeProvider;
public UserRegistrationAsyncValidator(IUserService userService, TimeProvider timeProvider)
{
_userService = userService ?? throw new ArgumentNullException(nameof(userService));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
ConfigureValidationRules();
}
private void ConfigureValidationRules()
{
// 基本規則 (同步驗證)
RuleFor(x => x.Username)
.NotEmpty().WithMessage("使用者名稱不可為 null 或空白")
.Length(3, 20).WithMessage("使用者名稱長度必須在 3 到 20 個字元之間")
.Matches(@"^[a-zA-Z0-9_]+$").WithMessage("使用者名稱只能包含字母、數字和底線");
// 非同步驗證規則
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("此電子郵件已被註冊");
}
}
public class UserRegistrationAsyncValidatorTests
{
private readonly IUserService _mockUserService;
private readonly FakeTimeProvider _fakeTimeProvider;
private readonly UserRegistrationAsyncValidator _validator;
public UserRegistrationAsyncValidatorTests()
{
_mockUserService = Substitute.For<IUserService>();
_fakeTimeProvider = new FakeTimeProvider();
_fakeTimeProvider.SetUtcNow(new DateTime(2024, 1, 1));
_validator = new UserRegistrationAsyncValidator(_mockUserService, _fakeTimeProvider);
}
[Fact]
public async Task ValidateAsync_使用者名稱可用_應該通過驗證()
{
// Arrange
var request = CreateValidRequest();
request.Username = "newuser123";
_mockUserService.IsUsernameAvailableAsync("newuser123")
.Returns(Task.FromResult(true));
_mockUserService.IsEmailRegisteredAsync(Arg.Any<string>())
.Returns(Task.FromResult(false));
// 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 = CreateValidRequest();
request.Username = "existinguser";
_mockUserService.IsUsernameAvailableAsync("existinguser")
.Returns(Task.FromResult(false));
_mockUserService.IsEmailRegisteredAsync(Arg.Any<string>())
.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 = CreateValidRequest();
request.Email = "existing@example.com";
_mockUserService.IsUsernameAvailableAsync(Arg.Any<string>())
.Returns(Task.FromResult(true));
_mockUserService.IsEmailRegisteredAsync("existing@example.com")
.Returns(Task.FromResult(true));
// Act
var result = await _validator.TestValidateAsync(request);
// Assert
result.ShouldHaveValidationErrorFor(x => x.Email)
.WithErrorMessage("此電子郵件已被註冊");
await _mockUserService.Received(1).IsEmailRegisteredAsync("existing@example.com");
}
[Fact]
public async Task ValidateAsync_外部服務拋出例外_應該正確處理()
{
// Arrange
var request = CreateValidRequest();
request.Username = "testuser";
_mockUserService.IsUsernameAvailableAsync("testuser")
.Returns(Task.FromException<bool>(new TimeoutException("服務逾時")));
// Act & Assert
await _validator.TestValidateAsync(request)
.Should().ThrowAsync<TimeoutException>();
await _mockUserService.Received(1).IsUsernameAvailableAsync("testuser");
}
private 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<string> { "User" },
AgreeToTerms = true
};
}
}
有時我們需要按照特定條件來決定要不要跑驗證:
public class ConditionalValidationTests
{
private readonly UserRegistrationValidator _validator;
public ConditionalValidationTests()
{
var fakeTimeProvider = new FakeTimeProvider();
fakeTimeProvider.SetUtcNow(new DateTime(2024, 1, 1));
_validator = new UserRegistrationValidator(fakeTimeProvider);
}
[Fact]
public void Validate_電話號碼為空_應該跳過驗證()
{
// Arrange
var request = CreateValidRequest();
request.PhoneNumber = null; // 空值或空白字元應該跳過驗證
// Act
var result = _validator.TestValidate(request);
// Assert
result.ShouldNotHaveValidationErrorFor(x => x.PhoneNumber);
}
[Fact]
public void Validate_電話號碼格式錯誤_應該驗證失敗()
{
// Arrange
var request = CreateValidRequest();
request.PhoneNumber = "123456789"; // 格式錯誤
// Act
var result = _validator.TestValidate(request);
// Assert
result.ShouldHaveValidationErrorFor(x => x.PhoneNumber)
.WithErrorMessage("電話號碼格式不正確");
}
[Theory]
[InlineData("0912345678")]
[InlineData("0987654321")]
[InlineData("0923456789")]
public void Validate_有效的電話號碼格式_應該通過驗證(string phoneNumber)
{
// Arrange
var request = CreateValidRequest();
request.PhoneNumber = phoneNumber;
// Act
var result = _validator.TestValidate(request);
// Assert
result.ShouldNotHaveValidationErrorFor(x => x.PhoneNumber);
}
private 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<string> { "User" },
AgreeToTerms = true
};
}
}
public class RoleValidationTests
{
private readonly UserRegistrationValidator _validator;
public RoleValidationTests()
{
var fakeTimeProvider = new FakeTimeProvider();
fakeTimeProvider.SetUtcNow(new DateTime(2024, 1, 1));
_validator = new UserRegistrationValidator(fakeTimeProvider);
}
[Fact]
public void Validate_空的角色清單_應該驗證失敗()
{
// Arrange
var request = CreateValidRequest();
request.Roles = new List<string>();
// Act
var result = _validator.TestValidate(request);
// Assert
result.ShouldHaveValidationErrorFor(x => x.Roles)
.WithErrorMessage("角色清單不可為 null 或空的陣列");
}
[Fact]
public void Validate_null的角色清單_應該驗證失敗()
{
// Arrange
var request = CreateValidRequest();
request.Roles = null;
// Act
var result = _validator.TestValidate(request);
// Assert
result.ShouldHaveValidationErrorFor(x => x.Roles)
.WithErrorMessage("角色清單不可為 null 或空的陣列");
}
[Theory]
[InlineData("InvalidRole")]
[InlineData("SuperUser")]
[InlineData("")]
[InlineData(null)]
public void Validate_無效的角色_應該驗證失敗(string invalidRole)
{
// Arrange
var request = CreateValidRequest();
request.Roles = new List<string> { "User", invalidRole };
// Act
var result = _validator.TestValidate(request);
// Assert
result.ShouldHaveValidationErrorFor(x => x.Roles)
.WithErrorMessage("包含無效的角色");
}
[Theory]
[InlineData(new[] { "User" })]
[InlineData(new[] { "Admin" })]
[InlineData(new[] { "Manager" })]
[InlineData(new[] { "Support" })]
[InlineData(new[] { "User", "Admin" })]
[InlineData(new[] { "User", "Manager", "Support" })]
public void Validate_有效的角色組合_應該通過驗證(string[] validRoles)
{
// Arrange
var request = CreateValidRequest();
request.Roles = validRoles.ToList();
// Act
var result = _validator.TestValidate(request);
// Assert
result.ShouldNotHaveValidationErrorFor(x => x.Roles);
}
private 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<string> { "User" },
AgreeToTerms = true
};
}
}
為了避免在每個測試中重複建立測試資料,我們可以寫個輔助方法:
public class UserRegistrationValidatorTestsImproved
{
private readonly FakeTimeProvider _fakeTimeProvider;
private readonly UserRegistrationValidator _validator;
public UserRegistrationValidatorTestsImproved()
{
_fakeTimeProvider = new FakeTimeProvider();
_fakeTimeProvider.SetUtcNow(new DateTime(2024, 1, 1));
_validator = new UserRegistrationValidator(_fakeTimeProvider);
}
[Fact]
public void Validate_完整的有效請求_應該通過驗證()
{
// Arrange
var request = CreateValidRequest();
// Act
var result = _validator.TestValidate(request);
// Assert
result.ShouldNotHaveAnyValidationErrors();
}
[Fact]
public void Validate_多個無效欄位_應該回傳所有錯誤()
{
// Arrange
var request = CreateValidRequest();
request.Username = ""; // 無效使用者名稱
request.Email = "invalid"; // 無效電子郵件
request.Password = "weak"; // 無效密碼
request.AgreeToTerms = false; // 未同意條款
// Act
var result = _validator.TestValidate(request);
// Assert
result.ShouldHaveValidationErrorFor(x => x.Username);
result.ShouldHaveValidationErrorFor(x => x.Email);
result.ShouldHaveValidationErrorFor(x => x.Password);
result.ShouldHaveValidationErrorFor(x => x.AgreeToTerms);
}
private 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<string> { "User" },
AgreeToTerms = true
};
}
private UserRegistrationRequest CreateInvalidRequest()
{
return new UserRegistrationRequest
{
Username = "",
Email = "invalid",
Password = "weak",
ConfirmPassword = "different",
BirthDate = new DateTime(1990, 1, 1),
Age = 25, // 不一致的年齡
PhoneNumber = "123", // 無效格式
Roles = new List<string>(), // 空角色
AgreeToTerms = false
};
}
}
使用 Theory
和 InlineData
測試多種輸入組合:
[Theory]
[InlineData("invalid_input", "expected_error_message")]
public void Validate_參數化測試_應該回傳對應錯誤(string input, string expectedError)
{
// 測試寫法
}
重點測試邊界條件:
[Theory]
[InlineData(17, "年齡必須大於或等於 18 歲")] // 邊界下限 - 1
[InlineData(18, null)] // 邊界下限
[InlineData(120, null)] // 邊界上限
[InlineData(121, "年齡必須小於或等於 120 歲")] // 邊界上限 + 1
public void Validate_年齡邊界值_應該正確驗證(int age, string expectedError)
{
// 測試寫法
}
正確處理非同步驗證:
[Fact]
public async Task ValidateAsync_外部服務驗證_應該正確處理()
{
// 設定 Mock 行為
_mockService.MethodAsync(Arg.Any<string>()).Returns(Task.FromResult(true));
// 跑非同步驗證
var result = await _validator.TestValidateAsync(request);
// 驗證結果和 Mock 呼叫
result.ShouldNotHaveValidationErrorFor(x => x.Property);
await _mockService.Received(1).MethodAsync(Arg.Any<string>());
}
測試 When
條件下的驗證邏輯:
[Fact]
public void Validate_條件不滿足_應該跳過驗證()
{
// Arrange
var request = CreateValidRequest();
request.OptionalField = null; // 觸發跳過條件
// Act
var result = _validator.TestValidate(request);
// Assert
result.ShouldNotHaveValidationErrorFor(x => x.OptionalField);
}
問題:使用 DateTime.Now
導致測試不穩定
解決:使用 FakeTimeProvider
控制時間
問題:驗證器依賴外部服務
解決:用 NSubstitute 做 Mock 物件
問題:多欄位交互驗證難以測試
解決:拆分驗證規則,分別測試每個條件
問題:重複寫測試資料
解決:寫輔助方法統一管理
用 FluentValidation Test Extensions,我們可以寫出完整、可靠的驗證測試,有效管理複雜的業務規則驗證邏輯。這樣的測試策略不只保護程式碼品質,更能作為業務邏輯的活文件。
明天我們將進入整合測試的領域,學習整合測試的基礎架構與應用場景。
範例程式碼:
這是「重啟挑戰:老派軟體工程師的測試修練」的第十八天。明天會介紹 Day 19 – 整合測試入門:基礎架構與應用場景。