Как я могу подтвердить hasProperty с помощью записи Java?

У меня есть фрагмент кода в тесте, который проверяет, содержит ли список результатов определенные свойства, используя Hamcrest 2.2:

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

Это прекрасно работало, когда NameDtoбыл нормальный класс. Но после того, как я изменил его на Record, Hamcrest's hasProperty жалуется на отсутствие названного имущества name:

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

Есть ли какой-нибудь другой сопоставитель, который я могу использовать для достижения такого же сопоставления, как и раньше? Или какой-то другой обходной путь, который я могу использовать, чтобы заставить его работать с записями?

3 ответа

Метод доступа к полю записи не соответствует обычному соглашению JavaBeans, поэтому User запись (скажем public record User (String name) {}) будет иметь метод доступа с именем name() вместо getName().

Я подозреваю, что именно поэтому Хэмкрест считает, что собственности нет. Я не думаю, что в Hamcrest есть выход из коробки, кроме как написать собственный Matcher.

Вот такой обычай HasRecordComponentWithValue вдохновленный существующими HasPropertyWithValue. Основная утилита, используемая здесь, - это JavaClass.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);
    }
}

Я обнаружил, что тот же тест можно выполнить, используя только AssertJ, по крайней мере, в этом случае:

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

Он не использует hasProperty, так что это не совсем решает вопрос.

Hamcrest фактически следует стандарту JavaBeans (который позволяет использовать произвольные имена средств доступа), поэтому мы можем сделать это с помощью hasProperty. Если вы хотите. Хотя я не уверен, что это так - это довольно хлопотно.

Если мы будем следить за работой источника для HasPropertyWithValue мы обнаруживаем, что он обнаруживает имя метода доступа, найдя PropertyDescriptor для собственности в соответствующего класса, полученного с помощью java.beans.Introspector.

В нем есть очень полезная документация о том, как разрешается данный класс:

Класс Introspector предоставляет инструментам стандартный способ узнать о свойствах, событиях и методах, поддерживаемых целевым Java Bean.

Для каждого из этих трех видов информации Introspector будет отдельно анализировать класс и суперклассы bean-компонента в поисках явной или неявной информации и использовать эту информацию для создания объекта BeanInfo, который всесторонне описывает целевой bean-компонент.

Для каждого класса «Foo» может быть доступна явная информация, если существует соответствующий класс «FooBeanInfo», который предоставляет ненулевое значение при запросе информации. Сначала мы ищем класс BeanInfo, беря полное имя целевого класса bean-компонента с указанием пакета и добавляя «BeanInfo» для формирования имени нового класса. Если это не удается, мы берем последний компонент имени класса с этим именем и ищем этот класс в каждом из пакетов, указанных в пути поиска пакетов BeanInfo.

Таким образом, для такого класса, как «sun.xyz.OurButton», мы сначала ищем класс BeanInfo с именем «sun.xyz.OurButtonBeanInfo», и если это не удается, мы будем искать в каждом пакете в пути поиска BeanInfo класс OurButtonBeanInfo. При использовании пути поиска по умолчанию это будет означать поиск «sun.beans.infos.OurButtonBeanInfo».

Если класс предоставляет явную информацию BeanInfo о себе, то мы добавляем ее к информации BeanInfo, полученной нами в результате анализа любых производных классов, но мы рассматриваем явную информацию как окончательную для текущего класса и его базовых классов и не продвигаемся дальше по цепочка суперклассов.

Если мы не находим явного BeanInfo в классе, мы используем низкоуровневое отражение для изучения методов класса и применяем стандартные шаблоны проектирования для идентификации средств доступа к свойствам, источников событий или общедоступных методов. Затем мы приступаем к анализу суперкласса класса и добавляем информацию из него (и, возможно, дальше по цепочке суперкласса).

Вы могли бы подумать Introspectorможет найти записи и сгенерировать правильные на этом последнем шаге (где «мы используем низкоуровневое отражение»), но, похоже, этого не произошло. Если вы немного погуглите, вы найдете разговоры в списке разработчиков JDK о добавлении этого, но, похоже, ничего не произошло. Возможно, потребуется обновить спецификацию JavaBeans, что, как я полагаю, может занять некоторое время.

Но, чтобы ответить на ваш вопрос, все, что нам нужно сделать, это предоставить для каждого типа записи, который у вас есть. Однако писать их от руки - это не то, чем мы хотим заниматься - это даже хуже, чем старомодный способ написания классов с помощью геттеров и сеттеров (и equals а также hashCode и что "нет).

Мы могли бы автоматически сгенерировать информацию о bean-компоненте на этапе сборки (или динамически при запуске приложения). Несколько более простой подход (который требует небольшого количества шаблонов) - это создание универсального кода, который можно использовать для всех классов записей. Вот подход с минимальными усилиями. Во-первых, предположим, что у нас есть такая запись:

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

И основной класс, который рассматривает его как 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);
        }
    }
}

Если мы просто скомпилируем и запустим как java Main x y z вы получите такой результат:

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

Таким образом, он не находит компоненты записи, как ожидалось. Сделаем общий 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();
    }

}

Имея этот класс в нашем наборе инструментов, все, что нам нужно сделать, это расширить его классом с правильным именем. В нашем примере PointBeanInfo в той же упаковке, что и Point записывать:

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

Имея все это на месте, мы запускаем наш основной класс и получаем ожидаемый результат:

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

Заключительное примечание: если вы просто хотите использовать свойства, чтобы ваши модульные тесты выглядели лучше, я предлагаю использовать один из обходных путей, указанных в других ответах, а не изощренный подход, который я представляю.

Другие вопросы по тегам