← Volver al blogValidaciones en .NET con FluentValidation: reglas claras, PATCH condicional y pruebas
dotnetfluentvalidationvalidationaspnet-coretesting

Validaciones en .NET con FluentValidation: reglas claras, PATCH condicional y pruebas

Tutorial funcional con .NET 10 y FluentValidation: reglas reutilizables, validación condicional para PATCH, ProblemDetails, Dependency Injection y pruebas de caja negra.

Los if de validación crecen rápido dentro de un controller. FluentValidation permite mover esas decisiones a clases pequeñas, explícitas y fáciles de probar sin esconder el flujo de la petición.


Validar una dirección de correo, comparar dos contraseñas o aplicar reglas únicamente cuando un campo llega en un PATCH parece sencillo. El problema aparece cuando todas esas condiciones terminan mezcladas con persistencia, respuestas HTTP y reglas de negocio.

En este tutorial construiremos una Web API funcional con .NET 10 y FluentValidation 12.1.1. El ejemplo incluye:

  • Registro completo de una cuenta.
  • Actualización parcial con semántica PATCH.
  • Reglas encadenadas y CascadeMode.Stop.
  • Comparación entre propiedades.
  • Condiciones con When.
  • Una regla reutilizable para contraseñas.
  • Registro automático mediante Dependency Injection.
  • Validación manual con ValidateAsync.
  • Errores con ValidationProblemDetails.
  • Pruebas de caja negra con FluentValidation.TestHelper.

El repositorio será en memoria para que el proyecto pueda ejecutarse sin una base de datos. La validación es el único tema del artículo.

Qué debe validar FluentValidation

En este ejemplo FluentValidation se ocupa de la forma de la entrada:

  • Campos requeridos.
  • Longitudes máximas y mínimas.
  • Formato del correo.
  • Caracteres permitidos.
  • Coincidencia entre contraseña y confirmación.
  • Presencia de al menos un campo en un PATCH.

Reglas como “el correo ya existe”, “el usuario puede editar esta cuenta” o “este plan permite modificar el perfil” pertenecen al caso de uso o al dominio. Separar ambas categorías mantiene los validators deterministas y rápidos.

Crear la solución

mkdir ValidationTutorial && cd ValidationTutorial

dotnet new sln --name ValidationTutorial --format slnx
dotnet new webapi --use-controllers --no-https \
  -n ValidationTutorial.Api \
  -o src/ValidationTutorial.Api \
  --framework net10.0

dotnet new xunit \
  -n ValidationTutorial.Tests \
  -o tests/ValidationTutorial.Tests \
  --framework net10.0

dotnet sln add src/ValidationTutorial.Api/ValidationTutorial.Api.csproj
dotnet sln add tests/ValidationTutorial.Tests/ValidationTutorial.Tests.csproj
dotnet add tests/ValidationTutorial.Tests reference \
  src/ValidationTutorial.Api/ValidationTutorial.Api.csproj

dotnet add src/ValidationTutorial.Api package FluentValidation --version 12.1.1
dotnet add src/ValidationTutorial.Api package \
  FluentValidation.DependencyInjectionExtensions --version 12.1.1

Elimina los archivos WeatherForecast generados por el template. La estructura final será:

ValidationTutorial/
├── src/ValidationTutorial.Api/
│   ├── Contracts/
│   │   ├── RegisterAccountRequest.cs
│   │   ├── UpdateAccountRequest.cs
│   │   └── AccountResponse.cs
│   ├── Validation/
│   │   ├── ValidationRules.cs
│   │   ├── ValidationMessages.cs
│   │   ├── PasswordRuleExtensions.cs
│   │   ├── RegisterAccountRequestValidator.cs
│   │   └── UpdateAccountRequestValidator.cs
│   ├── Controllers/AccountsController.cs
│   ├── Domain/Account.cs
│   ├── Persistence/
│   │   ├── IAccountRepository.cs
│   │   └── InMemoryAccountRepository.cs
│   └── Program.cs
└── tests/ValidationTutorial.Tests/
    ├── RegisterAccountRequestValidatorTests.cs
    └── UpdateAccountRequestValidatorTests.cs

1. Definir los contratos HTTP

Contracts/RegisterAccountRequest.cs

namespace ValidationTutorial.Api.Contracts;

/// <summary>
/// Input required to register a new account.
/// </summary>
public sealed record RegisterAccountRequest(
    string Email,
    string Password,
    string ConfirmPassword,
    string DisplayName,
    string? Bio);

El registro requiere todos los datos necesarios para crear una cuenta. La confirmación existe solo para validar la intención del usuario y nunca se persiste.

Contracts/UpdateAccountRequest.cs

namespace ValidationTutorial.Api.Contracts;

/// <summary>
/// Partial account update. A null property means "leave the current value unchanged".
/// </summary>
public sealed record UpdateAccountRequest(
    string? DisplayName,
    string? Bio);

Los campos son nullable porque el endpoint utiliza semántica PATCH. null significa “conservar el valor actual”; una cadena vacía sí fue enviada y debe validarse.

Contracts/AccountResponse.cs

using ValidationTutorial.Api.Domain;

namespace ValidationTutorial.Api.Contracts;

/// <summary>
/// Public account representation returned by the API.
/// </summary>
public sealed record AccountResponse(
    Guid Id,
    string Email,
    string DisplayName,
    string? Bio,
    DateTimeOffset CreatedAt)
{
    /// <summary>
    /// Maps the domain entity without exposing its internal implementation.
    /// </summary>
    public static AccountResponse From(Account account)
    {
        return new AccountResponse(
            account.Id,
            account.Email,
            account.DisplayName,
            account.Bio,
            account.CreatedAt);
    }
}

La respuesta se mantiene separada de la entidad para no exponer detalles internos del dominio.

2. Centralizar límites y mensajes

Validation/ValidationRules.cs

namespace ValidationTutorial.Api.Validation;

/// <summary>
/// Shared limits and whitelist patterns used by account validators.
/// </summary>
public static class ValidationRules
{
    public const int EmailMaxLength = 254;
    public const int PasswordMinLength = 12;
    public const int DisplayNameMaxLength = 80;
    public const int BioMaxLength = 280;

    public const string DisplayNamePattern =
        @"^[\p{L}\p{M}\p{N}\s._\-]+$";

    public const string UppercasePattern = @"[A-Z]";
    public const string LowercasePattern = @"[a-z]";
    public const string DigitPattern = @"\d";
    public const string SymbolPattern = @"[^a-zA-Z0-9]";
}

Los límites y patrones tienen nombres. Esto evita números y expresiones regulares repartidos por varios validators y facilita cambiar una política sin buscar magic strings.

El patrón del nombre es una whitelist: acepta letras Unicode, marcas diacríticas, números, espacios y una cantidad pequeña de separadores. Nombres como Álvaro García siguen siendo válidos, mientras que markup como <script> no coincide.

Validation/ValidationMessages.cs

namespace ValidationTutorial.Api.Validation;

/// <summary>
/// Stable error messages returned by the sample API and asserted by tests.
/// </summary>
public static class ValidationMessages
{
    public const string EmailRequired = "Email is required.";
    public const string EmailInvalid = "Email format is invalid.";
    public const string EmailTooLong = "Email is too long.";

    public const string PasswordRequired = "Password is required.";
    public const string PasswordTooShort =
        "Password must contain at least 12 characters.";
    public const string PasswordUppercase =
        "Password must contain an uppercase letter.";
    public const string PasswordLowercase =
        "Password must contain a lowercase letter.";
    public const string PasswordDigit =
        "Password must contain a digit.";
    public const string PasswordSymbol =
        "Password must contain a symbol.";
    public const string PasswordConfirmationRequired =
        "Password confirmation is required.";
    public const string PasswordsDoNotMatch =
        "Password confirmation must match the password.";

    public const string DisplayNameRequired = "Display name is required.";
    public const string DisplayNameTooLong = "Display name is too long.";
    public const string DisplayNameInvalid =
        "Display name contains unsupported characters.";
    public const string BioTooLong = "Bio cannot exceed 280 characters.";
    public const string UpdateRequiresValue =
        "Provide at least one field to update.";
}

Los tests pueden afirmar mensajes estables y la API mantiene un contrato predecible. En un sistema multidioma, estas constantes pueden reemplazarse por recursos localizados sin cambiar las reglas.

3. Crear una regla reutilizable

Validation/PasswordRuleExtensions.cs

using FluentValidation;

namespace ValidationTutorial.Api.Validation;

/// <summary>
/// Reusable password rules that can be shared by registration and reset flows.
/// </summary>
public static class PasswordRuleExtensions
{
    /// <summary>
    /// Requires a minimum length plus uppercase, lowercase, digit, and symbol.
    /// </summary>
    public static IRuleBuilderOptions<T, string> StrongPassword<T>(
        this IRuleBuilder<T, string> ruleBuilder)
    {
        return ruleBuilder
            .MinimumLength(ValidationRules.PasswordMinLength)
                .WithMessage(ValidationMessages.PasswordTooShort)
            .Matches(ValidationRules.UppercasePattern)
                .WithMessage(ValidationMessages.PasswordUppercase)
            .Matches(ValidationRules.LowercasePattern)
                .WithMessage(ValidationMessages.PasswordLowercase)
            .Matches(ValidationRules.DigitPattern)
                .WithMessage(ValidationMessages.PasswordDigit)
            .Matches(ValidationRules.SymbolPattern)
                .WithMessage(ValidationMessages.PasswordSymbol);
    }
}

FluentValidation permite extender IRuleBuilder. La política queda disponible como cualquier regla incorporada:

RuleFor(request => request.Password)
    .NotEmpty()
    .StrongPassword();

La extensión no consulta una base de datos ni conoce el endpoint. Solo encapsula reglas de formato que se repiten en registro, cambio y recuperación de contraseña.

4. Validar el registro

Validation/RegisterAccountRequestValidator.cs

using FluentValidation;
using ValidationTutorial.Api.Contracts;

namespace ValidationTutorial.Api.Validation;

/// <summary>
/// Validates registration input format and cross-property consistency.
/// </summary>
public sealed class RegisterAccountRequestValidator
    : AbstractValidator<RegisterAccountRequest>
{
    public RegisterAccountRequestValidator()
    {
        RuleLevelCascadeMode = CascadeMode.Stop;

        RuleFor(request => request.Email)
            .NotEmpty().WithMessage(ValidationMessages.EmailRequired)
            .MaximumLength(ValidationRules.EmailMaxLength)
                .WithMessage(ValidationMessages.EmailTooLong)
            .EmailAddress().WithMessage(ValidationMessages.EmailInvalid);

        RuleFor(request => request.Password)
            .NotEmpty().WithMessage(ValidationMessages.PasswordRequired)
            .StrongPassword();

        RuleFor(request => request.ConfirmPassword)
            .NotEmpty()
                .WithMessage(ValidationMessages.PasswordConfirmationRequired)
            .Equal(request => request.Password)
                .WithMessage(ValidationMessages.PasswordsDoNotMatch);

        RuleFor(request => request.DisplayName)
            .NotEmpty().WithMessage(ValidationMessages.DisplayNameRequired)
            .MaximumLength(ValidationRules.DisplayNameMaxLength)
                .WithMessage(ValidationMessages.DisplayNameTooLong)
            .Matches(ValidationRules.DisplayNamePattern)
                .WithMessage(ValidationMessages.DisplayNameInvalid);

        When(request => request.Bio is not null, () =>
        {
            RuleFor(request => request.Bio)
                .MaximumLength(ValidationRules.BioMaxLength)
                .WithMessage(ValidationMessages.BioTooLong);
        });
    }
}

Hay varias decisiones importantes:

  1. RuleLevelCascadeMode = CascadeMode.Stop evita ejecutar validadores posteriores cuando una regla anterior ya falló. Un correo vacío devuelve “requerido” y no varios errores redundantes.
  2. Equal(request => request.Password) compara dos propiedades del mismo objeto.
  3. When(request => request.Bio is not null) ejecuta la regla opcional solo cuando el cliente envió el campo.
  4. Las reglas describen entrada. No intentan comprobar unicidad ni autorización.

5. Validar un PATCH

Validation/UpdateAccountRequestValidator.cs

using FluentValidation;
using ValidationTutorial.Api.Contracts;

namespace ValidationTutorial.Api.Validation;

/// <summary>
/// Validates PATCH input only when an optional property was supplied.
/// </summary>
public sealed class UpdateAccountRequestValidator
    : AbstractValidator<UpdateAccountRequest>
{
    public UpdateAccountRequestValidator()
    {
        RuleLevelCascadeMode = CascadeMode.Stop;

        RuleFor(request => request)
            .Must(HasAtLeastOneValue)
            .WithName("request")
            .WithMessage(ValidationMessages.UpdateRequiresValue);

        When(request => request.DisplayName is not null, () =>
        {
            RuleFor(request => request.DisplayName)
                .NotEmpty().WithMessage(ValidationMessages.DisplayNameRequired)
                .MaximumLength(ValidationRules.DisplayNameMaxLength)
                    .WithMessage(ValidationMessages.DisplayNameTooLong)
                .Matches(ValidationRules.DisplayNamePattern)
                    .WithMessage(ValidationMessages.DisplayNameInvalid);
        });

        When(request => request.Bio is not null, () =>
        {
            RuleFor(request => request.Bio)
                .MaximumLength(ValidationRules.BioMaxLength)
                .WithMessage(ValidationMessages.BioTooLong);
        });
    }

    private static bool HasAtLeastOneValue(UpdateAccountRequest request)
    {
        return request.DisplayName is not null ||
               request.Bio is not null;
    }
}

Una petición vacía como esta no debe producir una operación exitosa que no cambia nada:

{
  "displayName": null,
  "bio": null
}

La regla sobre el objeto completo usa Must(HasAtLeastOneValue) y asigna el nombre request, de modo que ProblemDetails devuelve una clave clara.

Después, cada propiedad opcional se valida dentro de su propio When. Esta distinción es importante:

  • null: el campo no participa en el PATCH.
  • "": el cliente sí lo envió y la regla puede rechazarlo o convertirlo en una acción explícita.

En el caso de Bio, una cadena vacía es válida y la entidad la normaliza a null, permitiendo limpiar la biografía.

6. Ejecutar validación manual en ASP.NET Core

Controllers/AccountsController.cs

using FluentValidation;
using FluentValidation.Results;
using Microsoft.AspNetCore.Mvc;
using ValidationTutorial.Api.Contracts;
using ValidationTutorial.Api.Domain;
using ValidationTutorial.Api.Persistence;

namespace ValidationTutorial.Api.Controllers;

/// <summary>
/// Demonstrates explicit asynchronous FluentValidation usage in an API controller.
/// </summary>
[ApiController]
[Route("api/accounts")]
public sealed class AccountsController(
    IValidator<RegisterAccountRequest> registerValidator,
    IValidator<UpdateAccountRequest> updateValidator,
    IAccountRepository repository) : ControllerBase
{
    /// <summary>Registers an account after validating the complete request.</summary>
    [HttpPost]
    public async Task<ActionResult<AccountResponse>> Register(
        RegisterAccountRequest request,
        CancellationToken cancellationToken)
    {
        ValidationResult validation =
            await registerValidator.ValidateAsync(request, cancellationToken);

        if (!validation.IsValid)
        {
            return BadRequest(CreateProblemDetails(validation));
        }

        Account account = Account.Create(
            request.Email,
            request.DisplayName,
            request.Bio);

        await repository.AddAsync(account, cancellationToken);

        return CreatedAtAction(
            nameof(GetById),
            new { id = account.Id },
            AccountResponse.From(account));
    }

    /// <summary>Applies a validated partial update to an existing account.</summary>
    [HttpPatch("{id:guid}")]
    public async Task<ActionResult<AccountResponse>> Update(
        Guid id,
        UpdateAccountRequest request,
        CancellationToken cancellationToken)
    {
        ValidationResult validation =
            await updateValidator.ValidateAsync(request, cancellationToken);

        if (!validation.IsValid)
        {
            return BadRequest(CreateProblemDetails(validation));
        }

        Account? current = await repository.GetByIdAsync(id, cancellationToken);
        if (current is null)
        {
            return NotFound();
        }

        Account updated = current.Update(
            request.DisplayName,
            request.Bio);

        await repository.UpdateAsync(updated, cancellationToken);
        return Ok(AccountResponse.From(updated));
    }

    /// <summary>Returns an account by id.</summary>
    [HttpGet("{id:guid}")]
    public async Task<ActionResult<AccountResponse>> GetById(
        Guid id,
        CancellationToken cancellationToken)
    {
        Account? account = await repository.GetByIdAsync(id, cancellationToken);
        return account is null
            ? NotFound()
            : Ok(AccountResponse.From(account));
    }

    private static ValidationProblemDetails CreateProblemDetails(
        ValidationResult validation)
    {
        return new ValidationProblemDetails(validation.ToDictionary())
        {
            Title = "One or more validation errors occurred.",
            Status = StatusCodes.Status400BadRequest
        };
    }
}

La validación manual hace visible el flujo:

HTTP request
  → ValidateAsync
  → ValidationProblemDetails o caso de uso
  → HTTP response

La documentación oficial recomienda este enfoque como la opción más directa y fácil de depurar. Además funciona con reglas asíncronas, a diferencia del pipeline automático tradicional de MVC.

ValidationResult.ToDictionary() agrupa los errores con el formato esperado por ValidationProblemDetails:

{
  "title": "One or more validation errors occurred.",
  "status": 400,
  "errors": {
    "Email": ["Email format is invalid."],
    "ConfirmPassword": ["Password confirmation must match the password."]
  }
}

Aunque este proyecto solo usa reglas síncronas, llama siempre a ValidateAsync. Si más adelante se agrega MustAsync, el controller no necesita cambiar.

7. Registrar validators con Dependency Injection

Program.cs

using FluentValidation;
using ValidationTutorial.Api.Persistence;
using ValidationTutorial.Api.Validation;

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();
builder.Services.AddValidatorsFromAssemblyContaining<RegisterAccountRequestValidator>(
    ServiceLifetime.Transient);
builder.Services.AddSingleton<IAccountRepository, InMemoryAccountRepository>();

WebApplication app = builder.Build();

app.MapControllers();

app.Run();

AddValidatorsFromAssemblyContaining descubre las clases públicas que heredan de AbstractValidator<T> y las registra como IValidator<T>.

Uso ServiceLifetime.Transient, la opción más segura cuando un validator puede incorporar dependencias con ciclos de vida más cortos. La documentación recomienda evitar singleton salvo que el equipo controle cuidadosamente las dependencias inyectadas.

8. Mantener el ejemplo ejecutable

Domain/Account.cs

namespace ValidationTutorial.Api.Domain;

/// <summary>
/// Minimal account entity used by the validation tutorial.
/// </summary>
public sealed record Account
{
    public required Guid Id { get; init; }
    public required string Email { get; init; }
    public required string DisplayName { get; init; }
    public string? Bio { get; init; }
    public required DateTimeOffset CreatedAt { get; init; }

    /// <summary>
    /// Creates an account after the request has passed input validation.
    /// </summary>
    public static Account Create(
        string email,
        string displayName,
        string? bio)
    {
        return new Account
        {
            Id = Guid.NewGuid(),
            Email = email.Trim().ToLowerInvariant(),
            DisplayName = displayName.Trim(),
            Bio = NormalizeOptionalText(bio),
            CreatedAt = DateTimeOffset.UtcNow
        };
    }

    /// <summary>
    /// Applies PATCH semantics: null values preserve the current property.
    /// </summary>
    public Account Update(
        string? displayName,
        string? bio)
    {
        return this with
        {
            DisplayName = displayName?.Trim() ?? DisplayName,
            Bio = bio is null ? Bio : NormalizeOptionalText(bio)
        };
    }

    private static string? NormalizeOptionalText(string? value)
    {
        return string.IsNullOrWhiteSpace(value)
            ? null
            : value.Trim();
    }
}

La entidad recibe datos que ya pasaron validación de entrada, pero sigue controlando su normalización. El dominio no depende de FluentValidation.

Persistence/IAccountRepository.cs

using ValidationTutorial.Api.Domain;

namespace ValidationTutorial.Api.Persistence;

/// <summary>
/// Persistence abstraction used by the controller.
/// </summary>
public interface IAccountRepository
{
    Task AddAsync(
        Account account,
        CancellationToken cancellationToken);

    Task<Account?> GetByIdAsync(
        Guid id,
        CancellationToken cancellationToken);

    Task UpdateAsync(
        Account account,
        CancellationToken cancellationToken);
}

Persistence/InMemoryAccountRepository.cs

using System.Collections.Concurrent;
using ValidationTutorial.Api.Domain;

namespace ValidationTutorial.Api.Persistence;

/// <summary>
/// Thread-safe store that keeps the sample runnable without a database.
/// </summary>
public sealed class InMemoryAccountRepository : IAccountRepository
{
    private readonly ConcurrentDictionary<Guid, Account> _accounts = new();

    /// <inheritdoc />
    public Task AddAsync(
        Account account,
        CancellationToken cancellationToken)
    {
        cancellationToken.ThrowIfCancellationRequested();
        _accounts[account.Id] = account;
        return Task.CompletedTask;
    }

    /// <inheritdoc />
    public Task<Account?> GetByIdAsync(
        Guid id,
        CancellationToken cancellationToken)
    {
        cancellationToken.ThrowIfCancellationRequested();
        _accounts.TryGetValue(id, out Account? account);
        return Task.FromResult(account);
    }

    /// <inheritdoc />
    public Task UpdateAsync(
        Account account,
        CancellationToken cancellationToken)
    {
        cancellationToken.ThrowIfCancellationRequested();
        _accounts[account.Id] = account;
        return Task.CompletedTask;
    }
}

El repositorio en memoria es únicamente infraestructura de la demo. Puede sustituirse por EF Core sin tocar los validators.

9. Probar validators como cajas negras

RegisterAccountRequestValidatorTests.cs

using FluentValidation.TestHelper;
using ValidationTutorial.Api.Contracts;
using ValidationTutorial.Api.Validation;

namespace ValidationTutorial.Tests;

/// <summary>
/// Black-box tests for registration rules.
/// </summary>
public sealed class RegisterAccountRequestValidatorTests
{
    private readonly RegisterAccountRequestValidator _validator = new();

    [Fact]
    public async Task ValidateAsync_WithValidUnicodeName_Passes()
    {
        RegisterAccountRequest request = CreateValidRequest() with
        {
            DisplayName = "Álvaro García"
        };

        TestValidationResult<RegisterAccountRequest> result =
            await _validator.TestValidateAsync(request);

        result.ShouldNotHaveAnyValidationErrors();
    }

    [Fact]
    public async Task ValidateAsync_WithInvalidEmail_ReturnsEmailError()
    {
        RegisterAccountRequest request = CreateValidRequest() with
        {
            Email = "not-an-email"
        };

        TestValidationResult<RegisterAccountRequest> result =
            await _validator.TestValidateAsync(request);

        result.ShouldHaveValidationErrorFor(item => item.Email)
            .WithErrorMessage(ValidationMessages.EmailInvalid)
            .Only();
    }

    [Fact]
    public async Task ValidateAsync_WithoutUppercasePassword_ReturnsPasswordError()
    {
        RegisterAccountRequest request = CreateValidRequest() with
        {
            Password = "lowercase123!",
            ConfirmPassword = "lowercase123!"
        };

        TestValidationResult<RegisterAccountRequest> result =
            await _validator.TestValidateAsync(request);

        result.ShouldHaveValidationErrorFor(item => item.Password)
            .WithErrorMessage(ValidationMessages.PasswordUppercase)
            .Only();
    }

    [Fact]
    public async Task ValidateAsync_WithDifferentConfirmation_ReturnsMatchError()
    {
        RegisterAccountRequest request = CreateValidRequest() with
        {
            ConfirmPassword = "Different123!"
        };

        TestValidationResult<RegisterAccountRequest> result =
            await _validator.TestValidateAsync(request);

        result.ShouldHaveValidationErrorFor(item => item.ConfirmPassword)
            .WithErrorMessage(ValidationMessages.PasswordsDoNotMatch)
            .Only();
    }

    [Fact]
    public async Task ValidateAsync_WithMarkupInDisplayName_ReturnsCharacterError()
    {
        RegisterAccountRequest request = CreateValidRequest() with
        {
            DisplayName = "Alice<script>"
        };

        TestValidationResult<RegisterAccountRequest> result =
            await _validator.TestValidateAsync(request);

        result.ShouldHaveValidationErrorFor(item => item.DisplayName)
            .WithErrorMessage(ValidationMessages.DisplayNameInvalid)
            .Only();
    }

    private static RegisterAccountRequest CreateValidRequest()
    {
        return new RegisterAccountRequest(
            Email: "[email protected]",
            Password: "StrongPass123!",
            ConfirmPassword: "StrongPass123!",
            DisplayName: "Alice Developer",
            Bio: null);
    }
}

UpdateAccountRequestValidatorTests.cs

using FluentValidation.TestHelper;
using ValidationTutorial.Api.Contracts;
using ValidationTutorial.Api.Validation;

namespace ValidationTutorial.Tests;

/// <summary>
/// Black-box tests for conditional PATCH validation.
/// </summary>
public sealed class UpdateAccountRequestValidatorTests
{
    private readonly UpdateAccountRequestValidator _validator = new();

    [Fact]
    public async Task ValidateAsync_WithoutValues_ReturnsRequestError()
    {
        UpdateAccountRequest request = new(
            DisplayName: null,
            Bio: null);

        TestValidationResult<UpdateAccountRequest> result =
            await _validator.TestValidateAsync(request);

        result.ShouldHaveValidationErrorFor("request")
            .WithErrorMessage(ValidationMessages.UpdateRequiresValue)
            .Only();
    }

    [Fact]
    public async Task ValidateAsync_WithEmptyDisplayName_ReturnsRequiredError()
    {
        UpdateAccountRequest request = new(
            DisplayName: string.Empty,
            Bio: null);

        TestValidationResult<UpdateAccountRequest> result =
            await _validator.TestValidateAsync(request);

        result.ShouldHaveValidationErrorFor(item => item.DisplayName)
            .WithErrorMessage(ValidationMessages.DisplayNameRequired)
            .Only();
    }

    [Fact]
    public async Task ValidateAsync_WithLongBio_ReturnsLengthError()
    {
        UpdateAccountRequest request = new(
            DisplayName: null,
            Bio: new string('A', ValidationRules.BioMaxLength + 1));

        TestValidationResult<UpdateAccountRequest> result =
            await _validator.TestValidateAsync(request);

        result.ShouldHaveValidationErrorFor(item => item.Bio)
            .WithErrorMessage(ValidationMessages.BioTooLong)
            .Only();
    }

    [Fact]
    public async Task ValidateAsync_WithOneValidField_Passes()
    {
        UpdateAccountRequest request = new(
            DisplayName: "Updated Name",
            Bio: null);

        TestValidationResult<UpdateAccountRequest> result =
            await _validator.TestValidateAsync(request);

        result.ShouldNotHaveAnyValidationErrors();
    }
}

Las pruebas crean el validator real y verifican su comportamiento. No necesitan conocer cómo FluentValidation construye internamente cada regla.

TestValidateAsync permite afirmar propiedad, mensaje, código o severidad. El modificador Only() garantiza que el escenario no generó errores adicionales inesperados.

La suite cubre:

  • Entrada válida con caracteres Unicode.
  • Correo inválido.
  • Contraseña sin mayúscula.
  • Confirmación diferente.
  • Caracteres no permitidos.
  • PATCH vacío.
  • Campo opcional vacío.
  • Biografía demasiado larga.
  • Actualización válida de un solo campo.

10. Ejecutar el proyecto

dotnet build ValidationTutorial.slnx
dotnet test ValidationTutorial.slnx
dotnet run --project src/ValidationTutorial.Api \
  --urls http://localhost:5100

Probar una petición inválida:

curl -X POST http://localhost:5100/api/accounts \
  -H 'Content-Type: application/json' \
  -d '{
    "email": "bad",
    "password": "weak",
    "confirmPassword": "different",
    "displayName": "<script>",
    "bio": null
  }'

Registrar una cuenta válida:

curl -X POST http://localhost:5100/api/accounts \
  -H 'Content-Type: application/json' \
  -d '{
    "email": "[email protected]",
    "password": "StrongPass123!",
    "confirmPassword": "StrongPass123!",
    "displayName": "Álvaro Developer",
    "bio": "Building with .NET"
  }'

Actualizar solo el nombre:

curl -X PATCH http://localhost:5100/api/accounts/<guid> \
  -H 'Content-Type: application/json' \
  -d '{
    "displayName": "Updated Developer",
    "bio": null
  }'

El proyecto de este tutorial compila sin warnings, ejecuta 9 pruebas y devuelve 400 ValidationProblemDetails para entradas inválidas.

Errores que evitaría

Mezclar validación de entrada con reglas de negocio

Consultar si el correo ya existe dentro del validator acopla la validación a persistencia y puede generar carreras. El caso de uso debe tomar esa decisión con una restricción única en la base de datos.

Usar validación automática síncrona con reglas asíncronas

El pipeline automático tradicional de ASP.NET Core no es asíncrono y ya no se recomienda para proyectos nuevos. Si existe MustAsync, usa ValidateAsync explícitamente.

Duplicar reglas y mensajes

Límites, patrones y políticas repetidas terminan divergiendo. Las constantes y extensiones reutilizables mantienen una sola definición.

Validar campos PATCH que no llegaron

Un campo nullable no debe fallar solo porque fue omitido. Protege su regla con When y valida por separado la petición vacía.

Conclusión

FluentValidation aporta más valor cuando las reglas son explícitas, pequeñas y verificables:

  • RuleFor describe cada propiedad.
  • When modela campos opcionales.
  • Equal expresa relaciones entre valores.
  • Must cubre condiciones sobre el objeto completo.
  • Las extensiones encapsulan políticas reutilizables.
  • ValidateAsync mantiene el flujo visible y preparado para reglas asíncronas.
  • TestValidateAsync prueba el contrato sin depender de detalles internos.

El resultado es un controller que coordina HTTP y validators que se dedican exclusivamente a validar entrada.

Fuentes y referencias


Tutorial validado con .NET 10, FluentValidation 12.1.1 y una suite xUnit de 9 pruebas.

Comentarios

Cargando comentarios…