- Published on
What Is the Options Pattern and How to Easily Mock IOptions
- Authors
- Name
- Ivan Gechev
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:
{
"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:
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:
public interface INotificationService
{
string Notify();
}
We create the INotificationService
interface with one Notify()
method and then we implement it:
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:
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
:
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:
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:
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:
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:
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.