iT邦幫忙

2025 iThome 鐵人賽

DAY 18
0
Software Development

重啟挑戰:老派軟體工程師的測試修練系列 第 18 篇

Day 18 – 驗證測試:FluentValidation Test Extensions

  • 分享至 

  • xImage
  •  

前言

在前一天我們學會了如何處理檔案系統相依性的測試問題,今天我們要面對另一個重要的測試挑戰:資料驗證邏輯測試。

在開發過程中,我們經常需要處理各種資料驗證:

  • 使用者註冊資料驗證
  • API 請求參數驗證
  • 表單資料完整性檢查
  • 業務規則驗證
  • 複雜的跨欄位邏輯驗證

傳統上,我們可能會在業務邏輯中塞一堆 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; }
}

這個看起來簡單的註冊資料,可能包含以下驗證規則:

  • Username:不可為 null、不可為空白、長度 3-20、只能包含字母數字和底線
  • Email:不可為 null、不可為空白、有效格式、長度不超過 100、不能重複
  • Password:不可為 null、不可為空白、長度 8-50、包含大小寫字母和數字
  • ConfirmPassword:必須與 Password 相同
  • BirthDate 和 Age:年齡必須與生日計算結果一致、必須年滿 18 歲
  • PhoneNumber:可選、但如果填寫必須是有效格式
  • Roles:不可為 null、不可為空的陣列、每個角色都必須存在於系統中
  • AgreeToTerms:必須為 true

測試的複雜度:

驗證規則數量: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);
    }
}

這種驗證方式在測試時會遇到:

  1. 測試案例爆炸:每個組合都需要獨立測試
  2. 錯誤訊息難以維護:散布在各處的字串常數
  3. 邏輯重複:相似的驗證邏輯在不同地方重複
  4. 測試複雜度高:需要為每個分支寫測試

問題三:跨欄位驗證的複雜性

// 年齡與生日的一致性驗證
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 與 Test Extensions 解決方案

什麼是 FluentValidation?

FluentValidation 是一個專門為 .NET 應用程式設計的驗證框架,它提供了一種優雅且強型別的方式來定義驗證規則。與傳統的驗證方式不同,FluentValidation 採用流暢介面 (Fluent Interface) 的設計模式,讓驗證規則的撰寫更加直觀和易讀。

為什麼要使用 FluentValidation?

傳統 DataAnnotation 的限制

在 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 的問題:

  1. 複雜邏輯困難:跨欄位驗證、條件式驗證很難實現
  2. 可讀性差:複雜的正規表達式和驗證邏輯混在屬性上
  3. 重用性低:驗證邏輯綁定在模型上,難以在不同地方重用
  4. 測試困難:無法單獨測試驗證邏輯
  5. 有限的驗證器:內建驗證器有限,自定義驗證器複雜
  6. 錯誤訊息管理:錯誤訊息散布在各處,難以統一管理

FluentValidation 的優勢

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;
    }
}

FluentValidation vs DataAnnotation 比較

特性 DataAnnotation FluentValidation
複雜驗證邏輯 困難,需要自定義屬性 簡單,用方法撰寫
跨欄位驗證 非常困難 原生支援
條件式驗證 幾乎不可能 用 When 輕鬆實現
可讀性 屬性堆疊,難讀 流暢介面,清晰
可測試性 難以單獨測試 專門的測試工具
重用性 綁定在模型上 獨立的驗證器類別
客制化 需要寫屬性類別 直接寫方法邏輯
錯誤訊息 散布各處 集中管理
非同步驗證 不支援 原生支援
相依性注入 困難 完全支援

FluentValidation 相關資源

在開始使用 FluentValidation 之前,建議先了解以下資源:

FluentValidation 基礎特色

FluentValidation 是個流行的 .NET 驗證框架,它有這些特色:

  • 流暢的 API:用方法鏈的方式定義驗證規則
  • 分離關注點:把驗證邏輯從業務邏輯中分開
  • 可組合性:可以輕鬆組合和重用驗證規則
  • 豐富的內建驗證器:常見的驗證需求都有現成解決方案
  • 完整的測試支援:提供專門的測試工具和輔助方法

測試專案安裝必要套件

<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>

重構驗證邏輯:從 DataAnnotation 到 FluentValidation

重構前:使用 DataAnnotation(不易測試)

// 模型定義:驗證邏輯與資料模型混合
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);
    }
}

重構後:使用 FluentValidation(高度可測試)

// 乾淨的資料模型:只專注於資料結構
/// <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 個字元之間")
            .Must(BeComplexPassword).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);
    }
}

為什麼要對 Validator 類別做單元測試?

在進入具體的測試實作之前,讓我們先理解為什麼驗證器需要專門的單元測試,以及這樣做有什麼好處。

驗證邏輯的重要性

驗證器是應用程式的第一道防線,它們負責:

  1. 資料完整性保護:確保進入系統的資料符合業務規則
  2. 安全性把關:防止惡意或不當的資料輸入
  3. 使用者體驗:提供清楚的錯誤訊息指導使用者
  4. 系統穩定性:避免無效資料造成後續處理錯誤

如果驗證邏輯出錯,後果可能很嚴重:

// 想像一下這些驗證失效的後果:
- 使用者可以註冊空白的使用者名稱
- 系統接受格式錯誤的電子郵件地址
- 密碼複雜度要求被繞過
- 未成年使用者可以成功註冊成人服務
- 無效的角色權限被授予使用者

對 Validator 做單元測試的核心好處

1. 業務規則的活文件

測試案例本身就是最好的業務規則文件:

[Fact]
public void Validate_未成年使用者_應該驗證失敗()
{
    // 這個測試明確說明:系統不允許未成年使用者註冊
    // 比任何文件都更清楚、更準確
}

[Theory]
[InlineData(17, "年齡必須大於或等於 18 歲")]
public void Validate_年齡邊界值_應該正確驗證(int age, string expectedError)
{
    // 清楚定義了年齡限制的邊界條件
}

2. 驗證邏輯的獨立性驗證

驗證器可以脫離 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);
    
    // 執行速度快、結果穩定、不受外部影響
}

3. 邊界條件和例外情況的完整覆蓋

驗證器測試可以系統性地測試各種邊界條件:

[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)
{
    // 系統性地測試各種可能的輸入情況
}

4. 重構安全網

當業務規則變更時,測試提供安全的重構保障:

// 原本的業務規則:年齡必須 >= 18
[Theory]
[InlineData(17, false)]  // 未成年
[InlineData(18, true)]   // 成年

// 如果業務規則改變:年齡必須 >= 16
// 測試會立即告訴我們哪些地方需要修改

5. 跨欄位驗證的可靠性

複雜的跨欄位邏輯特別需要測試保護:

[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);
}

6. 效能和可維護性

  • 快速執行:驗證器測試通常在毫秒內完成
  • 易於維護:測試程式碼結構清晰,容易修改
  • 自動化友善:可以整合到 CI/CD 流程中
  • 回歸防護:防止修改時意外破壞現有功能

測試覆蓋率的實際價值

對驗證器做完整測試覆蓋有具體的商業價值:

  1. 減少生產環境錯誤:提早發現驗證漏洞
  2. 降低客服成本:減少因驗證問題導致的使用者困惑
  3. 提升開發信心:開發團隊可以安心修改驗證規則
  4. 加速功能開發:新功能可以快速驗證其驗證邏輯正確性
  5. 合規性要求:某些行業要求驗證邏輯有完整的測試證明

小結

驗證器測試不只是技術實踐,更是保護業務邏輯完整性的重要手段。透過系統性的測試,我們可以確保應用程式的第一道防線穩固可靠,為整個系統的品質打下堅實基礎。


FluentValidation Test Extensions 基本用法

基礎測試寫法

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 可以有效測試多種輸入組合:

[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(),這是一個重要的設計決策:

為什麼使用本地時間?

  • 業務邏輯正確性:使用者輸入的生日是基於當地時區的日期
  • 避免時區問題:UTC 時間可能導致年齡計算錯誤,特別是跨日期邊界時
  • 用戶體驗一致性:年齡計算應該符合使用者的預期

測試時的時間設定

雖然我們在驗證器中使用 GetLocalNow(),但在測試時仍使用 SetUtcNow() 來設定 FakeTimeProvider 的時間。這是因為:

  • 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() 擴充方法來提高程式碼的可讀性。

使用 FakeTimeProvider 的時間控制

[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);
}

進階驗證場景與 NSubstitute 整合

自定義驗證規則測試

當驗證需要依賴外部服務時,我們需要使用 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
        };
    }
}

測試實務要點

核心原則

  1. 單一責任:每個測試只驗證一個特定的驗證規則
  2. 描述性命名:測試名稱清楚說明情境和預期結果
  3. 3A 模式:Arrange、Act、Assert 結構清晰
  4. 資料隔離:使用輔助方法管理測試資料
  5. 時間控制:使用 FakeTimeProvider 控制時間相關驗證

測試技巧

1. 參數化測試

使用 Theory 和 InlineData 測試多種輸入組合:

[Theory]
[InlineData("invalid_input", "expected_error_message")]
public void Validate_參數化測試_應該回傳對應錯誤(string input, string expectedError)
{
    // 測試寫法
}

2. 邊界值測試

重點測試邊界條件:

[Theory]
[InlineData(17, "年齡必須大於或等於 18 歲")] // 邊界下限 - 1
[InlineData(18, null)] // 邊界下限
[InlineData(120, null)] // 邊界上限
[InlineData(121, "年齡必須小於或等於 120 歲")] // 邊界上限 + 1
public void Validate_年齡邊界值_應該正確驗證(int age, string expectedError)
{
    // 測試寫法
}

3. 非同步驗證測試

正確處理非同步驗證:

[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>());
}

4. 條件式驗證測試

測試 When 條件下的驗證邏輯:

[Fact]
public void Validate_條件不滿足_應該跳過驗證()
{
    // Arrange
    var request = CreateValidRequest();
    request.OptionalField = null; // 觸發跳過條件
    
    // Act
    var result = _validator.TestValidate(request);
    
    // Assert
    result.ShouldNotHaveValidationErrorFor(x => x.OptionalField);
}

常見陷阱與解決方案

1. 時間相關驗證

問題:使用 DateTime.Now 導致測試不穩定
解決:使用 FakeTimeProvider 控制時間

2. 外部依賴

問題:驗證器依賴外部服務
解決:用 NSubstitute 做 Mock 物件

3. 複雜驗證邏輯

問題:多欄位交互驗證難以測試
解決:拆分驗證規則,分別測試每個條件

4. 測試資料管理

問題:重複寫測試資料
解決:寫輔助方法統一管理

效能考量

  1. 避免過度驗證:只測試必要的驗證路徑
  2. Mock 設定優化:重用 Mock 設定,避免重複寫
  3. 測試資料最小化:只寫測試需要的最小資料集
  4. 並行測試:讓測試可以同時跑

維護性建議

  1. 定期更新測試:當驗證規則變更時,同步更新測試
  2. 文件化複雜規則:為複雜的業務驗證規則加上註解
  3. 測試覆蓋率監控:讓新的驗證規則都有對應測試
  4. 重構測試程式碼:定期檢視和重構測試程式碼

用 FluentValidation Test Extensions,我們可以寫出完整、可靠的驗證測試,有效管理複雜的業務規則驗證邏輯。這樣的測試策略不只保護程式碼品質,更能作為業務邏輯的活文件。

延伸閱讀

FluentValidation 核心資源

測試工具資源

.NET 驗證相關文章

明天我們將進入整合測試的領域,學習整合測試的基礎架構與應用場景。

範例程式碼:


這是「重啟挑戰:老派軟體工程師的測試修練」的第十八天。明天會介紹 Day 19 – 整合測試入門:基礎架構與應用場景。


上一篇
Day 17 – 檔案與 IO 測試:使用 System.IO.Abstractions 模擬檔案系統 - 實現可測試的檔案操作
下一篇
Day 19 – 整合測試入門:基礎架構與應用場景
系列文
重啟挑戰:老派軟體工程師的測試修練 共 31 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言