Testing all equals and hashCode methods
by JohT
One thing, that leads to many discussions, is how and if equals
and hashCode
methods should be tested.
Most of the time these methods get generated.
It doesn’t seem right to test generated code.
On the other hand, not testing these methods leads to poor test coverage statistics.
In rare cases, when these methods are not regenerated or edited manually,
they might cause bugs, that are very hard to find.
In this post, I’ll show you an effective way to test all equals
and hashCode
methods within one single test class.
Table of Contents
- Libraries
- JUnit Test Class
- Connecting EqualsVerifier to ArchUnit
- Upgrading a test to a specification
- Specification using EqualsVerifier and ArchUnit
Libraries
maven `pom.xml` example (click to expand)
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<version>5.6.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>nl.jqno.equalsverifier</groupId>
<artifactId>equalsverifier</artifactId>
<version>3.1.12</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.tngtech.archunit</groupId>
<artifactId>archunit-junit5</artifactId>
<version>0.13.1</version>
<scope>test</scope>
</dependency>
The source code for this example can be found here.
JUnit Test Class
public class EqualsHashcodeTest {
private static JavaClasses classes = new ClassFileImporter()
.withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
.importPackages("io.github.joht.sample.basic"); /*1*/
@Test
public void testAllEqualsAndHashCodeMethods() {
ConfiguredEqualsVerifier verifier = EqualsVerifier.configure()
.usingGetClass()
.suppress(Warning.STRICT_HASHCODE); /*2*/
ArchRuleDefinition.codeUnits().that()
.haveName("hashCode").or().haveName("equals")
.should(FulfillEqualsAndHashcodeContract.configuredBy(verifier)) /*3*/
.check(classes);
}
}
-
You first need no import the classes that should be analyzed by ArchUnit. Since it may take a while, it should only be done once per class (hence
static
). -
EqualsVerifier can be configured if the default settings don’t fit. In this particular case, the
hashCode
implementation can skip some fields that are compared inside theequals
method, which still meets the contract. -
FulfillEqualsAndHashcodeContract is the most important part.
It connects EqualsVerifier to ArchUnit by extending the abstract classArchCondition
.
Connecting EqualsVerifier to ArchUnit
class FulfillEqualsAndHashcodeContract extends ArchCondition<JavaCodeUnit> {
private final ConfiguredEqualsVerifier verifier;
public static final ArchCondition<JavaCodeUnit> configuredBy(ConfiguredEqualsVerifier verifier) {
return new FulfillEqualsAndHashcodeContract(verifier);
}
private FulfillEqualsAndHashcodeContract(ConfiguredEqualsVerifier verifier) {
super("fulfills the equals and hashCode contract");
this.verifier = verifier;
}
@Override
public void check(JavaCodeUnit codeUnit, ConditionEvents events) {
Class<?> classToTest = classForName(codeUnit.getOwner().getName());
EqualsVerifierReport report = verifier.forClass(classToTest).report(); /*1*/
events.add(eventFor(report, codeUnit.getOwner())); /*2*/
}
private static SimpleConditionEvent eventFor(EqualsVerifierReport report, JavaClass owner) {
return new SimpleConditionEvent(owner, report.isSuccessful(), report.getMessage());
}
private static Class<?> classForName(String classname) {
try {
return Class.forName(classname);
} catch (ClassNotFoundException e) {
throw new IllegalStateException(e);
}
}
}
-
The exchangeable
ConfiguredEqualsVerifier
is used to generate the report forequals
andhashCode
methods. This is done for theJavaCodeUnit
, that is currently selected by ArchUnit. -
The report of the EqualsVerifier is converted to a
SimpleConditionEvent
and added to the other events. If all checks are successful, the test will pass. If one of theCodeUnits
doesn’t fulfill the contract, it will be listed in the message of the failed test.
Upgrading a test to a specification
There is a significant difference in the purpose for which the test is written for.
Finding bugs is the most obvious one.
Documenting, what the code should do and how it’s expected to behave
takes it to the next level. It can now be seen as a specification
.
Have a look at behaviour-driven development, if you want to find out more about it.
Specification using EqualsVerifier and ArchUnit
public class EqualsHashcodeSpecificationTest {
private static JavaClasses classes = new ClassFileImporter()
.withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
.importPackages("io.github.joht.sample");
@Test
@DisplayName("entities are considered equal, if their id are equal") /* 1 */
public void entitiesAreConsideredEqualIfTheirIdAreEqual() {
ConfiguredEqualsVerifier verifier = EqualsVerifier.configure()
.usingGetClass()
.suppress(Warning.ALL_FIELDS_SHOULD_BE_USED);
ArchRuleDefinition.methods().that()
.haveName("hashCode").or().haveName("equals")
.and().areDeclaredInClassesThat().haveSimpleNameContaining("Entity") /* 2 */
.should(FulfillEqualsAndHashcodeContract.configuredBy(verifier)) /* 3 */
.check(classes);
}
@Test
@DisplayName("value objects are only considered equal, if all of their fields are equal")
public void valueObjectsAreOnlyConsideredEqualIfAllOfTheirFieldsAreEqual() {
ConfiguredEqualsVerifier verifier = EqualsVerifier.configure()
.usingGetClass()
.suppress(Warning.STRICT_HASHCODE);
ArchRuleDefinition.methods().that()
.haveName("hashCode").or().haveName("equals")
.and().areDeclaredInClassesThat().haveSimpleNameContaining("Value")
.should(FulfillEqualsAndHashcodeContract.configuredBy(verifier))
.check(classes);
}
}
-
JUnit 5 provides the annotation
@DisplayName
for more expressive test case names. This is the most significant difference between aspecification
and an ordinarytest
. The test method name can be used as well, e.g. when using JUnit 4. -
ArchUnit supports a whole bunch of ways to select classes. In this example, entities and value objects are simply distinguished by their class name.
-
The implementation of FulfillEqualsAndHashcodeContract is the same as above.
Hint: If you want to reach out to me without leaving a comment below, open a new discussion on GitHub.