June 10, 2020

Testing IOptionsMonitor

IOptionsMonitor is a feature of the .NET Core Configuration system that allows you to access and subscribe to changes in configuration options at runtime.

The code below demonstrates how you can use IOptionsMonitor in your application to access configuration options and apply any changes as they occur:

/// <summary>
/// Service discovery that gets service configurations from app settings
/// </summary>
public class ServiceDiscovery : IServiceDiscovery
{
    private Dictionary<string, ServiceConfiguration> _services;
    
    public ServiceDiscovery(IOptionsMonitor<PaymentsOptions> options)
    {
        options?.CurrentValue?.Services.ThrowIfNull("services");
        options.OnChange(newValue => _services = Bind(newValue.Services));
        _services = Bind(options.CurrentValue.Services);
    }
    
    public ValueTask<ServiceConfiguration> DiscoverServiceAsync(string serviceKey)
    {
        serviceKey.ThrowIfNullOrWhiteSpace(nameof(serviceKey));
        _services.TryGetValue(serviceKey, out ServiceConfiguration service);
        return new ValueTask<ServiceConfiguration>(service);
    }

    private static Dictionary<string, ServiceConfiguration> Bind(IDictionary<string, string> services)
        => services.ToDictionary(s => s.Key, s => new ServiceConfiguration(s.Key, s.Value, true));
}

You subscribe to changes using the OnChange method, providing a callback for the new value.

Testing IOptionsMonitor

To test code that uses IOptionsMonitor use a stub implementation that can trigger the consuming code’s callback function when required:

public class TestOptionsMonitor<TOptions> : IOptionsMonitor<TOptions>
{
    private Action<TOptions, string> _listener;

    public TestOptionsMonitor(TOptions currentValue)
    {
        CurrentValue = currentValue;
    }

    public TOptions CurrentValue { get; private set; }

    public TOptions Get(string name)
    {
        return CurrentValue;
    }

    public void Set(TOptions value)
    {
        CurrentValue = value;
        _listener.Invoke(value, null);
    }

    public IDisposable OnChange(Action<TOptions, string> listener)
    {
        _listener = listener;
        return Mock.Of<IDisposable>();
    }
}

The test class below (using nspec) demonstrates how this can be used:

public class describe_ServiceDiscovery : nspec
{
    ServiceDiscovery _serviceDiscovery;
    TestOptionsMonitor<PaymentsOptions> _options;

    void before_each()
    {
        _options = new TestOptionsMonitor<PaymentsOptions>(new PaymentsOptions
        {
            Services = new Dictionary<string, string>()
        });

        _serviceDiscovery = new ServiceDiscovery(_options);
    }

    void describe_discovery()
    {
        ServiceConfiguration result = default;
        string targetKey = "target-service";

        actAsync = async () => result = await _serviceDiscovery.DiscoverServiceAsync(targetKey);

        context["given no services exist"] = () =>
        {
            it["returns null"] = () => result.ShouldBeNull();
        };

        context["given no matching services exists"] = () =>
        {
            before = () => _options.Set(new PaymentsOptions { Services = new Dictionary<string, string> { { "other-service", "http://foo.com" } } });

            it["returns null"] = () => result.ShouldBeNull();
        };

        context["given a matching services exists"] = () =>
        {
            before = () => _options.Set(new PaymentsOptions { Services = new Dictionary<string, string> { { targetKey, "http://target.com" } } });

            it["returns the service"] = () =>
            {
                result.ShouldNotBeNull();
                result.Id.ShouldBe(targetKey);
                result.Uri.ShouldBe("http://target.com");
            };
        };
    }
}

© 2022 Ben Foster