Unit testing equals and hashCode methods
I had to add few fields into existing class wchih was simple POJO, but was used
in collections. And had equals
and hashCode
defined. I forgot to update
hashCode
. Which I discovered when I was checking my change before sending it to code
review. World is saved. Go sleep.
General contract for equals
and hashCode
From Object.equals
The
equals
method implements an equivalence relation on non-null object references:
It is reflexive: for any non-null reference value
x
,x.equals(x)
should returntrue
.It is symmetric: for any non-null reference values
x
andy
,x.equals(y)
should returntrue
if and only ify.equals(x)
returnstrue
.It is transitive: for any non-null reference values
x
,y
, andz
, ifx.equals(y)
returnstrue
andy.equals(z)
returnstrue
, thenx.equals(z)
should returntrue
.It is consistent: for any non-null reference values
x
andy
, multiple invocations ofx.equals(y)
consistently returntrue
or consistently returnfalse
, provided no information used inequals
comparisons on the objects is modified.For any non-null reference value
x
,x.equals(null)
should returnfalse
.
From Object.hashCode
The general contract of
hashCode
is:
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 inequals
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.
It’s not really easy to meet at requirements in the contract. Very good article
How to Write an Equality Method in
Java written by Odersky
is diving deep into problems of equals
and hashCode
, so I will skip this.
I would rather focus on unit testing of equality.
Unit testing equals(Object)
is easy
You can write your own unit tests(and tons of code around it) or use EqualsTester from guava (which is the preferable way).
new EqualsTester()
.addEqualityGroup(
new Foobar("hello"),
new Foobar("hello"))
.addEqualityGroup(
new Foobar("bye"))
.testEquals();
Unit testing hashCode()
is hard
Wait. It is easy too! Use EqualsTester too. According to javadoc:
This tests:
- the hash codes of any two equal objects are equal
That’s great, because you will follow the contract. And that’s probably the only thing that you can test about hash code. You cannot really test when 2 objects are not equal, because hash code might be or might not be equal. Only simple classes where hash is taken from id on integer range might define their hash function as perfect.
The problem is that if you forget to update hashCode
, you will live in
unconsciousness that your hashCode
method is ineffective and producing a lot
of collisions during insertions into hashed collections. You will very likely
discover it during integration tests when the time needed to execute test will
dramatically increase. The worse scenario is to discover such mistake in
production…
How to deal with equals
and hashCode
invariant
To ensure that you covered all fields in equals
and hashCode
you can:
- Use code generator, like Project Lombok and it’s @EqualsAndHashCode annotation.
- Write your own tester to which you put list of fields which should participate on the equality and hash codes. It will analyze bytecode of the methods if fields are read.
@EqualsAndHashCode
I’m big fan of this approach. If your class is annotated just with
@EqualsAndHashCode
, Lombok will generate equals
and hashCode
from all
non-static, non-transient fields. You will never miss any new field! You have to
rely on another framework, but it will save a lot of pain and makes your code
readable. You should definitely use EqualsTester
to ensure correctness of
generated code!
If you do not want to cover all fields, you can use exclude
or of
attributes
to define your own set of fields which should be used for equals
and
hashCode
generation. The advantage is that you have to manage just only one
list of fields, clearly visible, not hidden somewhere in the class body and it’s
methods in two places!
@EqualsAndHashCode(of={"foobar1"})
public class MyFoobar {
private final int foobar1;
private final int foobar2;
public MyFoobar(int foobar1, int foobar2) {
this.foobar1 = foobar1;
this.foobar2 = foobar2;
}
}
...
public class MyFoobarTest {
@Test
public void equalsAndHashCode() {
new EqualsTester()
.addEqualityGroup(
new MyFoobar(1, 2),
new MyFoobar(1, 999))
.addEqualityGroup(
new MyFoobar(999, 1))
new MyFoobar(999, 999))
.testEquals();
}
}
Checking legacy code with bytecode analyses
This idea came to my mind during writing this post. What if you do not want to
invest into rewriting your code base into Lombok? You would be happy if there is
some tool which can analyse bytecode of existing classes and it will report
discrepancies in usage of fields between equals
and hashCode
. Is it even
possible?
I checked FindBugs if they provide such functionality and didn’t find it.
So I quickly implemented PoC here. It is using ASM for bytecode analyses. Usage is very simple as its feature set.
- Currently detects only direct access to fields
- If field is
final
and value is known during compilation, noGETFIELD
operand is generated and such access is not detected - Takes all fields presented in
equals
orhashCode
package cz.rank.tests;
import org.junit.Test;
import java.util.Objects;
public class EqualsHashCodeReporterTest {
@Test
public void emptyEqualsAndHashCodeProduceNoReport() throws Exception {
new EqualsHashCodeReporter(Object.class).report();
}
@Test(expected = AssertionError.class)
public void onlyInHashCodeProducesException() throws Exception {
new EqualsHashCodeReporter(OnlyHashCodeObject.class).report();
}
@Test(expected = AssertionError.class)
public void onlyInEqualsProducesException() throws Exception {
new EqualsHashCodeReporter(OnlyEqualsObject.class).report();
}
@Test(expected = AssertionError.class)
public void differentFieldsProduceException() throws Exception {
new EqualsHashCodeReporter(EqualsAndHashCodeDifferentObject.class).report();
}
private static class OnlyHashCodeObject {
private final int field;
private OnlyHashCodeObject(int field) {
this.field = field;
}
@Override
public int hashCode() {
return Objects.hash(field);
}
}
private static class OnlyEqualsObject {
private final int field;
private OnlyEqualsObject(int field) {
this.field = field;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof OnlyEqualsObject)) return false;
OnlyEqualsObject that = (OnlyEqualsObject) o;
return field == that.field;
}
}
private static class EqualsAndHashCodeDifferentObject {
private final int field;
private final int field2;
private EqualsAndHashCodeDifferentObject(int field) {
this.field = field;
this.field2 = field << 2;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof EqualsAndHashCodeDifferentObject)) return false;
EqualsAndHashCodeDifferentObject that = (EqualsAndHashCodeDifferentObject) o;
return field == that.field;
}
@Override
public int hashCode() {
return Objects.hash(field, field2);
}
}
}