在 IOptions .NET Core 1.1 及更高版本的惰性验证之上单元测试自定义热切验证

Unit Testing custom eager validation on top of lazy validation from IOptions .NET Core 1.1 and up

这不是问题,而是我在没有提出问题的情况下尝试的案例研究。万一以后有人尝试这种愚蠢的单元测试,这些是我的发现:

在尝试实施急切验证时,因为 .NET Core 3.1 当前不支持它,但正如本节底部的文档所述 https://docs.microsoft.com/en-us/aspnet/core/fundamentals/configuration/options?view=aspnetcore-3.1#options-post-configuration:

Eager validation (fail fast at startup) is under consideration for a future release.

如果您已经实现了自定义预先验证,则无法通过访问相关选项以编程方式测试延迟验证。

这是我所做的:

已创建配置 class

public class TestOptions : IValidateObject // for eager validation config
{
    [Required]
    public string Prop { get; set; }

    public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
    {
        if (string.IsNullOrEmpty(this.Prop))
            yield return new ValidationResult($"{nameof(this.Prop)} is null or empty.");
    }
}

在我正在测试的库中添加了配置:

public static void AddConfigWithValidation(this IServiceCollection services, Action<TestOptions> options)
{
    var opt = new TestOptions();
    options(opt);

    // eager validation
    var validationErrors = opt.Validate(new ValidationContext(opt)).ToList();

    if (validationErrors.Any())
        throw new ApplicationException($"Found {validationErrors.Count} configuration error(s): {string.Join(',', validationErrors)}");

    // lazy validation with validate data annotations from IOptions
    services.AddOptions<TestOptions>()
        .Configure(o =>
        {
            o.Prop = opt.Prop
        })
        .ValidateDataAnnotations();
}

测试看起来像这样

public class MethodTesting
{
    private readonly IServiceCollection _serviceCollection;

    public MethodTesting()
    {
        _serviceCollection = new ServiceCollection();
    }

    // this works as it should
    [Fact] 
    public void ServiceCollection_Eager_Validation()
    {
        var opt = new TestOptions { Prop = string.Empty };
        Assert.Throws<ApplicationException>(() => _serviceCollection.AddConfigWithValidation(o =>
        {
            o.Prop = opt.Prop
        });
    }

    // this does not work
    [Fact]
    public void ServiceCollection_Lazy_Validation_Mock_Api_Start()
    {
        var builder = new ConfigurationBuilder()
            .SetBasePath(Directory.GetCurrentDirectory())
            .AddJsonFile("settings.json", optional: false, reloadOnChange: true);

        _configuration = builder.Build();

        var opt = _configuration.GetSection(nameof(TestOptions)).Get<TestOptions>();

        _serviceCollection.AddConfigWithValidation(o =>
        {
            o.Prop = opt.Prop
        });

        // try to mock a disposable object, sort of how the API works on subsequent calls
        using (var sb = _serviceCollection.BuildServiceProvider())
        {
            var firstValue = sb.GetRequiredService<IOptionsSnapshot<TestOptions>>().Value;
            firstValue.Should().BeEquivalentTo(opt);
        }

        // edit the json file programmatically, trying to trigger a new IOptionsSnapshot<>
        var path = $"{Directory.GetCurrentDirectory()}\settings.json";

        var jsonString = File.ReadAllText(path);

        var concreteObject = Newtonsoft.Json.JsonConvert.DeserializeObject<TestObject>(jsonString);

        concreteObject.TestObject.Prop = string.Empty;

        File.WriteAllText(path, Newtonsoft.Json.JsonConvert.SerializeObject(concreteObject));

        using (var sb = _serviceCollection.BuildServiceProvider())
        {
            // this does not work, as the snapshot is still identical to the first time it is pulled
            Assert.Throws<OptionsValidationException>(() => _serviceCollection.BuildServiceProvider().GetRequiredService<IOptionsSnapshot<TestOptions>>().Value);
        }
    }

    // this does not work as well
    [Fact]
    public void ServiceCollection_Lazy_Validation_Mock_Api_Start_With_Direct_Prop_Assignation()
    {
        var builder = new ConfigurationBuilder()
            .SetBasePath(Directory.GetCurrentDirectory())
            .AddJsonFile("settings.json", optional: false, reloadOnChange: true);

        _configuration = builder.Build();

        var opt = _configuration.GetSection(nameof(TestOptions)).Get<TestOptions>();

        _serviceCollection.AddConfigWithValidation(o =>
        {
            o.Prop = opt.Prop
        });

        using (var sb = _serviceCollection.BuildServiceProvider())
        {
            var firstValue = sb.GetRequiredService<IOptionsSnapshot<TestOptions>>().Value;
            firstValue.Should().BeEquivalentTo(opt);
        }

        var prop = _configuration["TestOptions:Prop"];

        _configuration["TestOptions:Prop"] = string.Empty;

        // this returns a new value
        var otherProp = _configuration["TestOptions:Prop"];

        using (var sb = _serviceCollection.BuildServiceProvider())
        {
            // this does not work, the snapshot is not yet modified, however, calling _configuration.GetSection(nameof(TestOptions)).Get<TestOptions>(); does return the new TestOptions.

            Assert.Throws<OptionsValidationException>(() => _serviceCollection.BuildServiceProvider().GetRequiredService<IOptionsSnapshot<TestOptions>>().Value);
        }

    }

    public class TestObject
    {
        public TestOptions TestOptions { get; set; }
    }

我的 settings.json 看起来像:

{
    "TestOptions": {
        "Prop": "something"
    }
}

解决这个问题的解决方案 运行 作为测试,是添加一个可选参数或一个带有可选参数的重载方法,强制或不急于验证并测试惰性验证在以下情况下是否正常工作渴望已停用。

请注意,这并不完美,但这是一种测试方法,适用于想要测试当提供的选项来自更新但应用程序未更新的源时如何测试急切和惰性验证的人重新启动。

如果您有任何建议、问题或想就手头的主题进行讨论,请随时使用评论部分

看来我找到了一些可以满足惰性验证寓言的东西,并且在它之上有急切的验证。请注意,IValidatableObject 与 IValidateOptions 对于急切验证没有区别,所以请使用最适合你的东西!

解决方法:

public static void AddConfigWithValidation(this IServiceCollection services, IConfiguration config)
{
    // lazy validation
    services.Configure<TestOptions>(config.GetSection(nameof(TestOptions))).AddOptions<TestOptions>().ValidateDataAnnotations();

    var model = config.GetSection(nameof(TestOptions)).Get<TestOptions>();

    // eager validation
    var validationErrors = model.Validate(new ValidationContext(model)).ToList();

    if (validationErrors.Any())
        throw new ApplicationException($"Found {validationErrors.Count} configuration error(s): {string.Join(',', validationErrors)}");
}

并且在测试方法中:

[Fact]
public void ServiceCollection_Lazy_Validation_Mock_Api_Start()
{
    var builder = new ConfigurationBuilder()
        .SetBasePath(Directory.GetCurrentDirectory())
        .AddJsonFile("settings.json", optional: false, reloadOnChange: true);

    _configuration = builder.Build();

    var opt = _configuration.GetSection(nameof(TestOptions)).Get<TestOptions>();

    _serviceCollection.AddConfigWithValidation(_configuration);

    var firstValue = _serviceCollection.BuildServiceProvider().GetRequiredService<IOptionsSnapshot<TestOptions>>().Value;

    firstValue.Should().BeEquivalentTo(opt);

    // edit the json file programmatically, trying to trigger a new IOptionsSnapshot<>
    var path = $"{Directory.GetCurrentDirectory()}\settings.json";

    var jsonString = File.ReadAllText(path);

    var concreteObject = Newtonsoft.Json.JsonConvert.DeserializeObject<TestObject>(jsonString);

    concreteObject.TestObject.Prop = string.Empty;

    File.WriteAllText(path, Newtonsoft.Json.JsonConvert.SerializeObject(concreteObject));

    _configuration = builder.Build(); // rebuild the config builder

    System.Threading.Thread.Sleep(1000); // let it propagate the change

    // error is thrown, lazy validation is triggered.
    Assert.Throws<OptionsValidationException>(() => _serviceCollection.BuildServiceProvider().GetRequiredService<IOptionsSnapshot<TestOptions>>().Value);
}

这现在可以正常工作并触发惰性验证。

请注意,我试图模仿他们的 IConfiguration 监听变化的实现,但它没有用。

为了进行急切的验证,我偶然发现了 this post on github(不能相信它,但它似乎可以解决问题)

我使用如下...

    public static IServiceCollection AddOptionsWithEagerValidation<TOptions, TOptionsValidator>(this IServiceCollection services,
            Action<TOptions> configAction,
            ILogger<ServiceCollection>? logger = default)
        where TOptions : class, new()
        where TOptionsValidator : class, IValidator, new()
    {
        services
            .AddOptions<TOptions>()
            .Configure(configAction)
            .Validate(x =>
            {
                return ValidateConfigurationOptions<TOptions, TOptionsValidator>(x, logger);
            })
            .ValidateEagerly();

        return services;
    }

我在 Configure 期间做了一些自定义的事情,然后在 Validate 期间使用 Fluent Validation 执行我自己的验证。 ValidateEagerly 导致 IStatupFilter 提前验证选项。