DataAnnotations and Non-Nullable Required Inference

December 14, 2022#Software Development
Article
Author image.

Scott DePouw, Senior Consultant

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 of string?)

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.


Copyright © 2024 NimblePros - All Rights Reserved