Skip to content

Instantly share code, notes, and snippets.

@nniesen
Last active February 25, 2022 19:08
Show Gist options
  • Save nniesen/d87eb6db40a6fb28de525ec1819789c1 to your computer and use it in GitHub Desktop.
Save nniesen/d87eb6db40a6fb28de525ec1819789c1 to your computer and use it in GitHub Desktop.
Assertion method for validating equals/HashCode implementations
package org.test;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotSame;
import static org.junit.Assert.assertTrue;
public class AssertEqualsHashCode {
/**
* Assert that the equals method is reflexive, symmetric, transitive, consistent, and should return a false if the reference
* argument is null and asserts that the hashCode method is consistent with equals (i.e., if objects are equal their hash
* codes must be equal).
* <p>
* Testing notes:
* <ul>
* <li>Call this method once for each field value (permutation) needed to cover all code paths that effect equality and hash
* code values.</li>
* <li>For objects where multiple fields determine equality, test each field making sure to only permutate one field value at
* a time.</li>
* <li>Hash code tests are only performed using the equal instances so be sure to test any needed field permutations for the
* equal instances.</li>
* </ul>
* Definitions (Follows the contract Javadoc for equals and hashCode):
* <ul>
* <li>Reflexivity requires that for any reference x, x.equals(x) should return true.</li>
* <li>Symmetry requires that for any references x and y, x.equals(y) should return true if and only if y.equals(x) returns
* true.</li>
* <li>Transitivity requires that for any references x, y and z, if x.equals(y) returns true and y.equals(z) returns true,
* then x.equals(z) should return true.</li>
* <li>Consistency requires that repeated calls to x.equals(y) should consistently return a true or consistently return a
* false, if no data /state has changed in either object.</li>
* <li>Non-nullity requires that the for any non-null reference x, x.equals(null) should return false.</li>
* </ul>
*
* @param messagePrefix A prefix, such as the field being permutated, to apply to the {@code AssertionError} messages. The
* prefix should include whitespace as none will be added.
* @param x An instance considered to be equal.
* @param y An instance considered to be equal.
* @param z An instance considered to be equal.
* @param n An instance considered to be NOT equal.
*/
public static void assertEqualsHashCode(final String messagePrefix, final Object x, final Object y, final Object z,
final Object n) {
// All instances must be unique.
assertNotSame(messagePrefix + "Expected unique: x ! y", x, y);
assertNotSame(messagePrefix + "Expected unique: y ! z", y, z);
assertNotSame(messagePrefix + "Expected unique: z ! x", z, x);
assertNotSame(messagePrefix + "Expected unique: x ! n", x, n);
assertNotSame(messagePrefix + "Expected unique: y ! n", y, n);
assertNotSame(messagePrefix + "Expected unique: z ! n", z, n);
assertTrue(messagePrefix + "Expected reflexive equals: x.equals(x)", x.equals(x));
assertTrue(messagePrefix + "Expected consistent equals: x.equals(x)", x.equals(x));
assertFalse(messagePrefix + "Expected not equal null: x!=null", x.equals(null));
assertFalse(messagePrefix + "Expected not equal other class: x!=other", x.equals(new BaseTest() {
}));
assertTrue(messagePrefix + "Expected symmetric equals: x=y", x.equals(y));
assertTrue(messagePrefix + "Expected symmetric equals: y=x", y.equals(x));
// given xy are symmetric...
assertTrue(messagePrefix + "Expected transitive equals: y=z", y.equals(z));
assertTrue(messagePrefix + "Expected transitive equals: x=z", x.equals(z));
assertFalse(messagePrefix + "Expected not equal: x!=n", x.equals(n));
assertFalse(messagePrefix + "Expected not equal: n!=x", n.equals(x));
// If equal, hash codes must be equal. Object n is not tested because hash codes do not have to be unique, just consistent with equals.
assertTrue(messagePrefix + "Expected hash code equals: x==y", x.hashCode() == y.hashCode());
assertTrue(messagePrefix + "Expected hash code equals: y==z", y.hashCode() == z.hashCode());
assertTrue(messagePrefix + "Expected hash code equals: z==x", z.hashCode() == x.hashCode());
}
}
package org.test;
import static org.junit.Assert.assertEquals;
import org.junit.Test;
public class AssertEqualsHashCodeTest {
@Test
public void testAssertEqualsHashCode_UniqueInstance() {
TestSingleFieldEquals x = new TestSingleFieldEquals("A");
TestSingleFieldEquals y = new TestSingleFieldEquals("A");
TestSingleFieldEquals z = new TestSingleFieldEquals("A");
TestSingleFieldEquals n = new TestSingleFieldEquals("B");
assertUniqueEqualsHashCodeInstances(x, x, z, n, "x ! y");
assertUniqueEqualsHashCodeInstances(x, y, y, n, "y ! z");
assertUniqueEqualsHashCodeInstances(z, y, z, n, "z ! x");
assertUniqueEqualsHashCodeInstances(x, y, z, x, "x ! n");
assertUniqueEqualsHashCodeInstances(x, y, z, y, "y ! n");
assertUniqueEqualsHashCodeInstances(x, y, z, z, "z ! n");
}
private void assertUniqueEqualsHashCodeInstances(final TestSingleFieldEquals x, final TestSingleFieldEquals y,
final TestSingleFieldEquals z, final TestSingleFieldEquals n, final String expectedMessage) {
try {
assertEqualsHashCode(x, y, z, n);
fail("Expected AssertionError with message: " + expectedMessage);
} catch (AssertionError e) {
assertEquals("Expected unique: " + expectedMessage + " expected not same", e.getMessage());
}
}
@Test
public void testAssertEqualsHashCode_NotEqualInstanceIsEqual() {
this.thrown.expect(AssertionError.class);
this.thrown.expectMessage("Expected not equal: x!=n");
TestSingleFieldEquals x = new TestSingleFieldEquals("A");
TestSingleFieldEquals y = new TestSingleFieldEquals("A");
TestSingleFieldEquals z = new TestSingleFieldEquals("A");
TestSingleFieldEquals n = new TestSingleFieldEquals("A");
assertEqualsHashCode(x, y, z, n);
}
/**
* Example usage of assertEqualsHashCode for testing class with one field participating in Equals and HashCode.
*/
@Test
public void testAssertEqualsHashCode_SingleField() {
TestSingleFieldEquals x = new TestSingleFieldEquals("SameValue");
TestSingleFieldEquals y = new TestSingleFieldEquals("SameValue");
TestSingleFieldEquals z = new TestSingleFieldEquals("SameValue");
// Test a not-equal value for the field
TestSingleFieldEquals n = new TestSingleFieldEquals("DifferentValue");
assertEqualsHashCode(x, y, z, n);
// Test any special values for the field that affect equality.
n = new TestSingleFieldEquals(/* empty char[] */);
assertEqualsHashCode(x, y, z, n);
// Also test special empty value on the equal instances for hash codes.
x = new TestSingleFieldEquals(/* empty char[] */);
y = new TestSingleFieldEquals(/* empty char[] */);
z = new TestSingleFieldEquals(/* empty char[] */);
n = new TestSingleFieldEquals("NotEmpty");
assertEqualsHashCode(x, y, z, n);
// Test special value where length affects code path.
x = new TestSingleFieldEquals("SameLengthAndSameValue");
y = new TestSingleFieldEquals("SameLengthAndSameValue");
z = new TestSingleFieldEquals("SameLengthAndSameValue");
n = new TestSingleFieldEquals("SameLengthButDiffValue");
assertEqualsHashCode(x, y, z, n);
}
private static class TestSingleFieldEquals {
private final char value[];
public TestSingleFieldEquals() {
this.value = new char[0]; // <- interesting field value, empty array
}
public TestSingleFieldEquals(final String original) {
this.value = original.toCharArray();
}
@Override
public int hashCode() {
int result = 0;
if (this.value.length > 0) { // <- Note: need to test empty permutation for the equal instances.
char val[] = this.value;
for (int i = 0; i < this.value.length; i++) {
result = (31 * result) + val[i];
}
}
return result;
}
@Override
public boolean equals(final Object obj) {
if (this == obj) {
return true;
}
if (obj instanceof TestSingleFieldEquals) {
TestSingleFieldEquals other = (TestSingleFieldEquals) obj;
int n = this.value.length;
if (n == other.value.length) {
char v1[] = this.value;
char v2[] = other.value;
int i = 0;
while (n-- != 0) {
if (v1[i] != v2[i]) {
return false; // <- Note: need to test permutation where values are same length but different.
}
i++;
}
return true;
}
}
return false;
}
}
/**
* Example usage of assertEqualsHashCode for testing class with multiple fields participating in Equals and HashCode.
*/
@Test
public void testAssertEqualsHashCode_MultipleFields() {
TestMultiFieldEquals x = new TestMultiFieldEquals("Same", 75);
TestMultiFieldEquals y = new TestMultiFieldEquals("Same", 75);
TestMultiFieldEquals z = new TestMultiFieldEquals("Same", 75);
// For objects where multiple fields determine equality, test each field and only permutate one at a time.
// Test change in name field
TestMultiFieldEquals n = new TestMultiFieldEquals("Diff", 75);
assertEqualsHashCode("Permutated name - ", x, y, z, n);
// Test change in age field
n = new TestMultiFieldEquals("Same", 35);
assertEqualsHashCode("Permutated age - ", x, y, z, n);
// Test for special field properties: e.g., null for name field
n = new TestMultiFieldEquals(null, 75);
assertEqualsHashCode("Null name - ", x, y, z, n);
// Also test for special field properties where a different code path is taken if the special value is on
// the "equals" objects. This is also needed to fully test hash codes with special field properties since
// hash code tests are only performed on the "equals" objects.
x = new TestMultiFieldEquals(null, 75);
y = new TestMultiFieldEquals(null, 75);
z = new TestMultiFieldEquals(null, 75);
n = new TestMultiFieldEquals("NotNull", 75);
assertEqualsHashCode("Null name (on equals instances) - ", x, y, z, n);
}
private static class TestMultiFieldEquals {
private final String name;
private final int age;
public TestMultiFieldEquals(final String name, final int age) {
this.name = name;
this.age = age;
}
@Override
public int hashCode() {
final int prime = 31;
int result = 1;
result = (prime * result) + ((this.name == null) ? 0 : this.name.hashCode()); // <- Note: need to test null permutation for the equal instances.
result = (prime * result) + this.age;
return result;
}
@Override
public boolean equals(final Object obj) {
if (this == obj) {
return true;
}
if (obj == null) {
return false;
}
if (getClass() != obj.getClass()) {
return false;
}
TestMultiFieldEquals other = (TestMultiFieldEquals) obj;
if (this.name == null) { // <-------- Note: need to test null permutation for both equal and not-equal instances.
if (other.name != null) { // <-/
return false;
}
} else if (!this.name.equals(other.name)) {
return false;
}
if (this.age != other.age) {
return false;
}
return true;
}
}
@Test
public void testAssertEqualsHashCode_ImplementationFlaw_NotReflexive() {
EqualsHashCodeImpl x = new EqualsHashCodeImpl("A", 1);
EqualsHashCodeImpl y = new EqualsHashCodeImpl("A", 2);
EqualsHashCodeImpl z = new EqualsHashCodeImpl("A", 3);
EqualsHashCodeImpl n = new EqualsHashCodeImpl("B", 1);
assertImplementationFlaw(x, y, z, n, "notReflexive", "Expected reflexive equals: x.equals(x)");
}
@Test
public void testAssertEqualsHashCode_ImplementationFlaw_NotConsistent() {
EqualsHashCodeImpl x = new EqualsHashCodeImpl("A", 1);
EqualsHashCodeImpl y = new EqualsHashCodeImpl("A", 2);
EqualsHashCodeImpl z = new EqualsHashCodeImpl("A", 3);
EqualsHashCodeImpl n = new EqualsHashCodeImpl("B", 1);
assertImplementationFlaw(x, y, z, n, "notConsistent", "Expected consistent equals: x.equals(x)");
}
@Test
public void testAssertEqualsHashCode_ImplementationFlaw_EqualsNull() {
EqualsHashCodeImpl x = new EqualsHashCodeImpl("A", 1);
EqualsHashCodeImpl y = new EqualsHashCodeImpl("A", 2);
EqualsHashCodeImpl z = new EqualsHashCodeImpl("A", 3);
EqualsHashCodeImpl n = new EqualsHashCodeImpl("B", 1);
assertImplementationFlaw(x, y, z, n, "equalsNull", "Expected not equal null: x!=null");
}
@Test
public void testAssertEqualsHashCode_ImplementationFlaw_EqualsAnotherClass() {
EqualsHashCodeImpl x = new EqualsHashCodeImpl("A", 1);
EqualsHashCodeImpl y = new EqualsHashCodeImpl("A", 2);
EqualsHashCodeImpl z = new EqualsHashCodeImpl("A", 3);
EqualsHashCodeImpl n = new EqualsHashCodeImpl("B", 1);
assertImplementationFlaw(x, y, z, n, "equalsAnotherClass", "Expected not equal other class: x!=other");
}
@Test
public void testAssertEqualsHashCode_ImplementationFlaw_NotSymmetric() {
EqualsHashCodeImpl x = new EqualsHashCodeImpl(null, 1);
EqualsHashCodeImpl y = new EqualsHashCodeImpl(null, 2);
EqualsHashCodeImpl z = new EqualsHashCodeImpl(null, 3);
EqualsHashCodeImpl n = new EqualsHashCodeImpl("B", 1);
assertImplementationFlaw(x, y, z, n, "notSymmetric", "Expected symmetric equals: x=y");
}
@Test
public void testAssertEqualsHashCode_ImplementationFlaw_NotTransitive() {
EqualsHashCodeImpl x = new EqualsHashCodeImpl("A", 1);
EqualsHashCodeImpl y = new EqualsHashCodeImpl("A", 1);
EqualsHashCodeImpl z = new EqualsHashCodeImpl("A", 3);
EqualsHashCodeImpl n = new EqualsHashCodeImpl("B", 1);
assertImplementationFlaw(x, y, z, n, "notTransitive", "Expected transitive equals: y=z");
}
@Test
public void testAssertEqualsHashCode_ImplementationFlaw_HashCodeInconsistentWithEquals() {
EqualsHashCodeImpl x = new EqualsHashCodeImpl("A", 1);
EqualsHashCodeImpl y = new EqualsHashCodeImpl("A", 2);
EqualsHashCodeImpl z = new EqualsHashCodeImpl("A", 3);
EqualsHashCodeImpl n = new EqualsHashCodeImpl("B", 1);
assertImplementationFlaw(x, y, z, n, "inconsistentHashCode", "Expected hash code equals: x==y");
}
private void assertImplementationFlaw(final EqualsHashCodeImpl x, final EqualsHashCodeImpl y, final EqualsHashCodeImpl z,
final EqualsHashCodeImpl n, final String implementationFlaw, final String expectMessage) {
// Assert success without flaw
EqualsHashCodeImpl.setImplementationFlaw("none");
assertEqualsHashCode(x, y, z, n);
// Assert failure with flaw
this.thrown.expect(AssertionError.class);
this.thrown.expectMessage(expectMessage);
EqualsHashCodeImpl.setImplementationFlaw(implementationFlaw);
assertEqualsHashCode(x, y, z, n);
}
private static class EqualsHashCodeImpl {
protected static int callCount = 0;
protected static boolean inconsistentHashCode;
protected static boolean notReflexive;
protected static boolean notConsistent;
protected static boolean equalsNull;
protected static boolean equalsAnotherClass;
protected static boolean notTransitive;
protected static boolean notSymmetric;
private final String fieldA;
private final int fieldB;
public EqualsHashCodeImpl(final String fieldA, final int fieldB) {
this.fieldA = fieldA;
this.fieldB = fieldB;
}
public static void setImplementationFlaw(final String implementationFlaw) {
callCount = 0;
inconsistentHashCode = false;
notReflexive = false;
notConsistent = false;
equalsNull = false;
equalsAnotherClass = false;
notTransitive = false;
notSymmetric = false;
if (!"none".equals(implementationFlaw)) {
setFieldValue(EqualsHashCodeImpl.class, implementationFlaw, Boolean.TRUE);
}
}
public static void setFieldValue(final Class<?> clazz, final String name, final Object value) {
try {
Field field = clazz.getDeclaredField(name);
field.setAccessible(true);
field.set(clazz, value);
} catch (final Exception e) {
throw new RuntimeException(e);
}
}
@Override
public int hashCode() {
final int prime = 31;
int result = 1;
result = (prime * result) + ((this.fieldA == null) ? 0 : this.fieldA.hashCode());
if (inconsistentHashCode) {
result = (prime * result) + this.fieldB; // inconsistent, add fieldB which is not part of equals
}
return result;
}
@Override
public boolean equals(final Object obj) {
if (this == obj) {
if (notReflexive) {
return false; // not reflexive, return false for same obj
}
if (notConsistent) {
callCount++;
return callCount < 2; // not consistent, change the result on subsequent call
}
return true;
}
if (obj == null) {
if (equalsNull) {
return true; // equals null, return true for null obj
}
return false;
}
if (getClass() != obj.getClass()) {
if (equalsAnotherClass) {
return true; // equals another class, return true for obj of another class
}
return false;
}
EqualsHashCodeImpl other = (EqualsHashCodeImpl) obj;
if (notTransitive) {
if (this.fieldB != other.fieldB) {
return false; // not transitive, add fieldB check that shouldn't be part of equals
}
}
if (this.fieldA == null) {
if (notSymmetric) {
return false; // not symmetric, skip check of other != null
}
if (other.fieldA != null) {
return false;
}
} else if (!this.fieldA.equals(other.fieldA)) {
return false;
}
return true;
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment