9.1.3 FluentValidation是否更新了破坏验证器的模拟?

问题描述

我已将软件包的版本更新为9.1.3,现在我的验证程序模拟无效。

无论.Validate()返回true还是false代码都会以某种方式运行。

以下是验证程序模拟的代码

validatorMock
    .Setup(x => x.Validate(It.IsAny<IValidationContext>()).IsValid)
    .Returns(false);

    Assert.Throws<ValidationException>(() => command.Execute(request),"Position field validation error");
    repositoryMock.Verify(repository => repository.EditPosition(It.IsAny<DbPosition>()),Times.Never);

这是测试失败:

Message: 
      Position field validation error
      Expected: <FluentValidation.ValidationException>
      But was:  null

Validator.cs:

public class SampleValidator : AbstractValidator<Position>
    {
        public SampleValidator()
        {
            RuleFor(position => position.Name)
                .NotEmpty()
                .MaximumLength(80)
                .WithMessage("Position name is too long");

            RuleFor(position => position.Description)
                .NotEmpty()
                .MaximumLength(350)
                .WithMessage("Position description is too long");
        }
    }

依赖注入:

services.AddTransient<IValidator<Position>,SampleValidator>();

用法

public class SampleCommand : ISampleCommand
    {
        private readonly IValidator<Position> validator;
        private readonly ISampleRepository repository;
        private readonly IMapper<Position,DbPosition> mapper;

        public SampleCommand(
            [FromServices] IValidator<Position> validator,[FromServices] ISampleRepository repository,[FromServices] IMapper<Position,DbPosition> mapper)
        {
            this.validator = validator;
            this.repository = repository;
            this.mapper = mapper;
        }

        public bool Execute(Position request)
        {
            validator.ValidateAndThrow(request);

            var position = mapper.Map(request);

            return repository.EditPosition(position);
        }
    }

测试中的验证者嘲讽:

private Mock<IValidator<EditPositionRequest>> validatorMock;
...
validatorMock = new Mock<IValidator<Position>>();

更新

在更新之前,所有测试均运行良好。现在它们已经毁了,我必须安装以前的版本。

解决方法

扩大我的评论:

是的,9.1更改了引发验证异常的处理方式。

某些上下文:

Validator类返回具有ValidationResult布尔属性的IsValidValidateAndThrow扩展方法检查此属性,如果IsValid为false,则引发异常。如果您模拟了验证器,那么如果模拟返回无效的验证结果,您仍然可以在模拟中使用“真实的” ValidateAndThrow扩展方法来引发异常。

在FluentValidation 9.1中,引发异常的逻辑从扩展方法中移出,并在RaiseValidationException中移入了验证器类本身。这样做是为了可以自定义抛出异常的逻辑(通过重写此方法),而这是扩展方法之前无法做到的。

// This is the ValidateAndThrow method definition versions older than 9.1
public static void ValidateAndThrow<T>(this IValidator<T> validator,T instance) {
  var result = validator.Validate(instance);
  
  if (!result.IsValid) {
    throw new ValidationException(result.Errors);
  }
}

// This is the ValidateAndThrowMethod in 9.1 and newer
public static void ValidateAndThrow<T>(this IValidator<T> validator,T instance) {
  validator.Validate(instance,options => {
    options.ThrowOnFailures();
  });
}

对于运行时使用,这没有什么不同-仍会引发异常(除非您已重写方法来防止此情况)。

但是,这样做的副作用是,如果您依赖于扩展方法而不是验证程序抛出的异常,则将产生不良结果。实际上,只有在验证验证器时才是这种情况。现在,当您创建模拟时,不会抛出异常,因为模拟并不像真正的验证器那样起作用。

我对FluentValidation的建议始终是“不要模拟验证器”,而应将它们视为黑盒,并为测试目的提供具有有效/无效输入的真实验证器实例-从长远来看,这将减少脆弱性。但是,我也知道,如果您已经有很多测试,则可能无法以这种方式重写测试。

作为一种解决方法,您可以模拟Validate的重载,该重载占用ValidationContext并检查ThrowOnFailures属性的上下文,并在设置了模拟的情况下引发异常真实。

但是,请注意,如果执行此操作,则可能会遇到这样的情况,即您的模拟行为是一种方式,而实际的验证程序的行为是不同的(如果其RaiseValidationException消息已被覆盖)。

由于这是一项重大更改,是否应该在主要版本中进行?理想情况下是的,这是我的缺点,因为我没有预见到这种特殊的用例。

编辑:以下是创建检查ThrowOnFailures属性的模拟的示例。该示例使用Moq库,但是相同的概念也将适用于其他模拟库。

private static Mock<IValidator<T>> CreateFailingMockValidator<T>() {
  var mockValidator = new Mock<IValidator<T>>();

  var failureResult = new ValidationResult(new List<ValidationFailure>() {
    new ValidationFailure("Foo","Bar")
  });

  // Setup the Validate/ValidateAsync overloads that take an instance.
  // These will never throw exceptions.
  mockValidator.Setup(p => p.Validate(It.IsAny<T>()))
    .Returns(failureResult).Verifiable();
  mockValidator.Setup(p => p.ValidateAsync(It.IsAny<T>(),It.IsAny<CancellationToken>()))
    .ReturnsAsync(failureResult);

  // Setup the Validate/ValidateAsync overloads that take a context.
  // This is the method called by ValidateAndThrow,so will potentially support throwing the exception.
  // Setup method invocations for with an exception and without.
  mockValidator.Setup(p => p.Validate(It.Is<ValidationContext<T>>(context => context.ThrowOnFailures)))
    .Throws(new ValidationException(failureResult.Errors));
  mockValidator.Setup(p => p.ValidateAsync(It.Is<ValidationContext<T>>(context => context.ThrowOnFailures),It.IsAny<CancellationToken>()))
    .Throws(new ValidationException(failureResult.Errors));

  // If ThrowOnFailures is false,return the result.
  mockValidator.Setup(p => p.Validate(It.Is<ValidationContext<T>>(context => !context.ThrowOnFailures)))
    .Returns(failureResult).Verifiable();
  mockValidator.Setup(p => p.ValidateAsync(It.Is<ValidationContext<T>>(context => !context.ThrowOnFailures),It.IsAny<CancellationToken>()))
    .ReturnsAsync(failureResult);

  return mockValidator;
}