Customizing Comparisons in FluentAssertions with Options

November 3rd 2014 FluentAssertions Unit Testing

Unit tests best serve their purpose when they are brief and easy to understand. There are cases when it can be difficult to achieve this; nevertheless it's still worth putting in the effort, as it may pay off. Here's a test similar to the one I've recently come up with:

[Test]
public void InitialTest()
{
  // add properties which should not have their values copied
  var ignoredPropertyNames = new[] { "LastChanged" };

  var activityType = typeof(ActivityWithSettings);
  var properties = activityType
    .GetProperties(BindingFlags.DeclaredOnly | 
      BindingFlags.Public | BindingFlags.Instance)
    .Where(property => !ignoredPropertyNames.Contains(property.Name)).ToList();

  var random = new Random();
  var source = new ActivityWithSettings();
  foreach (var property in properties)
  {
    if (property.PropertyType == typeof(string))
    {
      property.SetValue(source, 
        random.Next().ToString(CultureInfo.InvariantCulture), null);
    }
    else if (property.PropertyType == typeof(int) || 
      property.PropertyType == typeof(int?))
    {
      property.SetValue(source, random.Next(), null);
    }
    else
    {
      throw new NotImplementedException(
        String.Format(
          "Test does not yet support properties of type {0} (property {1}).",
          property.PropertyType.FullName, property.Name));
    }
  }

  var destination = new ActivityWithSettings();
  destination.CopySettingsFrom(source);

  foreach (var property in properties)
  {
    Assert.AreEqual(property.GetValue(source, null), 
      property.GetValue(destination, null),
      property.Name + " should have matching values.");
  }
}

It's far from obvious, what I was trying to achieve: test whether CopySettingsFrom method copies the right subset of properties from one object to another one. Since I mostly wanted the test to catch changes of the class which didn't include a corresponding modification of the method, I used a lot of reflection to define the set of properties and initialize them.

A couple of hours later I managed to write an equivalent test, which looked like this:

[Test]
public void FinalTest()
{
    var source = Builder<ActivityWithSettings>.CreateNew().Build();

    var destination = new ActivityWithSettings();
    destination.CopySettingsFrom(source);

    destination.ShouldBeEquivalentTo(source, options => options
        .Using(new NonInheritedPublicPropertiesSelectionRule())
        .Excluding(activity => activity.LastChanged));
}

Much better, isn't it? We'll take a look at the steps I took; but first, let's look at the tested code (a simplification of my actual scenario):

public class CoreActivity
{
    public int Id { get; set; }
    public string DisplayName { get; set; }
}

public class ActivityWithSettings : CoreActivity
{
    public int DeadlinePeriod { get; set; }
    public string Category { get; set; }
    public DateTime LastChanged { get; set; }

    public void CopySettingsFrom(ActivityWithSettings sourceActivity)
    {
        DeadlinePeriod = sourceActivity.DeadlinePeriod;
        Category = sourceActivity.Category;
        LastChanged = DateTime.Now;
    }
}

Essentially, CopySettingsFrom should copy the values of properties declared on the derived class, skipping the predefined exceptions. Any additional properties, added to the derived class in the future, should by default be copied as well.

This explains the steps in my initial test:

  • A list of properties to ignore.
  • A list of properties to check, consisting of the ones declared directly on the derived class, without the ones in the ignore list.
  • A block of code initializing all the properties of the source object to non-default values.
  • Test method invocation.
  • Validation of selected property values on the destination object.

I first replaced my object initialization with NBuilder. This library can be used for preparing test data with a minimum amount of effort. In my case it was enough to use it in its simplest form; it actually offers a lot more flexibility:

[Test]
public void TestWithBuilder()
{
    // add properties which should not have their values copied
    var ignoredPropertyNames = new[] { "LastChanged" };

    var activityType = typeof(ActivityWithSettings);
    var properties = activityType
        .GetProperties(BindingFlags.DeclaredOnly | 
            BindingFlags.Public | BindingFlags.Instance)
        .Where(property => !ignoredPropertyNames.Contains(property.Name)).ToList();

    var source = Builder<ActivityWithSettings>.CreateNew().Build();

    var destination = new ActivityWithSettings();
    destination.CopySettingsFrom(source);

    foreach (var property in properties)
    {
        Assert.AreEqual(property.GetValue(source, null), 
            property.GetValue(destination, null),
            property.Name + " should have matching values.");
    }
}

With NBuilder I already replaced a lot of complex code. The key to remaining changes were FluentAssertions. I already wrote about this library, but this time I had to use more of its features. ShouldBeEquivalentTo is the method for loosely comparing objects and its second parameter can be used to further customize the comparison behavior. This was the result of my first attempt at using its capabilities:

[Test]
public void TestWithFluentAssertions()
{
    var activityType = typeof(ActivityWithSettings);
    var properties = activityType
        .GetProperties(BindingFlags.DeclaredOnly | 
            BindingFlags.Public | BindingFlags.Instance).ToList();

    var source = Builder<ActivityWithSettings>.CreateNew().Build();

    var destination = new ActivityWithSettings();
    destination.CopySettingsFrom(source);

    destination.ShouldBeEquivalentTo(source, options => options
        .Including(ctx => properties.Select(property => property.Name)
                                    .Contains(ctx.PropertyPath))
        .Excluding(activity => activity.LastChanged));
}

Most importantly, I managed to replace string based exclusion list with a strongly typed one using lambdas. I still wasn't satisfied with the approach, I had to use to only include the properties declared directly on the derived type. It's not terrible, but it was really a step back from using IncludeAllDeclaredProperties method which promised to do exactly that before I tried it out. Unfortunately its understanding of declared properties is different from BindingFlags.DecaledOnly. While the latter does exactly what I needed, the former includes the inherited properties as well.

Although the library doesn't include such a method, the same can be achieved in a much more elegant manner than I did it in my attempt above. Instead of including the property filtering logic directly in the test an ISelectionRule can be written and then reused in any test:

public class NonInheritedPublicPropertiesSelectionRule : ISelectionRule
{
    public IEnumerable<PropertyInfo> SelectProperties(
        IEnumerable<PropertyInfo> selectedProperties,
        ISubjectInfo context)
    {
        return context.CompileTimeType
            .GetProperties(BindingFlags.Instance | 
                BindingFlags.Public | BindingFlags.DeclaredOnly);
    }

    public override string ToString()
    {
        return "Include only non-inherited declared properties";
    }
}

As you can see, all the logic is now wrapped in the rule, along with a description which will show up whenever the assertion using it, fails. Instead of the Including method, Using can now be used:

[Test]
public void FinalTest()
{
    var source = Builder<ActivityWithSettings>.CreateNew().Build();

    var destination = new ActivityWithSettings();
    destination.CopySettingsFrom(source);

    destination.ShouldBeEquivalentTo(source, options => options
        .Using(new NonInheritedPublicPropertiesSelectionRule())
        .Excluding(activity => activity.LastChanged));
}

And here we are: this is how my test ended up looking. Well worth the time I put into it, if you ask me.

Get notified when a new blog post is published (usually every Friday):

If you're looking for online one-on-one mentorship on a related topic, you can find me on Codementor.
If you need a team of experienced software engineers to help you with a project, contact us at Razum.
Copyright
Creative Commons License