Published on

What Is the Options Pattern and How to Easily Mock IOptions

8 min read
Authors
  • avatar
    Name
    Ivan Gechev
    Twitter

Having the ability to separate the settings (configurations) for different parts of our applications is a vital part of software development. So, in this article, we will explore the options pattern and how it helps us to achieve this separation. We will also take a look and how we can validate our settings as well as mock or create them for the various test cases of our applications.

What Is the Options Pattern?

With the options pattern, we use custom classes to provide strongly-typed access to different sets of related application settings. When we isolate and separate configuration settings by use cases in separate classes, we adhere to the separation of concerns principle - the settings for different parts of our application are independent and don't rely on each other.

The way we can access the options is via the IOptions<TOptions> interface where TOptions is a class we create. The IOptions<TOptions> is then injected where it is needed through dependency injection, we will see how this is done a bit later in the article.

Let's imagine the following configuration file:

appsettings.json
{
  "NotificationOptions": {
    "Sender": "Admin",
    "MaxLength": 150,
    "PlaySound": true
  }
}

Here we have a configuration section called NotificationOptions and it stores three individual values - Sender, MaxLength, and PlaySound.

Now, let's create our custom class that will hold those settings:

NotificationOptions.cs
public class NotificationOptions
{
    public string Sender { get; set; }
    public int MaxLength { get; set; }
    public bool PlaySound { get; set; }
}

We create the NotificationOptions class with its three properties that mirror our identically named configuration section and its values.

We need somewhere to inject the NotificationOptions class:

INotificationService.cs
public interface INotificationService
{
    string Notify();
}

We create the INotificationService interface with one Notify() method and then we implement it:

NotificationService.cs
public class NotificationService : INotificationService
{
    private readonly IOptions<NotificationOptions> _options;

    public NotificationService(IOptions<NotificationOptions> options)
    {
        _options = options;
    }

    public string Notify()
        => $"A notification was sent by {_options.Value.Sender} with a max text length of {_options.Value.MaxLength} characters and a play sound set to: {_options.Value.PlaySound}";
}

We create a new NotificationService class that implements our interface and inject IOptions<NotificationOptions> in the constructor. For the Notify() method we return a string that shows the values in our NotificationOptions class. To access those values we use the _options field, then its Value property (which accesses our NotificationOptions instance), and then we call the separate properties by name.

Then we create an endpoint using minimal APIs:

Program.cs
app.MapGet("/notify", (INotificationService notificationService) =>
{
    return Results.Ok(notificationService.Notify());
});

We create a GET endpoint with an address of /notify. We inject the INotificationService and return the results of the Notify() method wrapped in a 200 OK object result.

And finally, we register our NotificationOptions:

Program.cs
builder.Services.AddOptions<NotificationOptions>()
    .BindConfiguration(nameof(NotificationOptions));

We use the AddOptions<TOptions> method, where TOptions is our NotificationOptions class to add the options to the dependency inversion container. We also use the BindConfiguration() method to specify the name of our configuration section.

How to Add Validation to the Options Pattern?

While the above implementation of the options pattern works just fine, it still leaves room for errors. Most common cases are using incorrect names of either the configuration section or the class and its properties. We can also get incompatible or missing values. In this section, we will see what we can do to prevent any unwanted exceptions from creeping into our application.

We can start by adding some attributes:

NotificationOptions.cs
public class NotificationOptions
{
    [Required]
    [StringLength(10)]
    public string Sender { get; set; }
    [Required]
    public int MaxLength { get; set; }
    [Required]
    public bool PlaySound { get; set; }
}

In our NotificationOptions class, we add the Required attribute to all mandatory properties. We also decorate the Sender property with the StringLength(10) attribute, which sets the maximum length of the property's value to 10.

Then in our Program class, we update how we register our options:

Program.cs
builder.Services.AddOptions<NotificationOptions>()
    .BindConfiguration(nameof(NotificationOptions))
    .ValidateDataAnnotations()
    .ValidateOnStart();

We add the ValidateDataAnnotations() methods which will check the attributes of the options class and make sure our configuration values adhere to them. If any attributes are not adhered to, we will get an exception message when we try to access a given value.

Exceptions while our application is running might not be ideal so we add the ValidateOnStart() method, which will validate our options when our application is started and throw any exceptions at that point.

We can also have custom validations:

Program.cs
builder.Services.AddOptions<NotificationOptions>()
    .BindConfiguration(nameof(NotificationOptions))
    .ValidateDataAnnotations()
    .Validate(options =>
    {
        if (options.MaxLength <= 50)
        {
            return false;
        }

        return true;
    })
    .ValidateOnStart();

We can add the Validate() method and add some custom validation logic as well. In our case, we add a condition that returns false if the MaxLength value is less or equal to 50 and true if is not. This comes in handy when we have a lot of custom conditions for our application's configuration values.

Mocking IOptions for Unit Tests

Unit testing is paramount for any application so we need a way to make sure we know the best way to mock IOptions for our tests.

Let's see how this is done:

NotificationServiceTests.cs
public class NotificationServiceTests
{
    private const string Sender = "SomeName";
    private const int MaxLength = 100;
    private const bool PlaySound = false;

    private readonly NotificationService _notificationService;

    public NotificationServiceTests()
    {
        var options = Options.Create(
            new NotificationOptions
            {
                Sender = Sender,
                MaxLength = MaxLength,
                PlaySound = PlaySound
            });

        _notificationService = new NotificationService(options);
    }
}

We create the NotificationServiceTests and add some constants to it as well as a field to hold our NotificationService instance. Inside the constructor, we use the Options class and its Create() method to create an instance or our NotificationOptions class. For its properties, we use the constants we defined. Then, we use the options variable to create our NotificationService. And this is all we need to not mock, but create our options class for testing purposes. Then we can move on and write our test cases.

Conclusion

In conclusion, the options pattern is a powerful tool in software development, allowing us to cleanly separate and manage application settings. It adheres to the principle of separation of concerns, making settings for different parts of our application independent. By using strongly-typed classes and the IOptions<TOptions> interface, we can access these settings with ease. We've seen how to define and validate these options, preventing potential errors. Moreover, for unit testing, we've explored how to create and use IOptions instances without the need for complex mocking.