This is the third installment in this series:
- Introduction – The basics or what Fluent Validation is
- Part I – TDD with Fluent Validation and string validation
This time we will take a look at date validation. We will build upon the project we have been using from the beginning, so let’s get started by firing up Visual Studio, and opening our FluentValidation.Example.Solution. Add a new class to our Models project called Contract:
public class Contract
{
public DateTime ExecutionDate { get; set; }
public DateTime? ExpirationDate { get; set; }
}
In this example, our business requirements state a contract’s execution date must be before and contract’s expiration date (notice the expiration date is nullable) and both dates must be valid SQL dates - the year must be greater than 1753.
Note: If you have been following along in this series, there is a slight change that needs to be made to the ValidatorExtensions class to allow for complex type properties. Replace the existing ContainsRuleFor<T> method with the following:
public static void ContainsRuleFor<T>(this IValidator validator, Expression<Func<T, object>> propertyExpression)
{
string propertyToTest = ConvertExpressionToString(propertyExpression);
ContainsRuleFor(validator, propertyToTest);
}
private static string ConvertExpressionToString<T>(Expression<Func<T, object>> propertyExpression)
{
if (typeof(UnaryExpression).Equals(propertyExpression.Body.GetType()))
{
var operand = ((UnaryExpression)propertyExpression.Body).Operand;
return ((MemberExpression)operand).Member.Name;
}
return ((MemberExpression)propertyExpression.Body).Member.Name;
}
With that update in place, and our business rules in mind, it is time to write our first test. Add a new class to the UnitTests project called ContractValidatorTests.
[TestClass]
public class ContractValidatorTests
{
private Contract target;
private IValidator<Contract> validator;
[TestInitialize]
public void Init()
{
//arrange
target = new Contract();
validator = new ContractValidator();
}
[TestMethod]
public void ExecutionDate_LessThanJanuary11753_ValidationFails()
{
//act
ValidationResult result = validator.Validate(target);
//assert
validator.ContainsRuleFor<Contract>(x => x.ExecutionDate);
result.AssertValidationFails("Execution date is invalid.");
}
At this point, our code will not compile until we’ve added a ContractValidator class. Add a new class name ContractValidator to the Models project.
public class ContractValidator : AbstractValidator<Contract>
{
}
Our test will compile and fail as expected – so far, so good.
Now, let’s implement our first validation rule and get that test to pass. Add the following to the ContractValidator class:
public ContractValidator()
{
RuleFor(x => x.ExecutionDate)
.Must(BeAValidSqlDate)
.WithMessage("Execution date is invalid.");
}
private bool BeAValidSqlDate(DateTime date)
{
if (ReferenceEquals(date, null))
{
return true;
}
return date.Year > 1753;
}
This time we’ve introduced the Must() predicate validator. There are several ways to implement this method, but in this case, we have chosen to delegate the logic to the BeAValidSqlDate method (this may be an unusual name for a method, but it reads better in context). Let’s run our test again and it will pass. This method will only work for execution date, not expiration date as it is a nullable type. We’ll fix that shortly.
I am going to add all of our tests now, and then walk through adding the validation rules.
[TestMethod]
public void ExecutionDate_Year1753_ValidationFails()
{
//arrange
target.ExecutionDate = DateTime.Parse("1/1/1753");
//act
ValidationResult result = validator.Validate(target, x => x.ExecutionDate);
//assert
validator.ContainsRuleFor<Contract>(x => x.ExecutionDate);
result.AssertValidationFails("Execution date is invalid.");
}
[TestMethod]
public void ExecutionDate_YearGreaterThan1753_ValidationPasses()
{
//arrange
target.ExecutionDate = DateTime.Parse("1/1/1754");
//act
ValidationResult result = validator.Validate(target, x => x.ExecutionDate);
//assert
validator.ContainsRuleFor<Contract>(x => x.ExecutionDate);
result.AssertValidationPasses();
}
[TestMethod]
public void ExecutionDate_GreaterThanExpirationDate_ValidationFails()
{
//arrange
target.ExecutionDate = DateTime.Today;
target.ExpirationDate = DateTime.Today.AddDays(-1);
//act
ValidationResult result = validator.Validate(target, x => x.ExecutionDate);
//assert
validator.ContainsRuleFor<Contract>(x => x.ExecutionDate);
result.AssertValidationFails("Execution date must be before expiration date.");
}
If we run our tests now, they will fail, again following the TDD method of red-green-refactor.
We could update our rules like this:
RuleFor(x => x.ExecutionDate)
.Must(BeAValidSqlDate)
.WithMessage("Execution date is invalid.")
.LessThan(x => x.ExpirationDate.Value)
.WithMessage("Execution date must be before expiration date.");
and ordinarily our tests would pass. Give it a try. Our tests are still failing. What happened? This is because of the nullable expiration date. In order to get our tests to pass, we must create a custom rule to accommodate the nullable expiration date. Update the ContractValidator so it looks like this:
public ContractValidator()
{
RuleFor(x => x.ExecutionDate)
.Must(BeAValidSqlDate)
.WithMessage("Execution date is invalid.");
Custom(x =>
{
if (ExecutionDateGreaterThanExpirationDate(x))
{
return new ValidationFailure("ExecutionDate", "Execution date must be before expiration date.");
}
return null;
});
}
private bool ExecutionDateGreaterThanExpirationDate(Contract contract)
{
if (ReferenceEquals(contract.ExpirationDate, null))
{
return false;
}
return contract.ExecutionDate > contract.ExpirationDate.Value;
}
private bool BeAValidSqlDate(DateTime date)
{
if (ReferenceEquals(date, null))
{
return true;
}
return date.Year > 1753;
}
With these updated in place, run your tests again. Now they all pass.
Remember earlier I said we would fix the BeAValidSqlDate method so it works with nullable DateTime properties? In order to do that, we need to make another change to the code. First, we will create a custom property validator. Add a new class to the Models project called SqlDateValidator:
public class SqlDateValidator : PropertyValidator
{
public SqlDateValidator()
: base("Year must be greater than 1753")
{
}
protected override bool IsValid(PropertyValidatorContext context)
{
DateTime? date = context.PropertyValue as DateTime?;
if (ReferenceEquals(date, null))
{
return true;
}
return date.Value.Year > 1753;
}
}
Notice that this class extends the PropertyValidator base class. Now let’s add a couple of tests for the expiration date and update our ContractValidator class:
[TestMethod]
public void ExpirationDate_Year1753_ValidationFails()
{
//arrange
target.ExpirationDate = DateTime.Parse("1/1/1753");
//act
ValidationResult result = validator.Validate(target, x => x.ExpirationDate);
//assert
validator.ContainsRuleFor<Contract>(x => x.ExpirationDate);
result.AssertValidationFails("Expiration date is invalid.");
}
[TestMethod]
public void ExpirationDate_YearGreaterThan1753_ValidationPasses()
{
//arrange
target.ExpirationDate = DateTime.Parse("1/1/1754");
//act
ValidationResult result = validator.Validate(target, x => x.ExpirationDate);
//assert
validator.ContainsRuleFor<Contract>(x => x.ExpirationDate);
result.AssertValidationPasses();
}
[TestMethod]
public void ExpirationDate_IsNull_ValidationPasses()
{
//act
ValidationResult result = validator.Validate(target, x => x.ExpirationDate);
//assert
validator.ContainsRuleFor<Contract>(x => x.ExpirationDate);
result.AssertValidationPasses();
}
Run the tests to verify they fail. Now for update the ContractValidator:
public ContractValidator()
{
RuleFor(x => x.ExecutionDate)
.SetValidator(new SqlDateValidator())
.WithMessage("Execution date is invalid.");
Custom(x =>
{
if (ExecutionDateGreaterThanExpirationDate(x))
{
return new ValidationFailure("ExecutionDate", "Execution date must be before expiration date.");
}
return null;
});
RuleFor(x => x.ExpirationDate)
.SetValidator(new SqlDateValidator())
.WithMessage("Expiration date is invalid.");
}
private bool ExecutionDateGreaterThanExpirationDate(Contract contract)
{
if (ReferenceEquals(contract.ExpirationDate, null))
{
return false;
}
return contract.ExecutionDate > contract.ExpirationDate.Value;
}
We’ve simplified our ContractValidator by using the SqlDateValidator class, which accommodates nullable and non-nullable DateTime properties. We’ve also created a reusable property validator. Now all of our tests will pass.
One thing to remember about the FluentValidation library, there are many ways to achieve the same result, some are trickier than others. Making sure we have unit tests to prove our validation works allows us to try different methods and refactor the code to find the best solution without fear of breaking existing functionality.
Next time we will take a look at validation using a repository.
No comments:
Post a Comment