javaunit-testinghamcrestjava-recordjava-16

How can I assert hasProperty with a Java Record?


I have a piece of code in a test that checks that a list of results contains certain properties, using Hamcrest 2.2:

assertThat(result.getUsers(), hasItem(
    hasProperty("name", equalTo(user1.getName()))
));
assertThat(result.getUsers(), hasItem(
    hasProperty("name", equalTo(user2.getName()))
));

This worked perfectly fine when NameDto was a normal class. But after I changed it to a Record, Hamcrest's hasProperty complains about there being no property named name:

java.lang.AssertionError:
Expected: a collection containing hasProperty("name", "Test Name")
     but: mismatches were: [No property "name", No property "name"]

Is there some other matcher I can use to achieve the same matching as before? Or some other workaround I can use to get it to work with records?


Solution

  • The accessor method of a record field does not follow the regular JavaBeans convention, so the User record (say public record User (String name) {}) will have an accessor method whose name is name() instead of getName().

    I suspect this is why Hamcrest considers there is no property. I don't think there is a way out-of-the-box in Hamcrest other than to write a custom Matcher.

    Here's a custom HasRecordComponentWithValue inspired by the existing HasPropertyWithValue. The main utility leveraged here is Java's Class.getRecordComponents():

    public static class HasRecordComponentWithValue<T> extends TypeSafeDiagnosingMatcher<T> {
        private static final Condition.Step<RecordComponent,Method> WITH_READ_METHOD = withReadMethod();
        private final String componentName;
        private final Matcher<Object> valueMatcher;
    
        public HasRecordComponentWithValue(String componentName, Matcher<?> valueMatcher) {
            this.componentName = componentName;
            this.valueMatcher = nastyGenericsWorkaround(valueMatcher);
        }
    
        @Override
        public boolean matchesSafely(T bean, Description mismatch) {
            return recordComponentOn(bean, mismatch)
                      .and(WITH_READ_METHOD)
                      .and(withPropertyValue(bean))
                      .matching(valueMatcher, "record component'" + componentName + "' ");
        }
    
        private Condition.Step<Method, Object> withPropertyValue(final T bean) {
            return new Condition.Step<Method, Object>() {
                @Override
                public Condition<Object> apply(Method readMethod, Description mismatch) {
                    try {
                        return matched(readMethod.invoke(bean, NO_ARGUMENTS), mismatch);
                    } catch (Exception e) {
                        mismatch.appendText(e.getMessage());
                        return notMatched();
                    }
                }
            };
        }
    
        @Override
        public void describeTo(Description description) {
            description.appendText("hasRecordComponent(").appendValue(componentName).appendText(", ")
                       .appendDescriptionOf(valueMatcher).appendText(")");
        }
    
        private Condition<RecordComponent> recordComponentOn(T bean, Description mismatch) {
            RecordComponent[] recordComponents = bean.getClass().getRecordComponents();
            for(RecordComponent comp : recordComponents) {
                if(comp.getName().equals(componentName)) {
                    return matched(comp, mismatch);
                }
            }
            mismatch.appendText("No record component \"" + componentName + "\"");
            return notMatched();
        }
    
    
        @SuppressWarnings("unchecked")
        private static Matcher<Object> nastyGenericsWorkaround(Matcher<?> valueMatcher) {
            return (Matcher<Object>) valueMatcher;
        }
    
        private static Condition.Step<RecordComponent,Method> withReadMethod() {
            return new Condition.Step<RecordComponent, java.lang.reflect.Method>() {
                @Override
                public Condition<Method> apply(RecordComponent property, Description mismatch) {
                    final Method readMethod = property.getAccessor();
                    if (null == readMethod) {
                        mismatch.appendText("record component \"" + property.getName() + "\" is not readable");
                        return notMatched();
                    }
                    return matched(readMethod, mismatch);
                }
            };
        }
    
        @Factory
        public static <T> Matcher<T> hasRecordComponent(String componentName, Matcher<?> valueMatcher) {
            return new HasRecordComponentWithValue<T>(componentName, valueMatcher);
        }
    }