How can I assert hasProperty with a Java Record?
Asked Answered
S

4

19

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?

Sexed answered 7/4, 2021 at 8:45 Comment(0)
O
14

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);
    }
}
Outer answered 7/4, 2021 at 9:26 Comment(5)
very nice, but I guess Hamcrest could "fix" this and internally check if that is a record via Class::isRecordQuinquennial
@Quinquennial Agreed 100%, although I'm not sure they are compatible with recent versions of Java.Outer
@Quinquennial or Java Beans gets updated to catch up with its own Java version. Unless the Java designers say that record components are not properties (in which case Hamcrest would be right).Carrara
A number of frameworks have already upgraded to support records; most have found this to be a very small amount of code. I suspect a polite request to the Hamcrest maintainers would result in this addition.Beatrisbeatrisa
I now submitted an issue in Hamcrest to point out this problem: github.com/hamcrest/JavaHamcrest/issues/392Homicide
S
6

I found that the same test can be achieved using only AssertJ, at least in this case:

assertThat(result.getUsers())
        .extracting(UserDto::name)
        .contains(user1.getName(), user2.getName());

It's not using hasProperty, so it doesn't exactly solve the question though.

Sexed answered 7/4, 2021 at 9:14 Comment(0)
A
2

Hamcrest actually follows the JavaBeans standard (which allows for arbitrary accessor names), so we can do this with hasProperty. If you want to. I'm not sure you do, though - it's quite a hassle.

If we follow the workings of the source for HasPropertyWithValue we find that it discovers the accessor method's name by finding the PropertyDescriptor for the property in the BeanInfo of the class concerned, retrieved by means of the java.beans.Introspector.

The Introspector has some very helpful documentation on how BeanInfo for a given class is resolved:

The Introspector class provides a standard way for tools to learn about the properties, events, and methods supported by a target Java Bean.

For each of those three kinds of information, the Introspector will separately analyze the bean's class and superclasses looking for either explicit or implicit information and use that information to build a BeanInfo object that comprehensively describes the target bean.

For each class "Foo", explicit information may be available if there exists a corresponding "FooBeanInfo" class that provides a non-null value when queried for the information. We first look for the BeanInfo class by taking the full package-qualified name of the target bean class and appending "BeanInfo" to form a new class name. If this fails, then we take the final classname component of this name, and look for that class in each of the packages specified in the BeanInfo package search path.

Thus for a class such as "sun.xyz.OurButton" we would first look for a BeanInfo class called "sun.xyz.OurButtonBeanInfo" and if that failed we'd look in each package in the BeanInfo search path for an OurButtonBeanInfo class. With the default search path, this would mean looking for "sun.beans.infos.OurButtonBeanInfo".

If a class provides explicit BeanInfo about itself then we add that to the BeanInfo information we obtained from analyzing any derived classes, but we regard the explicit information as being definitive for the current class and its base classes, and do not proceed any further up the superclass chain.

If we don't find explicit BeanInfo on a class, we use low-level reflection to study the methods of the class and apply standard design patterns to identify property accessors, event sources, or public methods. We then proceed to analyze the class's superclass and add in the information from it (and possibly on up the superclass chain).

You'd think the Introspector could grok records and generate correct BeanInfo in that last step (where "we use low-level reflection"), but it appears not to. If you google for a bit you'll find some talk on the JDK dev list about adding this, but nothing seems to have happened. Might be that the JavaBeans spec has to be updated, which I imagine could take some time.

But, to answer your question, all we have to do is provide a BeanInfo for each and every record type you have. Handwriting them, however, isn't something we want to do - it's even worse than the old-fashioned way of writing classes with getters and setters (and equals and hashCode and whatnot).

We could autogenerate the bean info as a build step (or dynamically when we start the app). A somewhat simpler approach (which requires a bit of boilerplate) is making a generic BeanInfo that can be used for all record classes. Here's a minimum-effort approach. First, suppose we have this record:

public record Point(int x, int y){}

And a main class that treats it as a bean:

public class Main {
    public static void main(String[] args) throws Exception {
        var bi = java.beans.Introspector.getBeanInfo(Point.class);
        var bean = new Point(4, 2);
        for (var prop : args) {
            Object value = Stream.of(bi.getPropertyDescriptors())
                .filter(pd -> pd.getName().equals(prop))
                .findAny()
                .map(pd -> {
                    try {
                        return pd.getReadMethod().invoke(bean);
                    } catch (ReflectiveOperationException e) {
                        return "Error: " + e;
                    }
                })
                .orElse("(No property with that name)");
            System.out.printf("Prop %s: %s%n", prop, value);
        }
    }
}

If we just compile and run like java Main x y z you get output like this:

Prop x: (No property with that name)
Prop y: (No property with that name)
Prop z: (No property with that name)

So it doesn't find the record components, as expected. Let's make a generic BeanInfo:

public abstract class RecordBeanInfo extends java.beans.SimpleBeanInfo {

    private final PropertyDescriptor[] propertyDescriptors;

    public RecordBeanInfo(Class<?> recordClass) throws IntrospectionException {
        if (!recordClass.isRecord())
            throw new IllegalArgumentException("Not a record: " + recordClass);
        var components = recordClass.getRecordComponents();
        propertyDescriptors = new PropertyDescriptor[components.length];
        for (var i = 0; i < components.length; i++) {
            var c = components[i];
            propertyDescriptors[i] = new PropertyDescriptor(c.getName(), c.getAccessor(), null);
        }
    }

    @Override
    public PropertyDescriptor[] getPropertyDescriptors() {
        return this.propertyDescriptors.clone();
    }

}

Having this class in our toolbox, all we have to do is extend it with a class of the right name. For our example, PointBeanInfo in the same package as the Point record:


public class PointBeanInfo extends RecordBeanInfo {
    public PointBeanInfo() throws IntrospectionException {
        super(Point.class);
    }
}

With all these things in place, we run our main class and get the expected output:

$ java Main x y z
Prop x: 4
Prop y: 2
Prop z: (No property with that name)

Closing note: If you just want to use properties to make your unit tests look nicer, I suggest using one of the workarounds given in other answers, rather than the overengineered approach I present.

Aurochs answered 7/4, 2021 at 11:8 Comment(0)
P
0

The simplest I've found is to use a FeatureMatcher.

Start with a generic factory method to create it: (It would be nice if this was in hamcrest-core)

public static <T, U> Matcher<? super T> hasProperty(
        String name,
        Function<T, U> extractor,
        Matcher<U> subMatcher
) {
     return new FeatureMatcher<>(subMatcher, "has property " + name, name) {
         @Override
         protected U featureValueOf(T target) {
            return extractor.apply(target);
        }
    };
}

It's also nice to add a factory for a Matcher based on your specific property:

Matcher<? super User> aUserWithSameNameAs(User expected) {
    return hasProperty("name", User::name, equalTo(expected.name()));
}

Your test then becomes:

assertThat(result.getUsers(), hasItems(
    aUserWithSameNameAs(user1),
    aUserWithSameNameAs(user2)
));
Porism answered 12/4 at 14:10 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.