【问题标题】:How can I assert hasProperty with a Java Record?如何使用 Java 记录断言 hasProperty?
【发布时间】:2023-03-12 16:56:01
【问题描述】:

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

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

NameDto 是一个普通班级时,这工作得非常好。但是在我将其更改为 Record 后,Hamcrest 的 hasProperty 抱怨没有名为 name 的属性:

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

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

【问题讨论】:

    标签: java unit-testing hamcrest java-record java-16


    【解决方案1】:

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

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

    这是一个受现有 HasPropertyWithValue 启发的自定义 HasRecordComponentWithValue。这里使用的主要实用程序是 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);
        }
    }
    

    【讨论】:

    • 非常好,但我猜 Hamcrest 可以“修复”这个问题并在内部通过 Class::isRecord 检查这是否是 record
    • @Eugene 同意 100%,尽管我不确定它们是否与最新版本的 Java 兼容。
    • @Eugene 或 Java Beans 得到更新以赶上它自己的 Java 版本。除非 Java 设计者说记录组件不是属性(在这种情况下,Hamcrest 是正确的)。
    • 许多框架已经升级支持记录;大多数人发现这是非常少量的代码。我怀疑对 Hamcrest 维护者的礼貌请求会导致此添加。
    【解决方案2】:

    我发现AssertJ也可以实现同样的测试,至少在这种情况下:

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

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

    【讨论】:

      【解决方案3】:

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

      如果我们遵循the source for HasPropertyWithValue 的工作原理,我们会发现它通过在相关类的BeanInfo 中查找属性的PropertyDescriptor 来发现访问器方法的名称,通过java.beans.Introspector 检索。

      Introspector 有一些非常有用的文档,说明如何解决给定类的 BeanInfo

      Introspector 类为工具提供了一种标准的学习方式 关于目标 Java 支持的属性、事件和方法 豆子。

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

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

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

      如果一个类提供了关于它自己的显式 BeanInfo,那么我们将它添加到 我们通过分析任何派生得到的 BeanInfo 信息 类,但我们认为显式信息是确定性的 对于当前类及其基类,不进行任何 进一步提升超类链。

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

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

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

      我们可以在构建步骤中自动生成 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();
          }
      
      }
      

      在我们的工具箱中有这个类,我们所要做的就是用一个正确名称的类来扩展它。对于我们的示例,PointBeanInfoPoint 记录在同一个包中:

      
      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)
      

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

      【讨论】:

        猜你喜欢
        • 1970-01-01
        • 2015-08-20
        • 1970-01-01
        • 2020-07-11
        • 2014-04-12
        • 1970-01-01
        • 1970-01-01
        • 2023-03-05
        • 2017-01-09
        相关资源
        最近更新 更多