12

我在测试中有一段代码使用 Hamcrest 2.2 检查结果列表是否包含某些属性:

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

NameDto这在正常上课时效果很好。但是在我将其更改为 a 之后Record,HamcresthasProperty抱怨没有名为的属性name

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

我可以使用其他匹配器来实现与以前相同的匹配吗?或者我可以使用其他一些解决方法来让它与记录一起使用?

4

3 回答 3

11

记录字段的访问器方法不遵循常规的 JavaBeans 约定,因此User记录(例如public record User (String name) {})将具有名称为name()而不是 的访问器方法getName()

我怀疑这就是 Hamcrest 认为没有财产的原因。除了编写自定义 Matcher 之外,我认为在 Hamcrest 中没有开箱即用的方法。

这是一个HasRecordComponentWithValue受现有HasPropertyWithValue. 这里使用的主要实用程序是 Java 的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);
    }
}
于 2021-04-07T09:26:59.200 回答
4

我发现仅使用 AssertJ 就可以实现相同的测试,至少在这种情况下:

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

它没有使用hasProperty,所以它并不能完全解决问题。

于 2021-04-07T09:14:43.380 回答
2

Hamcrest 实际上遵循 JavaBeans 标准(允许任意访问器名称),因此我们可以使用hasProperty. 如果你想。不过,我不确定你是否这样做 - 这很麻烦。

如果我们遵循源代码HasPropertyWithValue的工作原理,我们会发现它通过在相关类PropertyDescriptor的 中查找属性来发现访问器方法的名称,并通过.BeanInfojava.beans.Introspector

有一些关于如何解决给定类的非常Introspector有用的文档:BeanInfo

Introspector 类为工具提供了一种了解目标 Java Bean 支持的属性、事件和方法的标准方法。

对于这三种信息中的每一种,Introspector 将分别分析 bean 的类和超类以查找显式或隐式信息,并使用该信息构建一个全面描述目标 bean 的 BeanInfo 对象。

对于每个类“Foo”,如果存在相应的“FooBeanInfo”类,该类在查询信息时提供非空值,则可以使用显式信息。我们首先通过获取目标 bean 类的完整包限定名称并附加“BeanInfo”以形成新的类名来查找 BeanInfo 类。如果这失败了,那么我们取这个名称的最终类名组件,并在 BeanInfo 包搜索路径中指定的每个包中查找该类。

因此,对于诸如“sun.xyz.OurButton”之类的类,我们将首先查找名为“sun.xyz.OurButtonBeanInfo”的 BeanInfo 类,如果失败,我们将在 BeanInfo 搜索路径中的每个包中查找 OurButtonBeanInfo 类。使用默认搜索路径,这意味着查找“sun.beans.infos.OurButtonBeanInfo”。

如果一个类提供了关于它自己的显式 BeanInfo,那么我们将它添加到我们通过分析任何派生类获得的 BeanInfo 信息中,但我们认为显式信息对于当前类及其基类是确定的,并且不再继续往下超类链。

如果我们没有在类上找到显式的 BeanInfo,我们会使用低级反射来研究类的方法并应用标准设计模式来识别属性访问器、事件源或公共方法。然后我们继续分析类的超类并添加来自它的信息(并且可能在超类链上)。

您会认为可以在最后一步(“我们使用低级反射”)中Introspector挖掘记录并生成正确的记录,但似乎并非如此。BeanInfo如果你谷歌一下,你会在 JDK 开发列表上找到一些关于添加它的讨论,但似乎什么也没发生。可能是 JavaBeans 规范必须更新,我想这可能需要一些时间。

但是,要回答您的问题,我们所要做的就是为BeanInfo您拥有的每种记录类型提供一个。equals然而,手写它们并不是我们想要做的事情——它甚至比使用 getter和setter(等等)编写类的老式方式更糟糕hashCode

我们可以自动生成 bean 信息作为构建步骤(或者在我们启动应用程序时动态生成)。一种更简单的方法(需要一些样板文件)是制作BeanInfo可用于所有记录类的泛型。这是一种最小努力的方法。首先,假设我们有这个记录:

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)

结束语:如果您只想使用属性使单元测试看起来更好,我建议使用其他答案中给出的解决方法之一,而不是我提出的过度设计的方法。

于 2021-04-07T11:08:12.683 回答