为单元测试填充IConfiguration

107

.NET Core配置允许添加许多选项以添加值(环境变量、json文件、命令行参数)。

我只是无法弄清楚并找到如何通过代码填充它的答案。

我正在为配置扩展方法编写单元测试,我认为通过代码在单元测试中填充它比为每个测试加载专用的json文件更容易。

我的当前代码:

[Fact]
public void Test_IsConfigured_Positive()
{

  // test against this configuration
  IConfiguration config = new ConfigurationBuilder()
    // how to populate it via code
    .Build();

  // the extension method to test
  Assert.True(config.IsConfigured());

}

更新:

特殊情况之一是“空部分”,在 json 中看起来像这样。

{
  "MySection": {
     // the existence of the section activates something triggering IsConfigured to be true but does not overwrite any default value
   }
 }
更新2: 正如马修在评论中指出的那样,json中有一个空节与根本没有该节是一样的。我举了一个例子,确实是这种情况。我原先期望会有不同的行为,但现在发现并不是这样。 那么我该怎么做?我期望什么: 我正在为IConfiguration编写2个扩展方法的单元测试(实际上是因为Get...Settings方法中的值绑定由于某些原因无法工作(但这是另一个话题)。它们看起来像这样:
public static bool IsService1Configured(this IConfiguration configuration)
{
  return configuration.GetSection("Service1").Exists();
}

public static MyService1Settings GetService1Settings(this IConfiguration configuration)
{
  if (!configuration.IsService1Configured()) return null;

  MyService1Settings settings = new MyService1Settings();
  configuration.Bind("Service1", settings);

  return settings;
}

我曾经误解,如果我在appsettings中放置一个空的部分,IsService1Configured()方法将会返回true(现在显然是错误的)。我预期的差异是,现在有了一个空的部分,GetService1Settings()方法将返回null,而不是我预期的带有所有默认值的MyService1Settings

幸运的是,这对我仍然有效,因为我不会有空的部分(或者现在我知道我必须避免这种情况)。这只是我在编写单元测试时遇到的一个理论案例。

下面是更进一步的讨论(对于那些感兴趣的人)。

我用它来做什么?基于配置的服务激活/停用。

我有一个应用程序,其中包含编译成其中的一个或多个服务。根据部署环境,我需要完全激活/停用服务。这是因为一些(本地或测试设置)没有完全访问完整的基础架构(像缓存、指标之类的辅助服务)。我通过appsettings来实现这一点。如果服务被配置了(存在配置部分),它将被添加。如果不存在配置部分,则不会使用它。


以下是精简示例的完整代码。

  • 在Visual Studio中从模板创建一个名为WebApplication1的新API(不带HTTPS和身份验证)
  • 删除Startup类和appsettings.Development.json
  • 将Program.cs中的代码替换为下面的代码
  • 现在,您可以通过添加/移除Service1Service2部分来激活/停用服务。
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using System;

namespace WebApplication1
{

  public class MyService1Settings
  {
  public int? Value1 { get; set; }
  public int Value2 { get; set; }
  public int Value3 { get; set; } = -1;
  }

  public static class Service1Extensions
  {

  public static bool IsService1Configured(this IConfiguration configuration)
  {
  return configuration.GetSection("Service1").Exists();
  }

  public static MyService1Settings GetService1Settings(this IConfiguration configuration)
  {
  if (!configuration.IsService1Configured()) return null;

  MyService1Settings settings = new MyService1Settings();
  configuration.Bind("Service1", settings);

  return settings;
  }

  public static IServiceCollection AddService1(this IServiceCollection services, IConfiguration configuration, ILogger logger)
  {

  MyService1Settings settings = configuration.GetService1Settings();

  if (settings == null) throw new Exception("loaded MyService1Settings are null (did you forget to check IsConfigured in Startup.ConfigureServices?) ");

  logger.LogAsJson(settings, "MyServiceSettings1: ");

  // do what ever needs to be done

  return services;
  }

  public static IApplicationBuilder UseService1(this IApplicationBuilder app, IConfiguration configuration, ILogger logger)
  {

  // do what ever needs to be done

  return app;
  }

  }

  public class Program
  {

    public static void Main(string[] args)
    {
      CreateWebHostBuilder(args).Build().Run();
    }

    public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
      WebHost.CreateDefaultBuilder(args)
      .ConfigureLogging
        (
        builder => 
          {
            builder.AddDebug();
            builder.AddConsole();
          }
        )
      .UseStartup<Startup>();
      }

    public class Startup
    {

      public IConfiguration Configuration { get; }
      public ILogger<Startup> Logger { get; }

      public Startup(IConfiguration configuration, ILoggerFactory loggerFactory)
      {
      Configuration = configuration;
      Logger = loggerFactory.CreateLogger<Startup>();
      }

      // This method gets called by the runtime. Use this method to add services to the container.
      public void ConfigureServices(IServiceCollection services)
      {

      // flavour 1: needs check(s) in Startup method(s) or will raise an exception
      if (Configuration.IsService1Configured()) {
      Logger.LogInformation("service 1 is activated and added");
      services.AddService1(Configuration, Logger);
      } else 
      Logger.LogInformation("service 1 is deactivated and not added");

      // flavour 2: checks are done in the extension methods and no Startup cluttering
      services.AddOptionalService2(Configuration, Logger);

      services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2);
    }

    // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
    public void Configure(IApplicationBuilder app, IHostingEnvironment env)
    {

      if (env.IsDevelopment()) app.UseDeveloperExceptionPage();

      // flavour 1: needs check(s) in Startup method(s) or will raise an exception
      if (Configuration.IsService1Configured()) {
        Logger.LogInformation("service 1 is activated and used");
        app.UseService1(Configuration, Logger); }
      else
        Logger.LogInformation("service 1 is deactivated and not used");

      // flavour 2: checks are done in the extension methods and no Startup cluttering
      app.UseOptionalService2(Configuration, Logger);

      app.UseMvc();
    }
  }

  public class MyService2Settings
  {
    public int? Value1 { get; set; }
    public int Value2 { get; set; }
    public int Value3 { get; set; } = -1;
  }

  public static class Service2Extensions
  {

  public static bool IsService2Configured(this IConfiguration configuration)
  {
    return configuration.GetSection("Service2").Exists();
  }

  public static MyService2Settings GetService2Settings(this IConfiguration configuration)
  {
    if (!configuration.IsService2Configured()) return null;

    MyService2Settings settings = new MyService2Settings();
    configuration.Bind("Service2", settings);

    return settings;
  }

  public static IServiceCollection AddOptionalService2(this IServiceCollection services, IConfiguration configuration, ILogger logger)
  {

    if (!configuration.IsService2Configured())
    {
      logger.LogInformation("service 2 is deactivated and not added");
      return services;
    }

    logger.LogInformation("service 2 is activated and added");

    MyService2Settings settings = configuration.GetService2Settings();
    if (settings == null) throw new Exception("some settings loading bug occured");

    logger.LogAsJson(settings, "MyService2Settings: ");
    // do what ever needs to be done
    return services;
  }

  public static IApplicationBuilder UseOptionalService2(this IApplicationBuilder app, IConfiguration configuration, ILogger logger)
  {

    if (!configuration.IsService2Configured())
    {
      logger.LogInformation("service 2 is deactivated and not used");
      return app;
    }

    logger.LogInformation("service 2 is activated and used");
    // do what ever needs to be done
    return app;
  }
}

  public static class LoggerExtensions
  {
    public static void LogAsJson(this ILogger logger, object obj, string prefix = null)
    {
      logger.LogInformation(prefix ?? string.Empty) + ((obj == null) ? "null" : JsonConvert.SerializeObject(obj, Formatting.Indented)));
    }
  }

}
7个回答

215
您可以使用 MemoryConfigurationBuilderExtensions 将其提供给字典。
using Microsoft.Extensions.Configuration;

var myConfiguration = new Dictionary<string, string>
{
    {"Key1", "Value1"},
    {"Nested:Key1", "NestedValue1"},
    {"Nested:Key2", "NestedValue2"}
};

var configuration = new ConfigurationBuilder()
    .AddInMemoryCollection(myConfiguration)
    .Build();

相应的JSON将会是:

{
  "Key1": "Value1",
  "Nested": {
    "Key1": "NestedValue1",
    "Key2": "NestedValue2"
  }
}
等效的环境变量将是(假设没有前缀/不区分大小写):
Key1=Value1
Nested__Key1=NestedValue1
Nested__Key2=NestedValue2

相应的命令行参数将是:

dotnet myapp.dll \
  -- \
  --Key1=Value1 \
  --Nested:Key1=NestedValue1 \
  --Nested:Key2=NestedValue2

是的,那会起作用。我更新了我的问题以反映缺失的部分。 - monty
你应该更新你的问题,包括你期望发生什么。一个空的 JSON 节点将产生与根本没有该节点相同的输出结果。 - Matthew
确实,你是正确的。一个空的部分似乎已经被删除并不存在了。我在我的问题中添加了更新2,其中包含了一个完整的例子,说明了我(错误地)期望发生的事情以及原因。 - monty
单元测试显示配置绑定失败,因为我只定义了属性的get方法而没有定义set方法。 - monty

26

我采用的解决方案(至少回答了问题标题!)是在解决方案中使用设置文件testsettings.json,并将其设置为“始终复制”。

private IConfiguration _config;

public UnitTestManager()
{
    IServiceCollection services = new ServiceCollection();

    services.AddSingleton<IConfiguration>(Configuration);
}

public IConfiguration Configuration
{
    get
    {
        if (_config == null)
        {
            var builder = new ConfigurationBuilder().AddJsonFile($"testsettings.json", optional: false);
            _config = builder.Build();
        }

        return _config;
    }
}

嗨,大家好,AddJsonFile 在 .net 5.0 方面似乎有一些修改。来源:https://learn.microsoft.com/tr-tr/dotnet/api/microsoft.extensions.configuration.jsonconfigurationextensions.addjsonfile?view=dotnet-plat-ext-5.0#Microsoft_Extensions_Configuration_JsonConfigurationExtensions_AddJsonFile_Microonsoft - Beyto
我正在尝试在VS 2022中使用.NET 6.0,但是我遇到了一个错误:“ConfigurationBuilder不包含AddJsonFile”。 - Joe
2
你需要添加以下命名空间:Microsoft.Extensions.Configuration.Json - James

5
您可以使用以下技巧来模拟IConfiguration.GetValue<T>(key)扩展方法。
var configuration = new Mock<IConfiguration>();
var configSection = new Mock<IConfigurationSection>();

configSection.Setup(x => x.Value).Returns("fake value");
configuration.Setup(x => x.GetSection("MySection")).Returns(configSection.Object);
//OR
configuration.Setup(x => x.GetSection("MySection:Value")).Returns(configSection.Object);

1
这个回答更适用于这个(已关闭)问题:https://dev59.com/2FcP5IYBdhLWcg3w6-Op 但是我没有找到其他相关主题的工作Moq方法。很多人都在询问,但你能找到的唯一答案与“ConfigurationBuilder”有关。 如果上面的问题是开放的,我会在那里发布它。 - Serj

4
< p > 这个 AddInMemoryCollection 扩展方法有用吗?< /p > < p > 你可以将一个键值集合传递给它:IEnumerable<KeyValuePair<String,String>>,包含你在测试中需要的数据。< /p >
var builder = new ConfigurationBuilder();

builder.AddInMemoryCollection(new Dictionary<string, string>
{
     { "key", "value" }
});

3
我更喜欢我的应用程序类不依赖于IConfiguration。相反,我创建一个配置类来保存配置,并使用构造函数从IConfiguration初始化它,就像这样:
public class WidgetProcessorConfig
{
    public int QueueLength { get; set; }
    public WidgetProcessorConfig(IConfiguration configuration)
    {
        configuration.Bind("WidgetProcessor", this);
    }
    public WidgetProcessorConfig() { }
}

然后在你的ConfigureServices中,你只需要这样做:

services.AddSingleton<WidgetProcessorConfig>();
services.AddSingleton<WidgetProcessor>();

并且用于测试:

var config = new WidgetProcessorConfig
{
    QueueLength = 18
};
var widgetProcessor = new WidgetProcessor(config);

当应用程序类依赖于IConfiguration时,这肯定是一种不好的做法。但是,一旦配置变得复杂,仅仅进行绑定就不足够了。我为每个配置类实现了一个验证系统,在启动时引发错误。而且,这段代码需要进行单元测试。 :-) - monty
这会在配置源(例如json文件)更改时刷新设置吗?由于它是单例,我猜不会。我想,将其作用域化会解决这个问题,但从性能方面来看,这并不是很好。我猜这就是为什么存在这些IOptionsMonitorIOptionsSnapshot接口的原因,但它们在单元测试中很难模拟。因此,在易用性和性能之间需要做出选择... - Vincent Sels
据我所知,当应用程序启动时,iConfiguration 只会从文件中加载一次,因此将其作用域化并没有帮助。但是,如果它是一个 Web 应用程序,我相信当 appsettings.json 文件在磁盘上发生更改时,Web 服务器将自动重新加载应用程序,因此它应该可以工作。 - Andy

2
我会这样做:
IConfiguration config = new ConfigurationBuilder()
    .AddJsonFile("appsettings.Development.json")    
    .Build();

using var services = new ServiceCollection()
                .AddSingleton<IConfiguration>(config)
               // -> add your DI needs here
                .BuildServiceProvider();

或者当您有一些自定义依赖项需要使用自己的扩展方法进行注入时,例如 RegisterUseCases()

IConfiguration config = new ConfigurationBuilder()
    .AddJsonFile("appsettings.Development.json")    
    .Build();

using var services = new ServiceCollection()
                .RegisterUseCases()//-> Testing the use case that uses the IConfiguration
                .AddSingleton<IConfiguration>(config)
                .BuildServiceProvider();


var systemUnderTest= services.GetRequiredService<IMyConfigClass>();
... 

现在,您可以测试依赖于IConfiguration的类。

什么是IMyConfigClass?我需要在TestMethod(MSTest项目)中使用appsettings.json(IOptions部分)。 - Kiquenet
@Kiquenet,该接口只是使用IConfiguration的DI示例。 - Walter Verhoeven
@Kiquenet,你可以使用appsettings.json,或者使用.AddJsonFile(path)扩展方法加载任何json文件。 - Walter Verhoeven

0

添加InMemory json配置的数组示例:

using Microsoft.Extensions.Configuration;

var myConfiguration = new Dictionary<string, string>
{
   {"ArrayKeySample:0", "valueA"},
   {"ArrayKeySample:1", "valueB"},
   {"ArrayKeySample:2", "valueC"}
};

var configuration = new ConfigurationBuilder()
    .AddInMemoryCollection(myConfiguration)
    .Build();

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接