Unit Testing Validation of Annotated Models
It’s no secret that I like to unit test my code. The more I can decouple from dependencies and isolate, the faster I can work. Annotating my request objects incoming from API endpoints to ensure I get valid objects to work with (and the consumer gets instant feedback should they supply an invalid request object) is no exception to this rule. The validation mechanisms the DataAnnotations
library uses is freely available to call ourselves, which is a big boon. Invoking the ValidationContext
class with our object to validate gives us all the errors we’d see when calling the real thing. Right?
ASP.NET Core MVC Enforces Non-Nullable Rule
Not so! With the recent ability added to .NET to require nullable types to be explicitly defined, e.g. string? foo
instead of string foo
, MVC does a little extra lifting that’s beyond the scope of what DataAnnotations
does on its own! Say we have a simple CreateUserRequest
, and pre-nullable types, we defined it like this.
public class CreateUserRequest
{
[Required]
public string Username { get; set; }
[Required]
public string Password { get; set; }
[Required]
[EmailAddress]
public string Email { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public int? Age { get; set; }
}
We require the details needed to create a new User, thus marking those properties with [Required]
, but the others are all optionally-supplied. However, as of ASP.NET Core 3.1, non-nullable reference types become implicitly required by default! If we unit tested this before, our tests would still pass, but when hitting our Create endpoint, we’d be (incorrectly) told FirstName
and LastName
are required!
The Secret Modification to Your Models
In actuality, this is what our request model actually looks like in terms of validation:
public class CreateUserRequest
{
[Required]
public string Username { get; set; }
[Required]
public string Password { get; set; }
[Required]
[EmailAddress]
public string Email { get; set; }
[Required(AllowEmptyStrings = true)]
public string FirstName { get; set; }
[Required(AllowEmptyStrings = true)]
public string LastName { get; set; }
public int? Age { get; set; }
}
Any non-nullable reference type, so that they won’t get set to null
at any point (which is an important contract to maintain in code that only expects T?
reference types to be null), gets [Required(AllowEmptyStrings = true)]
added to it. This inferred required-ness can be disabled in your application’s startup, and is up to your team whether to do so or not. Personally, I like it being enabled (because null
is a big-time bug breeder, and in a code base where nullability is explicit, string foo
should never be null), but there is also merit to forcing your models to be explicitly marked [Required]
.
Aside: Importance of Functional Testing
Unit tests are an incredible tool, but they should not be the one and only kind of testing that you do. Had we written a single functional test along the lines of “when I hit the Create endpoint with a valid CreateUserRequest
instance, a User is created successfully”, then the request would’ve bounced back as a BadRequest
identifying the problem instantly. But if we only had unit tests, this problem would not have been noticed for a while. If we were really unlucky, a customer would be the one that noticed it first. Whoops!
Adjusting Our Unit Tests to Accurately Reflect Implicit Required Attributes
Time for fun! Let’s see what our unit tests for our CreateUserRequest
model looks like.
using System.ComponentModel.DataAnnotations;
using FluentAssertions;
using MyApp.Core;
using Xunit;
namespace MyApp.UnitTests;
public class CreateUserRequestValidation
{
private readonly DataAnnotationModelValidator _validator = new();
[Fact]
public void ValidatesGivenValidInstance()
{
CreateUserRequest req = new CreateUserRequestBuilder()
.WithValidValues()
.Build();
List<ValidationResult> validationResults = _validator.ValidateModel(req);
validationResults.Should().BeEmpty();
}
}
Our test code relies on a couple things. First is an example of the Builder Pattern in action, to construct a valid instance of CreateUserRequest
. (This same builder can be used in the functional test we mentioned earlier! We’d extract it to some project that all of our test projects can reference.)
using MyApp.Core;
namespace MyApp.UnitTests.Builders;
public class CreateUserRequestBuilder
{
private string _username;
private string _password;
private string _email;
private string _firstName;
private string _lastName;
private int? _age;
public CreateUserRequestBuilder WithValidValues()
{
_username = "JonDoe123";
_password = "SecureP@$$w0rd";
_email = "JonDoe@example.com";
return this;
}
public CreateUserRequest Build()
{
return new()
{
Username = _username,
Password = _password,
Email = _email,
FirstName = _firstName,
LastName = _lastName,
Age = _age
};
}
}
You may already see the flaw in this pre-nullable types implementation of WithValidValues()
, but first let’s show our hook into the same validation that DataAnnotations
uses.
using System.ComponentModel.DataAnnotations;
using Ardalis.GuardClauses;
namespace MyApp.UnitTests;
public class DataAnnotationModelValidator
{
public List<ValidationResult> ValidateModel<T>(T model)
where T : class
{
Guard.Against.Null(model);
var validationResults = new List<ValidationResult>();
var validationContext = new ValidationContext(model, null, null);
Validator.TryValidateObject(model, validationContext, validationResults, true);
return validationResults;
}
}
In our example application MyApp
before nullable reference types were a thing, this test passed. And in a post-nullable-reference-type world… it still passes, but incorrectly! With the inference going on, this test should be telling us a couple properties are required.
To overcome this deficiency, I added a method that simulates what MVC is doing. Good thing we have a separate class our tests use that we can easily slot this in!
using System.ComponentModel.DataAnnotations;
using System.Reflection;
using Ardalis.GuardClauses;
namespace MyApp.UnitTests;
public class DataAnnotationModelValidator
{
private readonly bool _inferRequiredForNonNullableReferenceTypes;
public DataAnnotationModelValidator(bool inferRequiredForNonNullableReferenceTypes = true)
{
_inferRequiredForNonNullableReferenceTypes = inferRequiredForNonNullableReferenceTypes;
}
public List<ValidationResult> ValidateModel<T>(T model)
where T : class
{
Guard.Against.Null(model);
var validationResults = new List<ValidationResult>();
var validationContext = new ValidationContext(model, null, null);
Validator.TryValidateObject(model, validationContext, validationResults, true);
if (_inferRequiredForNonNullableReferenceTypes)
{
CheckInferredRequiredProperties(model, validationResults);
}
return validationResults;
}
private static void CheckInferredRequiredProperties<T>(T model, List<ValidationResult> validationResults)
{
Guard.Against.Null(model, nameof(model));
Guard.Against.Null(validationResults);
var nullabilityInfoContext = new NullabilityInfoContext();
var inferredValidationResults = model.GetType().GetProperties(BindingFlags.Instance | BindingFlags.Public)
.Where(p => p.PropertyType.IsClass && p.GetValue(model) == null
&& !Attribute.IsDefined(p, typeof(RequiredAttribute))
&& nullabilityInfoContext.Create(p).WriteState is NullabilityState.NotNull)
.Select(prop => new ValidationResult($"Property {prop.Name} is a non-nullable reference type and cannot be null"));
validationResults.AddRange(inferredValidationResults);
}
}
Now our validator has the capability to infer required properties! (It’s optional too, as can be seen by the constructor parameter inferRequiredForNonNullableReferenceTypes
.) I added a method CheckInferredRequiredProperties()
which ValidateModel
calls, so no changes are required to our test. Within this new method I pull off a little reflection voodoo to pretend that non-nullable reference type properties have [Required(AllowEmptyStrings = true)]
stapled to them. We get all public instance properties that:
- are reference types (i.e.
IsClass
is true) - whose values are null
- who do not already have a
[Required]
attribute - that are not nullable (e.g.
string
instead ofstring?
)
Any properties matching these criteria are added to the collection of validation results. When we re-run our unit test, it now correctly fails, pointing out the problematic “we thought they were optional” fields and why they are considered required.
Expected validationResults to be empty, but found {Property FirstName is a non-nullable reference type and cannot be null, Property LastName is a non-...
Xunit.Sdk.XunitException
Expected validationResults to be empty, but found {Property FirstName is a non-nullable reference type and cannot be null, Property LastName is a non-nullable reference type and cannot be null}.
at FluentAssertions.Execution.XUnit2TestFramework.Throw(String message)
at FluentAssertions.Execution.TestFrameworkProvider.Throw(String message)
at FluentAssertions.Execution.DefaultAssertionStrategy.HandleFailure(String message)
at FluentAssertions.Execution.AssertionScope.FailWith(Func`1 failReasonFunc)
at FluentAssertions.Execution.AssertionScope.FailWith(Func`1 failReasonFunc)
at FluentAssertions.Execution.AssertionScope.FailWith(String message, Object[] args)
at FluentAssertions.Execution.GivenSelector`1.FailWith(String message, Object[] args)
at FluentAssertions.Collections.GenericCollectionAssertions`3.BeEmpty(String because, Object[] becauseArgs)
at MyApp.UnitTests.CreateUserRequestValidation.ValidatesGivenValidInstance() in C:\dev\MyApp\src\MyApp.UnitTests\CreateUserRequestValidation.cs:line 27
Let’s update our CreateUserRequest
to accurately reflect what’s optional. Note that are not removing the [Required]
attribute from our other non-nullable properties. For one thing, we definitely do not want to allow empty strings in them. For another, it’s good to explicitly call out what’s required in your models, so that it’s obvious to anyone viewing your code at a glance what is and isn’t required. Best of both worlds!
public class CreateUserRequest
{
// Snip
public string? FirstName { get; set; }
public string? LastName { get; set; }
public int? Age { get; set; }
}
Don’t forget CreateUserRequestBuilder
! It should be maintained to accurately reflect the model.
public class CreateUserRequestBuilder
{
// Snip
private string? _firstName;
private string? _lastName;
private int? _age;
// Snip
}
Now our test passes, and for the correct reason. We did it! With our base “valid object instance should have no errors” test working again we can test the error cases to our heart’s content, expanding our builder as needed, making sure empty strings are still allowed in the name fields, and so on. (If this test class had actually existed outside of this blog post, those tests likely would already be there — and would still be passing!)
Wrap-Up
The gotcha of inferred model validation requirements can sneak up on you. If you’re relying on explicit definitions only in particular, and happen to upgrade your application to use nullable typing, your unit tests need to be buffed up to simulate the new behavior! It wasn’t the most intuitive for me to discover what all was going on (and I’d assumed incorrectly that DataAnnotations
itself would have adjusted its ValidationContext
to mirror the new behavior; I was wrong). When I did, though, I enhanced my own test validation class to accommodate the new behavior (and added the flexibility to still maintain old behavior, if our application decided to do the same in its application startup code). We also got to see here how important it is to call your real code in a real way (through functional or integration testing): You can have 1000s of perfect unit tests, but at the end of the day your customers are going to be running the real code, and you should exercise that. With the inference correctly simulated, we can continue unit (and functional) testing our models to thoroughly exercise them.