SamePropertyValuesAs.java
package org.hamcrest.beans;
import org.hamcrest.Description;
import org.hamcrest.DiagnosingMatcher;
import org.hamcrest.Matcher;
import java.beans.FeatureDescriptor;
import java.beans.MethodDescriptor;
import java.beans.PropertyDescriptor;
import java.lang.reflect.Method;
import java.util.*;
import static java.util.Arrays.asList;
import static org.hamcrest.beans.PropertyUtil.NO_ARGUMENTS;
import static org.hamcrest.beans.PropertyUtil.propertyDescriptorsFor;
import static org.hamcrest.beans.PropertyUtil.recordReadAccessorMethodDescriptorsFor;
import static org.hamcrest.core.IsEqual.equalTo;
/**
* A matcher that checks if a given bean has the same property values
* as an example bean.
* @param <T> the matcher value type.
* @see #samePropertyValuesAs(Object, String...)
*/
public class SamePropertyValuesAs<T> extends DiagnosingMatcher<T> {
private final T expectedBean;
private final Set<String> propertyNames;
private final List<PropertyMatcher> propertyMatchers;
private final List<String> ignoredFields;
/**
* Constructor, best called from {@link #samePropertyValuesAs(Object, String...)}.
* @param expectedBean the bean object with the expected values
* @param ignoredProperties list of property names that should be excluded from the match
*/
@SuppressWarnings("WeakerAccess")
public SamePropertyValuesAs(T expectedBean, List<String> ignoredProperties) {
FeatureDescriptor[] descriptors = propertyDescriptorsFor(expectedBean, Object.class);
if (descriptors == null || descriptors.length == 0) {
descriptors = recordReadAccessorMethodDescriptorsFor(expectedBean, Object.class);
}
this.expectedBean = expectedBean;
this.ignoredFields = ignoredProperties;
this.propertyNames = propertyNamesFrom(descriptors, ignoredProperties);
this.propertyMatchers = propertyMatchersFor(expectedBean, descriptors, ignoredProperties);
}
@Override
protected boolean matches(Object actual, Description mismatch) {
return isNotNull(actual, mismatch)
&& isCompatibleType(actual, mismatch)
&& hasNoExtraProperties(actual, mismatch)
&& hasMatchingValues(actual, mismatch);
}
@Override
public void describeTo(Description description) {
description.appendText("same property values as " + expectedBean.getClass().getSimpleName())
.appendList(" [", ", ", "]", propertyMatchers);
if (! ignoredFields.isEmpty()) {
description.appendText(" ignoring ")
.appendValueList("[", ", ", "]", ignoredFields);
}
}
private boolean isCompatibleType(Object actual, Description mismatchDescription) {
if (expectedBean.getClass().isAssignableFrom(actual.getClass())) {
return true;
}
mismatchDescription.appendText("is incompatible type: " + actual.getClass().getSimpleName());
return false;
}
private boolean hasNoExtraProperties(Object actual, Description mismatchDescription) {
Set<String> actualPropertyNames = propertyNamesFrom(propertyDescriptorsFor(actual, Object.class), ignoredFields);
actualPropertyNames.removeAll(propertyNames);
if (!actualPropertyNames.isEmpty()) {
mismatchDescription.appendText("has extra properties called " + actualPropertyNames);
return false;
}
return true;
}
private boolean hasMatchingValues(Object actual, Description mismatchDescription) {
for (PropertyMatcher propertyMatcher : propertyMatchers) {
if (!propertyMatcher.matches(actual)) {
propertyMatcher.describeMismatch(actual, mismatchDescription);
return false;
}
}
return true;
}
private static <T> List<PropertyMatcher> propertyMatchersFor(T bean, FeatureDescriptor[] descriptors, List<String> ignoredFields) {
List<PropertyMatcher> result = new ArrayList<>(descriptors.length);
for (FeatureDescriptor descriptor : descriptors) {
if (isNotIgnored(ignoredFields, descriptor)) {
result.add(new PropertyMatcher(descriptor, bean));
}
}
return result;
}
private static Set<String> propertyNamesFrom(FeatureDescriptor[] descriptors, List<String> ignoredFields) {
HashSet<String> result = new HashSet<>();
for (FeatureDescriptor descriptor : descriptors) {
if (isNotIgnored(ignoredFields, descriptor)) {
result.add(descriptor.getDisplayName());
}
}
return result;
}
private static boolean isNotIgnored(List<String> ignoredFields, FeatureDescriptor propertyDescriptor) {
return ! ignoredFields.contains(propertyDescriptor.getDisplayName());
}
@SuppressWarnings("WeakerAccess")
private static class PropertyMatcher extends DiagnosingMatcher<Object> {
private final Method readMethod;
private final Matcher<Object> matcher;
private final String propertyName;
public PropertyMatcher(FeatureDescriptor descriptor, Object expectedObject) {
this.propertyName = descriptor.getDisplayName();
this.readMethod = descriptor instanceof PropertyDescriptor ?
((PropertyDescriptor) descriptor).getReadMethod() :
((MethodDescriptor) descriptor).getMethod();
this.matcher = equalTo(readProperty(readMethod, expectedObject));
}
@Override
public boolean matches(Object actual, Description mismatch) {
final Object actualValue = readProperty(readMethod, actual);
if (!matcher.matches(actualValue)) {
mismatch.appendText(propertyName + " ");
matcher.describeMismatch(actualValue, mismatch);
return false;
}
return true;
}
@Override
public void describeTo(Description description) {
description.appendText(propertyName + ": ").appendDescriptionOf(matcher);
}
}
private static Object readProperty(Method method, Object target) {
try {
return method.invoke(target, NO_ARGUMENTS);
} catch (Exception e) {
throw new IllegalArgumentException("Could not invoke " + method + " on " + target, e);
}
}
/**
* <p>Creates a matcher that matches when the examined object has values for all of
* its JavaBean properties that are equal to the corresponding values of the
* specified bean. If any properties are marked as ignored, they will be dropped from
* both the expected and actual bean. Note that the ignored properties use JavaBean
* display names, for example "<code>age</code>" rather than method names such as
* "<code>getAge</code>".
* </p>
* For example:
* <pre>{@code
* assertThat(myBean, samePropertyValuesAs(myExpectedBean))
* assertThat(myBean, samePropertyValuesAs(myExpectedBean), "age", "height")
* }</pre>
*
* @param <B> the matcher value type.
* @param expectedBean the bean against which examined beans are compared
* @param ignoredProperties do not check any of these named properties.
* @return The matcher.
*/
public static <B> Matcher<B> samePropertyValuesAs(B expectedBean, String... ignoredProperties) {
return new SamePropertyValuesAs<>(expectedBean, asList(ignoredProperties));
}
}