Unit-Testing is often a simple task, if you have small units that are designed to be tested in isolation easily. These units in common applications often have no complex business logic and the edge cases are quickly pinned down and dealt with individually, unit by unit.
But there are a couple of responsibilities (mostly methods) in Java that are to be tested the same way for each implementation.
These are
equals
hashCode
toString
(i.e. check no runtime exception is raised)compareTo
- Serialization
We will show in this article, how these responsibilities can be tested declaratively without writing individual test cases using JUnit theories.
For demonstration we will add methods to the following simple point class. For brevity we do not add getters to the fields and omit any Javadoc comments. For some obscure reasons we allow the coordinates to have a value of null
.
public final class Point {
private final Integer x;
private final Integer y;
public Point(final Integer x, final Integer y) {
this.x = x;
this.y = y;
}
}
equals and hashCode
So let’s add a equals
method to the class.
import org.apache.commons.lang.ObjectUtils;
public final class Point {
private final Integer x;
private final Integer y;
public Point(final Integer x, final Integer y) {
this.x = x;
this.y = y;
}
@Override
public int hashCode() {
int result = 17;
result = 37 * result + ObjectUtils.hashCode(x);
result = 37 * result + ObjectUtils.hashCode(y);
return result;
}
@Override
public boolean equals(final Object object) {
if (this == object) {
return true;
}
else if (object == null || getClass() != object.getClass()) {
return false;
}
final Point other = (Point) object;
return (ObjectUtils.equals(this.x, other.x)
&& ObjectUtils.equals(this.y, other.y));
}
}
Since we allowed coordinate values to be null
, we use ObjectUtils
from Apache’s commons-lang library.
Usually, for every class that supportes equals
and therefore hashCode
, we have to write some tests with typical edge values. What has to be tested is stated in the API documentation for Object
. And this is the same for all implementations.
These are the constraints for equals
:
- It is reflexive: for any non-null reference value x, x.equals(x) should return true.
- It is symmetric: for any non-null reference values x and y, x.equals(y) should return true if and only if y.equals(x) returns true.
- It is transitive: for any non-null reference values x, y, and z, if x.equals(y) returns true and y.equals(z) returns true, then x.equals(z) should return true.
- It is consistent: for any non-null reference values x and y, multiple invocations of x.equals(y) consistently return true or consistently return false, provided no information used in equals comparisons on the objects is modified.
- For any non-null reference value x, x.equals(null) should return false.
And for hashCode
:
- Whenever it is invoked on the same object more than once during an execution of a Java application, the
hashCode
method must consistently return the same integer, provided no information used in equals comparisons on the object is modified. This integer need not remain consistent from one execution of an application to another execution of the same application.- If two objects are equal according to the
equals(Object)
method, then calling thehashCode
method on each of the two objects must produce the same integer result.- It is not required that if two objects are unequal according to the
equals(java.lang.Object)
method, then calling thehashCode
method on each of the two objects must produce distinct integer results. However, the programmer should be aware that producing distinct integer results for unequal objects may improve the performance of hash tables.
This adds up to a couple of unit tests for each class. Writing these tests is cumbersome and error-prone.
Fortunately JUnit provides a features called Theories that help to specify the rules given above once and run these rules (or theories) against any implementation. All you have to do is to create instances for the edge values and tell JUnit to run the theories against them. So the work we have to do, using an implementation of ObjectTheories
is this:
import org.junit.experimental.theories.DataPoint;
import static java.lang.Integer.MIN_VALUE;
import static java.lang.Integer.MAX_VALUE;
import de.smartics.util.test.theories.ObjectTheories;
public class PointTest extends ObjectTheories {
@DataPoint
public static final Point MIN = new Point(MIN_VALUE, MIN_VALUE);
@DataPoint
public static final Point MIN_MAX = new Point(MIN_VALUE, MAX_VALUE);
@DataPoint
public static final Point MAX = new Point(MAX_VALUE, MAX_VALUE);
@DataPoint
public static final Point EQUAL = new Point(MAX_VALUE, MAX_VALUE);
}
The theory tests that equals
and hashCode
are implemented as required by the rules of the Object
class shown above. This is the result of the run:
toString
method. We still only have the default implementation and now add the following toString
implementation:
@Override public String toString() {
final StringBuilder buffer = new StringBuilder();
buffer.append(x.intValue()).append(", ").append(y.intValue());
return buffer.toString();
}
And add a new data point to our test:
@DataPointpublic static final Point NULL = new Point(null, null);
And see the toString
test fail:
We just wanted to show that the theories really find bugs.
An easy fix (a better fix may be to not allow null
values, but we do not want to make this article longer than it actually is – add a check to the constructor and the read method for serialization – we cover serialization in the next section):
@Override
public String toString() {
final StringBuilder buffer = new StringBuilder();
buffer.append(x).append(", ").append(y);
return buffer.toString();
}
At this point you may find code coverage a handy tool to check that every case has been checked by the theories. Let’s check if using a single data point is not enough:
No, it isn’t.
With all data points as shown above:
Yes, it is!
compareTo
Implementing a Comparable
has similar constraints as equals
to be met.
We add the comparable interface to our Point
class:
public final class Point implements Comparable<Point> {
...
public int compareTo(final Point o) {
int compare = ObjectUtils.compare(this.x, o.x);
if (compare == 0) {
compare = ObjectUtils.compare(this.y, o.y);
}
return compare;
}
Here is the solution using CompareToTheory
. Let’s start with just one data point:
import static java.lang.Integer.MIN_VALUE;
import org.junit.experimental.theories.DataPoint;
import de.smartics.util.test.theories.CompareToTheory;
public class PointCompareToTest extends CompareToTheory<Point> {
@DataPoint
public static final Point MIN = new Point(MIN_VALUE, MIN_VALUE);
}
As we can see, the test compareToIsTransitive
signals a test failure, since one data point is not enough to check transitivity for compareTo
.
So let’s add two additional data points:
import static java.lang.Integer.MAX_VALUE;
import static java.lang.Integer.MIN_VALUE;
import org.junit.experimental.theories.DataPoint;
import de.smartics.util.test.theories.CompareToTheory;
public class PointCompareToTest extends CompareToTheory<Point> {
@DataPoint
public static final Point MIN = new Point(MIN_VALUE, MIN_VALUE);
@DataPoint
public static final Point MIN_MAX = new Point(MIN_VALUE, MAX_VALUE);
@DataPoint
public static final Point MAX = new Point(MAX_VALUE, MAX_VALUE);
}
Again, with only some edge values are defined, JUnit theories do their work.
You like typos?
public int compareTo(final Point o) {
int compare = ObjectUtils.compare(this.x, o.x);
if (compare == 0) {
compare = ObjectUtils.compare(this.x, o.y); // <-- TYPO!
}
return compare;
}
Gotcha!
compareTo
is no longer symmetric nor transitive.
Please note that it depends on your data points (and typos ;-)), which tests fail. For more complex data types, please check with a coverage tool that all paths have been covered!
Theories just check that the implementation adhere to the rules stated by the Java API.
In the example above we just check symmetry and transitivity. We do not check business rules as: is point (0, 1) really smaller than point (1,2)? The same is true for equals: we do not check if two instance that should be equal as to the business rules, are really equal.
Add these tests, if you need to!
Serialization
You might already guess, how the test case based on SerializationTheory
looks like:
import static java.lang.Integer.MAX_VALUE;
import org.junit.experimental.theories.DataPoint;
import de.smartics.util.test.theories.SerializationTheory;
public class PointSerializationTest extends SerializationTheory {
@DataPoint
public static final Point NULL = new Point(null, null);
@DataPoint
public static final Point MAX = new Point(MAX_VALUE, MAX_VALUE);
}
This time we do test-first and run into an error: our class does not implement the Serializable
interface.
Let’s add the missing interface and we have finally the complete Point
class:
import java.io.Serializable;
import org.apache.commons.lang.ObjectUtils;
public final class Point implements Serializable, Comparable<Point> {
private static final long serialVersionUID = 1L;
private final Integer x;
private final Integer y;
public Point(final Integer x, final Integer y) {
this.x = x;
this.y = y;
}
public int compareTo(final Point o) {
int compare = ObjectUtils.compare(this.x, o.x);
if (compare == 0) {
compare = ObjectUtils.compare(this.y, o.y);
}
return compare;
}
@Override
public int hashCode() {
int result = 17;
result = 37 * result + ObjectUtils.hashCode(x);
result = 37 * result + ObjectUtils.hashCode(y);
return result;
}
@Override
public boolean equals(final Object object) {
if (this == object) {
return true;
}
else if (object == null || getClass() != object.getClass()) {
return false;
}
final Point other = (Point) object;
return (ObjectUtils.equals(this.x, other.x)
&& ObjectUtils.equals(this.y, other.y));
}
@Override
public String toString() {
final StringBuilder buffer = new StringBuilder();
buffer.append(x).append(", ").append(y);
return buffer.toString();
}
}
Let’s run the test one more time to be sure:
You may ask yourself, how do I get this?
If you are using Maven, integration in your build process is quite simple:
<dependency>
<groupId>de.smartics.util</groupId>
<artifactId>smartics-test-utils</artifactId>
<version>0.3.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.8.2</version>
<scope>test</scope>
</dependency>
Note to update versions as appropriate.
The artifact is not available on the standard repositories. Please have a look at our instructions on how to access artifacts on our repository.
For more details on this library, please refer to smartics Test Utils.