001/* 002 * Copyright 2023 the original author or authors. 003 * <p> 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * <p> 008 * https://www.apache.org/licenses/LICENSE-2.0 009 * <p> 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016package de.cuioss.test.valueobjects.contract; 017 018import static java.util.Objects.requireNonNull; 019import static org.junit.jupiter.api.Assertions.assertEquals; 020import static org.junit.jupiter.api.Assertions.assertFalse; 021import static org.junit.jupiter.api.Assertions.assertNotEquals; 022import static org.junit.jupiter.api.Assertions.assertTrue; 023 024import java.util.ArrayList; 025import java.util.Arrays; 026import java.util.Collection; 027import java.util.Collections; 028import java.util.HashMap; 029import java.util.List; 030import java.util.Map; 031import java.util.Set; 032import java.util.SortedSet; 033import java.util.TreeSet; 034 035import de.cuioss.test.valueobjects.api.object.ObjectTestConfig; 036import de.cuioss.test.valueobjects.api.object.ObjectTestContract; 037import de.cuioss.test.valueobjects.objects.ParameterizedInstantiator; 038import de.cuioss.test.valueobjects.objects.RuntimeProperties; 039import de.cuioss.test.valueobjects.property.PropertySupport; 040import de.cuioss.tools.logging.CuiLogger; 041import de.cuioss.tools.property.PropertyMemberInfo; 042 043/** 044 * Helper class providing base functionality for test for the 045 * {@link Object#equals(Object)} and {@link Object#hashCode()} variants of 046 * classes. 047 * 048 * @author Oliver Wolff 049 */ 050public class EqualsAndHashcodeContractImpl implements ObjectTestContract { 051 052 private static final Integer DEFAULT_INT_VALUE = 0; 053 054 private static final CuiLogger log = new CuiLogger(EqualsAndHashcodeContractImpl.class); 055 056 @Override 057 public void assertContract(final ParameterizedInstantiator<?> instantiator, 058 final ObjectTestConfig objectTestConfig) { 059 060 requireNonNull(instantiator, "parameterizedInstantiator must not be null"); 061 062 final var builder = new StringBuilder("Verifying "); 063 builder.append(getClass().getName()).append("\nWith configuration: ").append(instantiator.toString()); 064 log.info(builder.toString()); 065 066 final Object target = instantiator.newInstanceMinimal(); 067 assertBasicContractOnEquals(target); 068 ReflectionUtil.assertHashCodeMethodIsOverriden(target.getClass()); 069 assertBasicContractOnHashCode(target); 070 071 if (shouldTestPropertyContract(objectTestConfig)) { 072 executePropertyTests(instantiator, objectTestConfig); 073 } else { 074 log.info("Only checking basic contract of equals() and hasCode()"); 075 } 076 077 } 078 079 private static void executePropertyTests(final ParameterizedInstantiator<?> instantiator, 080 final ObjectTestConfig objectTestConfig) { 081 final SortedSet<String> consideredAttributes = new TreeSet<>(); 082 instantiator.getRuntimeProperties().getWritableProperties().stream() 083 .filter(p -> PropertyMemberInfo.DEFAULT.equals(p.getPropertyMemberInfo())) 084 .forEach(p -> consideredAttributes.add(p.getName())); 085 086 if (null != objectTestConfig) { 087 // Whitelist takes precedence 088 if (objectTestConfig.equalsAndHashCodeOf().length > 0) { 089 consideredAttributes.clear(); 090 consideredAttributes.addAll(Arrays.asList(objectTestConfig.equalsAndHashCodeOf())); 091 } else { 092 consideredAttributes.removeAll(Arrays.asList(objectTestConfig.equalsAndHashCodeExclude())); 093 } 094 } 095 if (consideredAttributes.isEmpty()) { 096 log.debug("No configured properties to be tested. Is this intentional?"); 097 } else { 098 log.info("Configured attributes found for equalsAndHashCode-testing: " + consideredAttributes); 099 assertEqualsAndHashCodeWithVariants(instantiator, consideredAttributes); 100 } 101 } 102 103 private static boolean shouldTestPropertyContract(final ObjectTestConfig objectTestConfig) { 104 return null == objectTestConfig || !objectTestConfig.equalsAndHashCodeBasicOnly(); 105 } 106 107 /** 108 * Asserts the {@link Object#equals(Object)} and {@link Object#hashCode()} 109 * contract with variants of data. 110 * 111 * @param instantiator 112 */ 113 private static void assertEqualsAndHashCodeWithVariants(final ParameterizedInstantiator<?> instantiator, 114 final SortedSet<String> consideredAttributes) { 115 116 assertEqualsAndHasCodeWithAllPropertiesSet(instantiator, consideredAttributes); 117 118 assertEqualsAndHashCodeWithSkippingProperties(instantiator, consideredAttributes); 119 120 assertEqualsAndHashCodeWithChangingProperties(instantiator, consideredAttributes); 121 122 } 123 124 /** 125 * Assert the methods {@link Object#equals(Object)} and 126 * {@link Object#hashCode()} with all properties set 127 * 128 * @param instantiator 129 * @param consideredAttributes 130 */ 131 private static void assertEqualsAndHasCodeWithAllPropertiesSet(final ParameterizedInstantiator<?> instantiator, 132 final SortedSet<String> consideredAttributes) { 133 final Collection<String> actualAttributes = new ArrayList<>(consideredAttributes); 134 135 final Collection<String> requiredNames = RuntimeProperties 136 .extractNames(instantiator.getRuntimeProperties().getRequiredProperties()); 137 138 actualAttributes.addAll(requiredNames); 139 140 final var properties = instantiator.getRuntimeProperties().getWritableAsPropertySupport(true, actualAttributes); 141 142 final Object fullObject1 = instantiator.newInstance(properties, false); 143 final Object fullObject2 = instantiator.newInstance(properties, false); 144 145 assertEquals(fullObject1, fullObject2, "Objects should be equal with all properties set"); 146 147 assertEquals(fullObject2, fullObject1, 148 "Objects should be equal with all properties set, but are not symmetric"); 149 150 assertBasicContractOnHashCode(fullObject1); 151 } 152 153 /** 154 * Assert the methods {@link Object#equals(Object)} and 155 * {@link Object#hashCode()} with the given set of properties and iterates 156 * accordingly the available variants 157 * 158 * @param instantiator 159 * @param consideredAttributes 160 */ 161 private static void assertEqualsAndHashCodeWithSkippingProperties(final ParameterizedInstantiator<?> instantiator, 162 final Set<String> consideredAttributes) { 163 164 final var information = instantiator.getRuntimeProperties(); 165 166 final var allWritableProperties = information.getWritableAsPropertySupport(true).stream() 167 .filter(prop -> consideredAttributes.contains(prop.getName())).toList(); 168 169 final var nonDefaultProperties = allWritableProperties.stream().filter(prop -> !prop.isDefaultValue()).toList(); 170 171 final var requiredProperties = nonDefaultProperties.stream().filter(PropertySupport::isRequired).toList(); 172 173 final var additionalProperties = nonDefaultProperties.stream().filter(property -> !property.isRequired()) 174 .toList(); 175 176 final var upperBound = Math.min(nonDefaultProperties.size(), consideredAttributes.size()) - 2; 177 if (additionalProperties.isEmpty()) { 178 log.info("Only required or default properties found, therefore no further testing"); 179 } else { 180 final Object minimalObject = instantiator.newInstance(requiredProperties, false); 181 final Object fullObject = instantiator.newInstance(allWritableProperties, false); 182 List<PropertySupport> iteratingProperties = new ArrayList<>(requiredProperties); 183 // Common Order of properties 184 for (final PropertySupport support : additionalProperties) { 185 if (iteratingProperties.size() < upperBound) { 186 iteratingProperties.add(support); 187 } else { 188 // Special case for the last property to be set but the objects 189 // still need to be unequal. For this last property to be iterated the value 190 // will be set to an explicit unequal value: 191 iteratingProperties.add(support.createCopyWithNonEqualValue()); 192 } 193 final Object iterating = instantiator.newInstance(iteratingProperties, false); 194 final var current = support.getName(); 195 assertEqualObjectAreNotEqual(minimalObject, iterating, current); 196 assertEqualObjectAreNotEqual(fullObject, iterating, current); 197 assertBasicContractOnHashCode(iterating); 198 } 199 // reverse Order of additional properties 200 iteratingProperties = new ArrayList<>(requiredProperties); 201 final List<PropertySupport> reverseAddtionalProperties = new ArrayList<>(additionalProperties); 202 Collections.reverse(reverseAddtionalProperties); 203 for (final PropertySupport support : reverseAddtionalProperties) { 204 if (iteratingProperties.size() < upperBound) { 205 iteratingProperties.add(support); 206 } else { 207 iteratingProperties.add(support.createCopyWithNonEqualValue()); 208 } 209 final Object iterating = instantiator.newInstance(iteratingProperties, false); 210 final var current = support.getName(); 211 assertEqualObjectAreNotEqual(minimalObject, iterating, current); 212 assertEqualObjectAreNotEqual(fullObject, iterating, current); 213 assertBasicContractOnHashCode(iterating); 214 } 215 } 216 217 } 218 219 private static void assertEqualsAndHashCodeWithChangingProperties(final ParameterizedInstantiator<?> instantiator, 220 final SortedSet<String> consideredAttributes) { 221 final Map<String, PropertySupport> allWritableProperties = new HashMap<>(); 222 223 instantiator.getRuntimeProperties().getWritableAsPropertySupport(true) 224 .forEach(p -> allWritableProperties.put(p.getName(), p)); 225 226 final Object expected = instantiator.newInstance(new ArrayList<>(allWritableProperties.values()), false); 227 for (final String name : consideredAttributes) { 228 final Map<String, PropertySupport> current = new HashMap<>(allWritableProperties); 229 assertTrue(current.containsKey(name), "Invalid configuration found: " + name + " not defined as property."); 230 current.put(name, current.get(name).createCopyWithNonEqualValue()); 231 final Object actual = instantiator.newInstance(new ArrayList<>(current.values()), false); 232 assertEqualObjectAreNotEqual(expected, actual, name); 233 } 234 // Now reverse order 235 final List<String> reverse = new ArrayList<>(consideredAttributes); 236 Collections.reverse(reverse); 237 for (final String name : reverse) { 238 final Map<String, PropertySupport> current = new HashMap<>(allWritableProperties); 239 assertTrue(current.containsKey(name), "Invalid configuration found: " + name + " not defined as property."); 240 current.put(name, current.get(name).createCopyWithNonEqualValue()); 241 final Object actual = instantiator.newInstance(new ArrayList<>(current.values()), false); 242 assertEqualObjectAreNotEqual(expected, actual, name); 243 } 244 } 245 246 private static void assertEqualObjectAreNotEqual(final Object expected, final Object actual, 247 final String deltaPropertyName) { 248 final var message = new StringBuilder("The Objects of type ").append(expected.getClass().getName()) 249 .append(" should not be equal, current property=").append(deltaPropertyName).toString(); 250 assertNotEquals(expected, actual, message); 251 assertNotEquals(actual, expected, message); 252 } 253 254 /** 255 * Verify object has implemented {@link Object#equals(Object)} method. In 256 * addition it checks whether the basic functionality like 257 * <ul> 258 * <li>equals(null) will be 'false'</li> 259 * <li>equals(new Object()) will be 'false'</li> 260 * <li>underTest.equals(underTest) will be 'true'</li> 261 * </ul> 262 * is implemented correctly 263 * 264 * @param underTest object under test 265 */ 266 @SuppressWarnings({ "squid:S2159", "squid:S1764" }) // Sonar complains that the x.equals(x) is 267 // always true. This is 268 // only the case if implemented correctly, what is checked 269 // within this test 270 public static void assertBasicContractOnEquals(final Object underTest) { 271 272 ReflectionUtil.assertEqualsMethodIsOverriden(underTest.getClass()); 273 274 // basic checks to equals implementation 275 final var msgNotEqualsNull = "Expected result for equals(null) will be 'false'. Class was : " 276 + underTest.getClass(); 277 278 assertFalse(underTest.equals(null), msgNotEqualsNull); 279 280 final var msgNotEqualsObject = "Expected result for equals(new Object()) will be 'false'. Class was : " 281 + underTest.getClass(); 282 283 assertFalse(underTest.equals(new Object()), msgNotEqualsObject); 284 285 final var msgEqualsToSelf = "Expected result for equals(underTest) will be 'true'. Class was : " 286 + underTest.getClass(); 287 288 assertTrue(underTest.equals(underTest), msgEqualsToSelf); 289 290 } 291 292 /** 293 * Verify object has implemented {@link Object#hashCode()} method. 294 * 295 * @param underTest object under test 296 */ 297 public static void assertBasicContractOnHashCode(final Object underTest) { 298 299 // basic checks to hashCode implementation 300 assertNotEquals(DEFAULT_INT_VALUE, underTest.hashCode(), 301 "Expected result of hashCode method is not '0'. Class was : " + underTest.getClass()); 302 } 303 304}