Why Test Validators?
Validators are the first line of defense for an application. Testing validators can:
- Ensure data integrity - Prevent invalid data from entering the system
- Document business rules - Tests are living documentation that clearly demonstrate business rules
- Security assurance - Prevent malicious or improper data input
- Refactoring safety net - Provide protection when business rules change
- Cross-field logic validation - Ensure complex logic works correctly
Prerequisites
Package Installation
<PackageReference Include="FluentValidation" Version="12.1.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="10.4.0" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="AwesomeAssertions" Version="9.4.0" />
Note:
FluentValidation.TestHelperThe namespace (TestValidate、ShouldHaveValidationErrorForand other APIs) is already included in theFluentValidationmain package, so there is no need to install a separate standalone package. You only needusing FluentValidation.TestHelper;to use it.
FluentValidation 12.x Notes: FluentValidation 12.0 is a major version upgrade, with a minimum requirement of .NET 8. Removed APIs include
Transform/TransformForEach(useMust+ manual conversion instead),InjectValidator(use constructor injection +SetValidator)、CascadeMode.StopOnFirstFailure(useRuleLevelCascadeMode = CascadeMode.Stop)。ShouldHaveAnyValidationErrorhas been renamed toShouldHaveValidationErrors. For the complete migration guide, see FluentValidation 12.0 Upgrade Guide。
Basic using Directives
using FluentValidation;
using FluentValidation.TestHelper;
using Microsoft.Extensions.Time.Testing;
using NSubstitute;
using Xunit;
using AwesomeAssertions;
Core Testing Patterns
This section covers 7 core testing patterns, each including a validator definition and a complete test example.
For complete code examples, see references/core-test-patterns.md
- Pattern 1: Basic Field Validation — Use
TestValidate+ShouldHaveValidationErrorFor/ShouldNotHaveValidationErrorForto test a single field rule - Pattern 2: Parameterized Testing — Use
[Theory]+[InlineData]to test multiple invalid/valid input combinations - Pattern 3: Cross-Field Validation — Multi-field related validation such as password confirmation and custom
Must()rules - Pattern 4: Time-Dependent Validation — Inject
TimeProvider, and useFakeTimeProviderto control time for testing - Pattern 5: Conditional Validation — Optional field validation using
.When(), testing condition-triggered and skipped scenarios - Pattern 6: Asynchronous Validation —
MustAsync+TestValidateAsync, with NSubstitute Mock external services - Pattern 7: Collection Validation — Validate that a collection is non-empty and that its elements are valid
Quick Example: Basic Field Validation
public class UserValidatorTests
{
private readonly UserValidator _validator = new();
[Fact]
public void Validate_空白使用者名稱_應該驗證失敗()
{
var result = _validator.TestValidate(
new UserRegistrationRequest { Username = "" });
result.ShouldHaveValidationErrorFor(x => x.Username)
.WithErrorMessage("使用者名稱不可為 null 或空白");
}
}
FluentValidation.TestHelper Core API
Test Methods
| Method | Purpose | Example |
|---|---|---|
TestValidate(model) |
Run synchronous validation | _validator.TestValidate(request) |
TestValidateAsync(model) |
Run asynchronous validation | await _validator.TestValidateAsync(request) |
Assertion Methods
| Method | Purpose | Example |
|---|---|---|
ShouldHaveValidationErrorFor(x => x.Property) |
Assert that the property should have an error | result.ShouldHaveValidationErrorFor(x => x.Username) |
ShouldNotHaveValidationErrorFor(x => x.Property) |
Assert that the property should not have an error | result.ShouldNotHaveValidationErrorFor(x => x.Email) |
ShouldNotHaveAnyValidationErrors() |
Assert that the entire object has no errors | result.ShouldNotHaveAnyValidationErrors() |
Error Message Validation
| Method | Purpose | Example |
|---|---|---|
WithErrorMessage(string) |
Verify error message content | .WithErrorMessage("使用者名稱不可為空") |
WithErrorCode(string) |
Verify error code | .WithErrorCode("NOT_EMPTY") |
Testing Best Practices
Recommended Practices
- Use parameterized tests - Use Theory to test multiple input combinations
- Test boundary values - Pay special attention to boundary conditions
- Control time - Use FakeTimeProvider to handle time dependencies
- Mock external dependencies - Use NSubstitute to isolate external services
- Create helper methods - Manage test data in a unified way
- Clear test naming - Use the
方法_情境_預期結果format - Test error messages - Ensure users see the correct error messages
Practices to Avoid
- Avoid using DateTime.Now - It causes unstable tests
- Avoid over-coupling tests - Each test should verify only one rule
- Avoid hardcoding test data - Create it using helper methods
- Avoid ignoring boundary conditions - Boundary values are where errors are most likely
- Avoid skipping error message verification - Error messages are part of the user experience
Common Test Scenarios
Scenario 1: Email format validation
[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);
}
Scenario 2: Age range validation
[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);
}
Scenario 3: Required field validation
[Fact]
public void Validate_未同意條款_應該驗證失敗()
{
var request = new UserRegistrationRequest { AgreeToTerms = false };
var result = _validator.TestValidate(request);
result.ShouldHaveValidationErrorFor(x => x.AgreeToTerms)
.WithErrorMessage("必須同意使用條款");
}
Test Helper Tools
Test data builder
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<string> { "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");
Integration with Other Skills
This skill can be used in combination with the following skills:
- unit-test-fundamentals: Unit testing fundamentals and the 3A pattern
- test-naming-conventions: Test naming conventions
- nsubstitute-mocking: Mocking external service dependencies
- test-data-builder-pattern: Building complex test data
- datetime-testing-timeprovider: Time-dependent testing
Troubleshooting
Q1: How do I test validation that requires a database query?
A: Use Mock to isolate the database dependency:
_mockUserService.IsUsernameAvailableAsync("username")
.Returns(Task.FromResult(false));
Q2: How do I handle time-related validation?
A: Use FakeTimeProvider to control time:
_fakeTimeProvider.SetUtcNow(new DateTime(2024, 1, 1));
Q3: How do I test complex cross-field validation?
A: Test each condition separately to ensure complete coverage:
// 測試生日已過的情況
// 測試生日未到的情況
// 測試邊界日期
Q4: To what extent should I test?
A: Focus testing on:
- At least one test per validation rule
- Boundary values and special cases
- Correctness of error messages
- All combinations of cross-field logic
Template File Reference
This skill provides the following template files:
templates/validator-test-template.cs: Complete validator test exampletemplates/async-validator-examples.cs: Asynchronous validation example
Output Format
- Generate a Validator test class (including TestHelper setup)
- Use ShouldHaveValidationErrorFor/ShouldNotHaveValidationErrorFor assertions
- Include examples of asynchronous validation and cross-field logic testing
- Provide .csproj package references (FluentValidation — TestHelper API already included)
Reference Resources
Original Article
The content of this skill is distilled from the article series "Old-School Software Engineer's Testing Training - 30-Day Challenge":
- Day 18 - Validation Testing: FluentValidation Test Extensions
- Ironman Series Article:https://ithelp.ithome.com.tw/articles/10376147
- Sample Code:https://github.com/kevintsengtw/30Days_in_Testing_Samples/tree/main/day18
Official Documentation
Related Skills
unit-test-fundamentals- Unit Testing Fundamentalsnsubstitute-mocking- Test Doubles and Mocking