Any class or structure in .NET can define the equality and inequality operators. Implementing such operators, however, can be error-prone. The operators therefore need to be validated by unit tests. We will first discuss the challenges of writing such unit tests. We will then review how just one new NUnit constraint can help writing thorough tests for the equality and inequality operators.

The implementations of the equality and inequality operators are presented below for a simple Customer class: two customers are expected to be equal if they have the same name. Refer to the Microsoft guidelines for further details.

class Customer
{
    private readonly string name;

    public Customer(string name)
    {
        if (name == null) { 
           throw new ArgumentNullException();
        } 
        this.name = name;
    }

    public string Name
    {
        get
        {
            return name;
        }
    }

    public override bool Equals(object obj)
    {
        Customer customer = obj as Customer;
        if (customer != null)
        {
            return this == customer;
        }
        else
        {
            return false;
        }
    }

    public override int GetHashCode()
    {
        return Name.GetHashCode();
    }

    public static bool operator ==(Customer lhs, Customer rhs)
    {
        if (object.ReferenceEquals(lhs, rhs))
        {
            return true;
        }
        else if ((object)lhs == null || (object)rhs == null)
        {
            return false;
        }
        else
        {
            return lhs.Name == rhs.Name;
        }
    }

    public static bool operator !=(Customer lhs, Customer rhs)
    {
        return !(lhs == rhs);
    }
}

Such implementations typically use lhs and rhs as names for the parameters of the equality and inequality operators. lhs is the abbreviation for left-hand side. Similarly, rhs stands for right-hand side. For the remaining of this discussion, equality operators will refer to both the equality and inequality operators.

Semantics of the Equality Operators

Equality operators allow to express equality relations between two objects using a more natural syntax than the overridden Equals method inherited from object, the base type of the .NET run-time. Fully supporting equality and inequality relations requires to respect three semantics: reflexivity, symmetry, and transitivity. Testing the semantics of the operators can be done with NUnit constraints. Given any references c1, c2, and c3 of type Customer, the equality operators should be:

  • reflexive.
    Assert.That(c1 == c1, Is.True);
    Assert.That(c1 != c1,  Is.False);
  • symmetric.
    Assert.That( (c1 == c2 && c2 == c1) || (c1 != c2 && c2 != c1), Is.True);
  • transitive.
    if (c1 == c2 && c2 == c3) {
       Assert.That(c1 == c3, Is.True);
    }
    if (c1 != c2 && c2 != c3) {
       Assert.That(c1 != c3, Is.True);
    } 

Equality operators in .NET should also behave in relation with the Equals and GetHashCode methods. Like the equality operators, Equals should also be reflective, symmetric, and transitive. Note that in the following piece of code, c1 and c2 are assumed to be non-null. Tests of the reflexivity, symmetry, and transitivity of Equals are also omitted for brevity here.

Assert.IsTrue( (c1 == c2 && c1.Equals(c2)) || (c1 != c2 && !c1.Equals(c2));
Assert.IsTrue( (c1 == c2 && c1.GetHashCode() == c2.GetHashCode()) || (c1 != c2 && c1.GetHashCode() == c2.GetHashCode());

Furthermore, any non-null reference c of type Customer should be expected not to be equal to the null reference.

Customer nullCustomer = null;

Assert.That(c.Equals(null), Is.False);

Assert.That(c == (Customer)null, Is.False);
Assert.That((Customer)null == c1, Is.False);

Assert.That(c != (Customer)null, Is.True);
Assert.That(c == (Customer)null, Is.False);

Similarly, the static equality operators should behave properly when all the input parameters are null. Testing such extreme cases is important as developers, including myself, sometimes forget to deal appropriately with null references.

Assert.That( (Customer)null == (Customer)null, Is.True);

Challenges of Testing

Using equality operators allows to create rich but syntaxically simple expressions which are easy to read and maintain. Therefore, an application may define a lot of equality operators. As mentioned previously, testing the newly implemented operators consists in testing if the operators respect the inherent semantics and are consistent with Equals and GetHashCode methods.

Testing properly the equality operators is finally more complex than expected as the number of potential cases to test is quite large. Furthermore, tests need also to be run for two references of Customer expected to be unequal. Consequently, the NUnit assertions should to be factorized in order to be reused across multiple test fixtures.

But Equality Operators are Resolved during Compilation

Consider the following first draft which attempts to factorize the assertions into the function AssertEqualityForEqualObject. This function is meant to test not only the Customer class, but also other types such as the Supplier class or the ZipCode structure for example. Since the only commonality between all the .NET types is the type object, our first draft will have two input parameters obj1 and obj2 of type object.

void AssertEqualityForEqualObject(object obj1, object obj2) {
   // Testing null arguments is omitted in this example
   
   Assert.That(obj1.Equals(obj2), Is.True); 
   Assert.That(obj2.Equals(obj1), Is.True);
   . . . .    
   // This statement will not work as indented!
   // Object.ReferenceEquals(obj1, obj2) will be called instead of Customer.operator==(obj1, obj2)
   Assert.That(obj1 == obj2, Is.True);
   . . . . 

The second assert will not work as indented because the decision of choosing the appropriate static operator is made at compile-time. Hence, the equality operator defined for the type object will always be called at run-time. In other words, the second assertion just checks if obj1 and obj2 are the same reference.

Generics will not work either for the same reason: operator == is resolved at compilate-time, not at run-time. Compiling the following piece of code will lead to the following error: Operator '==' cannot be applied to operands of type 'T' and 'U'. Comments have been made on this issue.

class EqualityAssertion<T, U>
{
    public bool Test(T obj1, U obj2)
    {
        return obj1 == obj2;
    }
}

Value and Reference Types

As previously mentioned, it is important to test cases with null parameters. However, such tests on the equality operators is irrelevant for value types. Abstract Data Type (ADT) like the PostCode or Money structures usually rely on equality operators. But it is expected that testing the Equals method with a null parameter on a value type will always return false:

Assert.That(DateTime.Now.Equals(null), Is.False);

NUnit Integration

The challenges of implementing a generic test for the equality operators have now been outlined. Several paths of action are possible to implement the solution with NUnit. One way is to inherit test fixtures from a base class which will have factorized the assertions for equality operators. Another way consists in extending NUnit by creating a new type of constraint. This constraint evaluates whether or not two instances expected to be equal follow the semantics of the equality operators.

Equality Operator Constraint

Let's review the details of the implementation of EqualityOperatorConstraint. We will first tackle on how equality operators can be resolved not at compile-time but at run-time.

IStaticEqualityOperatorProvider

The interface IStaticEqualityOperatorProvider is used to resolve the static equality operators at run-time. This interface defines two methods. As their names suggest, EvaluateStaticEqualEqualOperator and EvaluateStaticNotEqualOperator return the result for the expressions lhs == rhs and lhs != rhs, respectively.

public interface IStaticEqualityOperatorProvider
{
       bool EvaluateStaticEqualEqualOperator(object lhs, object rhs);
       bool EvaluateStaticNotEqualOperator(object lhs, object rhs);        
}   

Several implementations are provided:

  • ReflectiveStaticEqualityOperatorProvider<T> relies on reflection to call the correct operator== and operator!= functions.
    private bool? InvokeStaticOperator(T lhs, T rhs, string methodName)
    {
        MethodInfo mi = typeof(T).GetMethod(methodName, BindingFlags.Static | BindingFlags.Public, null, new Type[] {
                typeof(T),
                typeof(T)},
           null);
    
        if (mi != null)
        {
            object result = mi.Invoke(null, new object[] { lhs, rhs });
            if (!(result is bool))
            {
                throw new InvalidCastException();
            }
            return (bool)result;
        }
        else
        {
            return null;
        }
    }
    
    public override bool EvaluateStaticEqualEqualOperator(T lhs, T rhs)
    {
        bool? result = InvokeStaticOperator(lhs, rhs, "op_Equality");
        if (result == null)
        {
            throw new ReflectiveStaticEqualEqualOperatorNotFound(string.Format("operator== is not defined in \"{0}\"", typeof(T).Name));
        }
        else
        {
            return result.Value;
        }
    }
  • DelegatedStaticEqualityOperatorProvider accepts delegates as parameters of the constructor. It allows to come up with an inline implementation of the provider using anonymous methods. The following piece of code create a provider for DateTime:
    IStaticEqualityOperatorProvider provider = new DelegatedStaticEqualityOperatorProvider<DateTime>(
        delegate(DateTime lhs, DateTime rhs)
        {
           return lhs == rhs;
        },
        delegate(DateTime lhs, DateTime rhs)
        {
          return lhs != rhs;
        });

Aggregate of Atomic Constraints

Creating a new constraint in NUnit consists in implementing two methods of the abstract class Constraint: bool Matches(object actual) and void WriteDescriptionTo(MessageWriter writer). In the current implementation, EqualityOperatorConstraint constructs a compound constraint using & (with AndConstraint) and | (with OrConstraint) operators to add all the atomic constraints testing the semantic of the equality operators and the behavior of the Equals and GetHashCode methods.

Using the Equality Constraints

Let's go back to our first example with the Customer class. Testing the equality operators as well as testing Equals and GetHashCode is just a matter of using the new EqualityOperatorConstraint combined with ReflectiveStaticEqualityOperatorProvider<Customer>. The constraints comes with two flavors:

  • EqualityOperatorConstraint evaluates if two references are expected to be equal.
  • InequalityOperatorConstraint evaluates if two reference are expected to be unequal.
[Test]
public void TestCustomerWithEquality()
{
    Customer customer = new Customer("James");
    Constraint constraint = new EqualityOperatorConstraint(customer, new ReflectiveStaticEqualityOperatorProvider<Customer>());

    Assert.That(new Customer("James"), constraint);        
}

[Test]
public void TestCustomerWithInequality()
{
    Customer customer = new Customer("James");
    Constraint constraint = new InequalityOperatorConstraint(customer, new ReflectiveStaticEqualityOperatorProvider<Customer>());

    Assert.That(new Customer("David"), constraint);
}

The NUnit model provides a description when a constraint fails. As mentioned previously, the EqualityOperatorConstraint is an aggregate of simple constraints. To avoid any verbose message, the description of the first constraint which fails is provided instead. Consider the following case where the equality operator of the Customer class has been improperly implemented. Instead of comparing the names, the operator compares the length of the names.

class Customer
{
    private readonly string name;

    public Customer(string name)
    {
        this.name = name;
    }

    public string Name
    {

        get
        {
            return name;
        }
    }


    public override bool Equals(object obj)
    {
        Customer customer = obj as Customer;
        if (customer != null)
        {
            return this == customer;
        }
        else
        {
            return false;
        }
    }

    public override int GetHashCode()
    {
        return name.GetHashCode();
    }

    // Incorrect implementation of Customer. The lengths of the names are compared!
    public static bool operator ==(Customer lhs, Customer rhs)
    {
        if (object.ReferenceEquals(lhs, rhs))
        {
            return true;
        }
        else if ((object)lhs == null || (object)rhs == null)
        {
            return false;
        }
        else
        {
            return lhs.Name.Length == rhs.Name.Length;
        }
    }

    public static bool operator !=(Customer lhs, Customer rhs)
    {
        return !(lhs == rhs);
    }

    public override string ToString()
    {
        return Name;
    }
}

The case testing the inequality between a customer called James and Williams will pass since the names are of different lengths. However, testing the inequality between James and Henry will fail with this error message:

Assert.That(new Customer("Henry"), new InequalityOperatorConstraint(new Customer("James"),
                    new ReflectiveStaticEqualityOperatorProvider<Customer>()));
failed: operator ==(actual, expected) where "actual" was "Henry" and "expected" was "James"
Expected: False
Actual: True

A related test with the equality constraint this time will also fail because two customers with names of same length return a different hash code:

Assert.That(new Customer("Henry"), new EqualityOperatorConstraint(new Customer("James"),
                   new ReflectiveStaticEqualityOperatorProvider<Customer>()));
failed: actual.GetHashCode() == expected.GetHashCode() where "actual" was "Henry" and "expected" was "James"
Expected: True
Actual: False

Conclusion

In order to fully cover the tested code, the EqualityOperatorConstraint and the InequalityOperatorConstraint need to be used with different pairs of instances expected to be equal and unequal, respectivelly. The equality constraints can only carry out tests with the provided instances. So, as usual, keep an eye on code coverage while writing unit tests.

Download icon The source code for this post is available from github.