如何使用 Java 记录断言 hasProperty?
How can I assert hasProperty with a Java Record?
我在测试中有一段代码使用 Hamcrest 2.2 检查结果列表是否包含某些属性:
assertThat(result.getUsers(), hasItem(
hasProperty("name", equalTo(user1.getName()))
));
assertThat(result.getUsers(), hasItem(
hasProperty("name", equalTo(user2.getName()))
));
当 NameDto
是一个正常的 class 时,这工作得很好。但是在我将其更改为 Record
之后,Hamcrest 的 hasProperty
抱怨没有 属性 命名为 name
:
java.lang.AssertionError:
Expected: a collection containing hasProperty("name", "Test Name")
but: mismatches were: [No property "name", No property "name"]
我可以使用其他一些匹配器来实现与以前相同的匹配吗?或者我可以用来让它处理记录的其他一些解决方法?
我发现使用仅 AssertJ 可以实现相同的测试,至少在这种情况下:
assertThat(result.getUsers())
.extracting(UserDto::name)
.contains(user1.getName(), user2.getName());
它没有使用 hasProperty
,所以它并没有完全解决问题。
记录字段的访问器方法不遵循常规 JavaBeans 约定,因此 User
记录(比如 public record User (String name) {}
)将有一个名为 name()
的访问器方法getName()
.
我怀疑这就是 Hamcrest 认为没有 属性 的原因。除了编写自定义匹配器之外,我认为在 Hamcrest 中没有开箱即用的方法。
这是受现有 HasPropertyWithValue
. The main utility leveraged here is Java's Class.getRecordComponents()
启发的自定义 HasRecordComponentWithValue
:
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);
}
}
Hamcrest 实际上遵循 JavaBeans 标准(允许任意访问器名称),因此我们可以 使用 hasProperty
来做到这一点。如果你想。不过,我不确定你是否这样做 - 这很麻烦。
如果我们遵循 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
的工作原理。
Introspector
有一些非常有用的文档,说明如何解决给定 class 的 BeanInfo
:
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).
您可能认为 Introspector
可以在最后一步(“我们使用低级反射”)中 grok 记录并生成正确的 BeanInfo
,但事实并非如此。如果你 google 稍等一下,你会在 JDK 开发列表上找到一些关于添加此功能的讨论,但似乎什么也没有发生。可能是 JavaBeans 规范必须更新,我想这可能需要一些时间。
但是,要回答您的问题,我们所要做的就是为您拥有的每种记录类型提供 BeanInfo
。然而,手写它们并不是我们想要做的事情——它甚至比用 getter 和 setter(以及 equals
和 hashCode
等等编写 classes 的老式方式更糟糕).
我们可以在构建步骤中自动生成 bean 信息(或者在启动应用程序时动态生成)。一种更简单的方法(需要一些样板)是制作一个通用的 BeanInfo
可以用于所有记录 classes。这是一种最省力的方法。首先,假设我们有这条记录:
public record Point(int x, int y){}
还有一个将其视为 bean 的主要 class:
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();
}
}
在我们的工具箱中有了这个 class,我们所要做的就是用正确的名称 class 扩展它。对于我们的示例,PointBeanInfo
与 Point
记录在同一包中:
public class PointBeanInfo extends RecordBeanInfo {
public PointBeanInfo() throws IntrospectionException {
super(Point.class);
}
}
有了所有这些东西,我们 运行 我们的主要 class 并得到预期的输出:
$ java Main x y z
Prop x: 4
Prop y: 2
Prop z: (No property with that name)
结束语:如果您只是想使用属性让您的单元测试看起来更好,我建议使用其他答案中给出的解决方法之一,而不是我提出的过度设计的方法。
我在测试中有一段代码使用 Hamcrest 2.2 检查结果列表是否包含某些属性:
assertThat(result.getUsers(), hasItem(
hasProperty("name", equalTo(user1.getName()))
));
assertThat(result.getUsers(), hasItem(
hasProperty("name", equalTo(user2.getName()))
));
当 NameDto
是一个正常的 class 时,这工作得很好。但是在我将其更改为 Record
之后,Hamcrest 的 hasProperty
抱怨没有 属性 命名为 name
:
java.lang.AssertionError:
Expected: a collection containing hasProperty("name", "Test Name")
but: mismatches were: [No property "name", No property "name"]
我可以使用其他一些匹配器来实现与以前相同的匹配吗?或者我可以用来让它处理记录的其他一些解决方法?
我发现使用仅 AssertJ 可以实现相同的测试,至少在这种情况下:
assertThat(result.getUsers())
.extracting(UserDto::name)
.contains(user1.getName(), user2.getName());
它没有使用 hasProperty
,所以它并没有完全解决问题。
记录字段的访问器方法不遵循常规 JavaBeans 约定,因此 User
记录(比如 public record User (String name) {}
)将有一个名为 name()
的访问器方法getName()
.
我怀疑这就是 Hamcrest 认为没有 属性 的原因。除了编写自定义匹配器之外,我认为在 Hamcrest 中没有开箱即用的方法。
这是受现有 HasPropertyWithValue
. The main utility leveraged here is Java's Class.getRecordComponents()
启发的自定义 HasRecordComponentWithValue
:
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);
}
}
Hamcrest 实际上遵循 JavaBeans 标准(允许任意访问器名称),因此我们可以 使用 hasProperty
来做到这一点。如果你想。不过,我不确定你是否这样做 - 这很麻烦。
如果我们遵循 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
的工作原理。
Introspector
有一些非常有用的文档,说明如何解决给定 class 的 BeanInfo
:
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).
您可能认为 Introspector
可以在最后一步(“我们使用低级反射”)中 grok 记录并生成正确的 BeanInfo
,但事实并非如此。如果你 google 稍等一下,你会在 JDK 开发列表上找到一些关于添加此功能的讨论,但似乎什么也没有发生。可能是 JavaBeans 规范必须更新,我想这可能需要一些时间。
但是,要回答您的问题,我们所要做的就是为您拥有的每种记录类型提供 BeanInfo
。然而,手写它们并不是我们想要做的事情——它甚至比用 getter 和 setter(以及 equals
和 hashCode
等等编写 classes 的老式方式更糟糕).
我们可以在构建步骤中自动生成 bean 信息(或者在启动应用程序时动态生成)。一种更简单的方法(需要一些样板)是制作一个通用的 BeanInfo
可以用于所有记录 classes。这是一种最省力的方法。首先,假设我们有这条记录:
public record Point(int x, int y){}
还有一个将其视为 bean 的主要 class:
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();
}
}
在我们的工具箱中有了这个 class,我们所要做的就是用正确的名称 class 扩展它。对于我们的示例,PointBeanInfo
与 Point
记录在同一包中:
public class PointBeanInfo extends RecordBeanInfo {
public PointBeanInfo() throws IntrospectionException {
super(Point.class);
}
}
有了所有这些东西,我们 运行 我们的主要 class 并得到预期的输出:
$ java Main x y z
Prop x: 4
Prop y: 2
Prop z: (No property with that name)
结束语:如果您只是想使用属性让您的单元测试看起来更好,我建议使用其他答案中给出的解决方法之一,而不是我提出的过度设计的方法。