Testing Equality Operators with NUnit
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.
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.
- symmetric.
- transitive.
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.
Furthermore, any non-null reference c
of type Customer
should be expected not to be equal to the null reference.
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.
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
.
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.
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:
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.
Several implementations are provided:
ReflectiveStaticEqualityOperatorProvider<T>
relies on reflection to call the correctoperator==
andoperator!=
functions.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 forDateTime
:
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.
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.
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:
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:
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.
The source code for this post is available from github.