Thursday, May 10, 2018

Object Casting, Generic Arguments, and XUnit Comparers

A few years ago I wrote two separate posts on object casting and generic type parameters. It's hard to know for sure, but I suspect I was dealing with an issue then that came up again this week. And while those old posts did help me eventually get to the right answer, I had to do quite a bit more digging and toying to get what I needed this time. I figured it was time for an update and a more robust code sample that actually shows the whole solution.

Let me start by saying that I ultimately used this SO answer from Mariusz Pawelski to put everything together.

I also came upon this based on a previous answer I had provided on SO for equality checking in XUnit. When I wrote that answer I was only concerned with comparing the values of properties of single instances of objects. Take the Person class:
    1:  public class Person
    2:  {
    3:      public string FirstName { get; set; }
    4:  
    5:      public string LastName { get; set; }
    6:  }
I created a comparer that would evaluate whether two instances of Person had the same values for FirstName and LastName, and that worked just fine. What I needed recently was the ability to compare two lists to each other. Using the same comparer seemed to work, except that it didn't actually work. What was happening was that my comparer checked for properties, didn't find any, so returned true. I needed a way to iterate through a list of objects and compare the value of each property in each position in the first list to the value of the same property on the object in the same position of another list. That led me to this:
    1:  public class CustomEnumerableComparer<T> : IEqualityComparer<IEnumerable<T>>
    2:  {
    3:      public bool Equals(IEnumerable<T> expectedEnumerable, IEnumerable<T> actualEnumerable)
    4:      {
    5:          var expected = expectedEnumerable.ToList();
    6:          var actual = actualEnumerable.ToList();
    7:  
    8:          if (expected.Count != actual.Count)
    9:          {
   10:              throw new EqualException($"{expected.Count} items, $"{actual.Count} items");
   11:          }
   12:  
   13:          if (expected.Count == 0)
   14:          {
   15:              return true;
   16:          }
   17:  
   18:          var props = typeof(T).GetProperties(BindingFlags.DeclaredOnly | BindingFlags.Public | BindingFlags.Instance);
   19:  
   20:          for (var i = 0; i < expected.Count; i++)
   21:          {
   22:              foreach (var prop in props)
   23:              {
   24:                  var expectedValue = prop.GetValue(expected[i], null);
   25:                  var actualValue = prop.GetValue(actual[i], null);
   26:  
   27:                  if (expectedValue == null)
   28:                  {
   29:                      if (actualValue == null)
   30:                      {
   31:                          continue;
   32:                      }
   33:  
   34:                      throw new EqualException($"A value of null for property \"{prop.Name}\"",
   35:                          $"A value of \"{actualValue}\" for property \"{prop.Name}\"");
   36:                  }
   37:  
   38:                  if (actualValue == null)
   39:                  {
   40:                      throw new EqualException($"A value of \"{expectedValue}\" for property \"{prop.Name}\"",
   41:                          $"A value of null for property \"{prop.Name}\"");
   42:                  }
   43:  
   44:                  if (!expectedValue.Equals(actualValue))
   45:                  {
   46:                      throw new EqualException($"A value of \"{expectedValue}\" for property \"{prop.Name}\"",
   47:                          $"A value of \"{actualValue}\" for property \"{prop.Name}\"");
   48:                  }
   49:              }
   50:          }
   51:  
   52:          return true;
   53:      }
   54:  
   55:      public int GetHashCode(IEnumerable<T> parameterValue)
   56:      {
   57:          return Tuple.Create(parameterValue).GetHashCode();
   58:      }
   59:  }

This works perfectly! It works perfectly for a shallow equality check between two lists, that is. So if I was just comparing two lists of Person objects I'd be fine. However, if the Person class was changed to look like this:
    1:  public class Person
    2:  {
    3:      public List<Address> Addresses { get; set; }
    4:  
    5:      public string FirstName { get; set; }
    6:  
    6:      public string LastName { get; set; }
    7:  }

where the Address class looks like this:
    1:  public class Address
    2:  {
    3:      public string City { get; set; }
    4:  
    5:      public string Street1 { get; set; }
    6:  
    6:      public string Street2 { get; set; }
    7:  }

You can see that my comparer wouldn't work as I expect (again). That's because when it encountered the Addresses property, it'd do the same comparison it did before - it would check for properties on the List<> property, find none, and return true. I needed a way to recursively check equality on nested objects. That led me to the aforementioned SO answer as well as my own previous posts.

The first thing I did was move all of the code out of Equals into a private method called AreEqual with nearly the same method signature. Here's the signature only. I'll post all of the code at the very end.
    1:  private bool AreEqual<TNested>(IEnumerable<TNested> expectedEnumerable, IEnumerable<TNested> actualEnumerable)

Now that I have that separated out, I can recursively call it (that means call the AreEqual method from within the AreEqual method). The only thing I had left to do was determine when to do a recursive call and when to do a straight equality check. Using this other answer I found on SO (I spend a lot of time there some days) I was able to include some useful extension methods to determine whether my property was an enumerable.

At this point, I can do a deep equality check on a list and recursively do deep equality checks on properties of those objects in the list that are also lists themselves, but I still have one problem: How do I invoke the AreEqual argument from within itself given that the types of the nested lists aren't likely to be the same as their containing objects? Since AreEqual accepts a generic type parameter, I can invoke it like AreEqual when I know the type at compile-time, but it's a bit trickier when I won't know the type until run-time. That's where the last bit finally comes into play. This is the piece I added right in the middle of AreEqual to recursively call AreEqual with a dynamic generic type (again, I'll post the full comparer at the end).
   44:                  if(prop.IsNonStringEnumerable())
   45:                  {
   46:                      // get the type of the object in the enumerable
   47:                      var objectType = prop.PropertyType.GetGenericArguments()[0];
   48:                      // create a List
   49:                      var genericListType = typeof(List<>).MakeGenericType(objectType);
   50:                      // instantiate lists with the values in the enumerables
   51:                      var expectedList = (IList)Activator.CreateInstance(genericListType, expectedValue);
   52:                      var actualList = (IList)Activator.CreateInstance(genericListType, actualValue);
   53:                      AreEqual((dynamic)expectedList, (dynamic)actualList);
   54:                  }

We're not using the return value of AreEqual when we call it recursively because we've nested the throw statements in there so if we encounter a situation where two values (regardless of their depth) are not equal and they should be, processing will stop immediately.

OK, that was a long way to go to learn something you probably didn't even mean to read this post about (the post kinda turned into how to compare two values in XUnit). Thanks for sticking with me. Here's the full CustomEnumerableComparer class:
    1:  public class Person
    2:  {
    3:      public string FirstName { get; set; }
    4:  
    5:      public string LastName { get; set; }
    6:  }
I created a comparer that would evaluate whether two instances of Person had the same values for FirstName and LastName, and that worked just fine. What I needed recently was the ability to compare two lists to each other. Using the same comparer seemed to work, except that it didn't actually work. What was happening was that my comparer checked for properties, didn't find any, so returned true. I needed a way to iterate through a list of objects and compare the value of each property in each position in the first list to the value of the same property on the object in the same position of another list. That led me to this:
    1:  public class CustomEnumerableComparer<T> : IEqualityComparer<IEnumerable<T>>
    2:  {
    3:      public bool Equals(IEnumerable<T> expectedEnumerable, IEnumerable<T> actualEnumerable)
    4:      {
    5:          return AreEqual(expectedEnumerable, actualEnumerable);
    6:      }
    7:  
    8:      public int GetHashCode(IEnumerable<T> parameterValue)
    9:      {
   10:          return Tuple.Create(parameterValue).GetHashCode();
   11:      }
   12:  
   13:      private bool AreEqual<TNested>(IEnumerable<TNested> expectedEnumerable, IEnumerable<TNested> actualEnumerable)
   14:      {
   15:          var expected = expectedEnumerable.ToList();
   16:          var actual = actualEnumerable.ToList();
   17:  
   18:          if (expected.Count != actual.Count)
   19:          {
   20:              throw new EqualException($"{expected.Count} items, $"{actual.Count} items");
   21:          }
   22:  
   23:          if (expected.Count == 0)
   24:          {
   25:              return true;
   26:          }
   27:  
   28:          var props = typeof(T).GetProperties(BindingFlags.DeclaredOnly | BindingFlags.Public | BindingFlags.Instance);
   29:  
   30:          for (var i = 0; i < expected.Count; i++)
   31:          {
   32:              foreach (var prop in props)
   33:              {
   34:                  var expectedValue = prop.GetValue(expected[i], null);
   35:                  var actualValue = prop.GetValue(actual[i], null);
   36:  
   37:                  if (expectedValue == null)
   38:                  {
   39:                      if (actualValue == null)
   40:                      {
   41:                          continue;
   42:                      }
   43:  
   44:                      throw new EqualException($"A value of null for property \"{prop.Name}\"",
   45:                          $"A value of \"{actualValue}\" for property \"{prop.Name}\"");
   46:                  }
   47:  
   48:                  if (actualValue == null)
   49:                  {
   50:                      throw new EqualException($"A value of \"{expectedValue}\" for property \"{prop.Name}\"",
   51:                          $"A value of null for property \"{prop.Name}\"");
   52:                  }
   53:  
   54:                  if (prop.IsNonStringEnumerable())
   55:                  {
   56:                      // get the type of the object in the enumerable
   57:                      var objectType = prop.PropertyType.GetGenericArguments()[0];
   58:                      // create a List
   59:                      var genericListType = typeof(List<>).MakeGenericType(objectType);
   60:                      // instantiate lists with the values in the enumerables
   61:                      var expectedList = (IList)Activator.CreateInstance(genericListType, expectedValue);
   62:                      var actualList = (IList)Activator.CreateInstance(genericListType, actualValue);
   63:                      AreEqual((dynamic)expectedList, (dynamic)actualList);
   64:                  }
   65:                  else if (!expectedValue.Equals(actualValue))
   66:                  {
   67:                      throw new EqualException($"A value of \"{expectedValue}\" for property \"{prop.Name}\"",
   68:                          $"A value of \"{actualValue}\" for property \"{prop.Name}\"");
   69:                  }
   70:              }
   71:          }
   72:  
   73:          return true;
   74:      }
   75:  }




No comments:

Post a Comment