From 3d58e993a5d12e65ec1309497cf8fab4bf5f3645 Mon Sep 17 00:00:00 2001 From: Uno Kim Date: Sat, 30 Nov 2024 09:56:31 +0900 Subject: [PATCH] Make `hasProperty()`, `hasPropertyAtPath()`, `samePropertyValuesAs()` work for Java Records (#426) Make hasProperty(), hasPropertyAtPath(), samePropertyValuesAs() work for Java Records Resolves #392 --- .../src/main/java/org/hamcrest/Condition.java | 1 + .../java/org/hamcrest/beans/HasProperty.java | 4 +- .../hamcrest/beans/HasPropertyWithValue.java | 61 +++---- .../java/org/hamcrest/beans/PropertyUtil.java | 77 ++++++++- .../hamcrest/beans/SamePropertyValuesAs.java | 33 ++-- .../org/hamcrest/beans/HasPropertyTest.java | 6 + .../beans/HasPropertyWithValueTest.java | 86 +++++++++- .../org/hamcrest/beans/PropertyUtilTest.java | 150 ++++++++++++++++++ .../beans/SamePropertyValuesAsTest.java | 68 ++++++++ 9 files changed, 437 insertions(+), 49 deletions(-) create mode 100644 hamcrest/src/test/java/org/hamcrest/beans/PropertyUtilTest.java diff --git a/hamcrest/src/main/java/org/hamcrest/Condition.java b/hamcrest/src/main/java/org/hamcrest/Condition.java index 85293eb1..8518ec6c 100644 --- a/hamcrest/src/main/java/org/hamcrest/Condition.java +++ b/hamcrest/src/main/java/org/hamcrest/Condition.java @@ -19,6 +19,7 @@ public abstract class Condition { * @param the initial value type * @param the next step value type */ + @FunctionalInterface public interface Step { /** * Apply this condition to a value diff --git a/hamcrest/src/main/java/org/hamcrest/beans/HasProperty.java b/hamcrest/src/main/java/org/hamcrest/beans/HasProperty.java index cce9a8b3..8494c9a3 100644 --- a/hamcrest/src/main/java/org/hamcrest/beans/HasProperty.java +++ b/hamcrest/src/main/java/org/hamcrest/beans/HasProperty.java @@ -3,7 +3,6 @@ import org.hamcrest.Description; import org.hamcrest.Matcher; import org.hamcrest.TypeSafeMatcher; -import org.hamcrest.collection.ArrayMatching; /** * A matcher that checks if an object has a JavaBean property with the @@ -31,7 +30,8 @@ public HasProperty(String propertyName) { @Override public boolean matchesSafely(T obj) { try { - return PropertyUtil.getPropertyDescriptor(propertyName, obj) != null; + return PropertyUtil.getPropertyDescriptor(propertyName, obj) != null || + PropertyUtil.getMethodDescriptor(propertyName, obj) != null; } catch (IllegalArgumentException e) { return false; } diff --git a/hamcrest/src/main/java/org/hamcrest/beans/HasPropertyWithValue.java b/hamcrest/src/main/java/org/hamcrest/beans/HasPropertyWithValue.java index a734f800..f45c1264 100644 --- a/hamcrest/src/main/java/org/hamcrest/beans/HasPropertyWithValue.java +++ b/hamcrest/src/main/java/org/hamcrest/beans/HasPropertyWithValue.java @@ -5,6 +5,8 @@ import org.hamcrest.Matcher; import org.hamcrest.TypeSafeDiagnosingMatcher; +import java.beans.FeatureDescriptor; +import java.beans.MethodDescriptor; import java.beans.PropertyDescriptor; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; @@ -26,7 +28,7 @@ *

Example Usage

* Consider the situation where we have a class representing a person, which * follows the basic JavaBean convention of having get() and possibly set() - * methods for it's properties: + * methods for its properties: *
{@code  public class Person {
  *   private String name;
  *   public Person(String person) {
@@ -69,7 +71,7 @@
  */
 public class HasPropertyWithValue extends TypeSafeDiagnosingMatcher {
 
-    private static final Condition.Step WITH_READ_METHOD = withReadMethod();
+    private static final Condition.Step WITH_READ_METHOD = withReadMethod();
     private final String propertyName;
     private final Matcher valueMatcher;
     private final String messageFormat;
@@ -111,8 +113,11 @@ public void describeTo(Description description) {
                    .appendDescriptionOf(valueMatcher).appendText(")");
     }
 
-    private Condition propertyOn(T bean, Description mismatch) {
-        PropertyDescriptor property = PropertyUtil.getPropertyDescriptor(propertyName, bean);
+    private Condition propertyOn(T bean, Description mismatch) {
+        FeatureDescriptor property = PropertyUtil.getPropertyDescriptor(propertyName, bean);
+        if (property == null) {
+            property = PropertyUtil.getMethodDescriptor(propertyName, bean);
+        }
         if (property == null) {
             mismatch.appendText("No property \"" + propertyName + "\"");
             return notMatched();
@@ -122,22 +127,19 @@ private Condition propertyOn(T bean, Description mismatch) {
     }
 
     private Condition.Step withPropertyValue(final T bean) {
-        return new Condition.Step() {
-            @Override
-            public Condition apply(Method readMethod, Description mismatch) {
-                try {
-                    return matched(readMethod.invoke(bean, NO_ARGUMENTS), mismatch);
-                } catch (InvocationTargetException e) {
-                    mismatch
-                      .appendText("Calling '")
-                      .appendText(readMethod.toString())
-                      .appendText("': ")
-                      .appendValue(e.getTargetException().getMessage());
-                    return notMatched();
-                } catch (Exception e) {
-                    throw new IllegalStateException(
-                      "Calling: '" + readMethod + "' should not have thrown " + e);
-                }
+        return (readMethod, mismatch) -> {
+            try {
+                return matched(readMethod.invoke(bean, NO_ARGUMENTS), mismatch);
+            } catch (InvocationTargetException e) {
+                mismatch
+                  .appendText("Calling '")
+                  .appendText(readMethod.toString())
+                  .appendText("': ")
+                  .appendValue(e.getTargetException().getMessage());
+                return notMatched();
+            } catch (Exception e) {
+                throw new IllegalStateException(
+                  "Calling: '" + readMethod + "' should not have thrown " + e);
             }
         };
     }
@@ -147,17 +149,16 @@ private static Matcher nastyGenericsWorkaround(Matcher valueMatcher)
         return (Matcher) valueMatcher;
     }
 
-    private static Condition.Step withReadMethod() {
-        return new Condition.Step() {
-            @Override
-            public Condition apply(PropertyDescriptor property, Description mismatch) {
-                final Method readMethod = property.getReadMethod();
-                if (null == readMethod) {
-                    mismatch.appendText("property \"" + property.getName() + "\" is not readable");
-                    return notMatched();
-                }
-                return matched(readMethod, mismatch);
+    private static Condition.Step withReadMethod() {
+        return (property, mismatch) -> {
+            final Method readMethod = property instanceof PropertyDescriptor ?
+                    ((PropertyDescriptor) property).getReadMethod() :
+                    (((MethodDescriptor) property).getMethod());
+            if (null == readMethod || readMethod.getReturnType() == void.class) {
+                mismatch.appendText("property \"" + property.getName() + "\" is not readable");
+                return notMatched();
             }
+            return matched(readMethod, mismatch);
         };
     }
 
diff --git a/hamcrest/src/main/java/org/hamcrest/beans/PropertyUtil.java b/hamcrest/src/main/java/org/hamcrest/beans/PropertyUtil.java
index 71b7dcea..40d5a37d 100644
--- a/hamcrest/src/main/java/org/hamcrest/beans/PropertyUtil.java
+++ b/hamcrest/src/main/java/org/hamcrest/beans/PropertyUtil.java
@@ -2,7 +2,12 @@
 
 import java.beans.IntrospectionException;
 import java.beans.Introspector;
+import java.beans.MethodDescriptor;
 import java.beans.PropertyDescriptor;
+import java.lang.reflect.Field;
+import java.util.Arrays;
+import java.util.Set;
+import java.util.stream.Collectors;
 
 /**
  * Utility class with static methods for accessing properties on JavaBean objects.
@@ -11,6 +16,7 @@
  *
  * @author Iain McGinniss
  * @author Steve Freeman
+ * @author Uno Kim
  * @since 1.1.0
  */
 public class PropertyUtil {
@@ -27,7 +33,7 @@ private PropertyUtil() {
      * @param fromObj
      *     the object to check.
      * @return the descriptor of the property, or null if the property does not exist.
-     * @throws IllegalArgumentException if there's a introspection failure
+     * @throws IllegalArgumentException if there's an introspection failure
      */
     public static PropertyDescriptor getPropertyDescriptor(String propertyName, Object fromObj) throws IllegalArgumentException {
         for (PropertyDescriptor property : propertyDescriptorsFor(fromObj, null)) {
@@ -45,7 +51,7 @@ public static PropertyDescriptor getPropertyDescriptor(String propertyName, Obje
      * @param fromObj Use the class of this object
      * @param stopClass Don't include any properties from this ancestor class upwards.
      * @return Property descriptors
-     * @throws IllegalArgumentException if there's a introspection failure
+     * @throws IllegalArgumentException if there's an introspection failure
      */
     public static PropertyDescriptor[] propertyDescriptorsFor(Object fromObj, Class stopClass) throws IllegalArgumentException {
       try {
@@ -55,6 +61,73 @@ public static PropertyDescriptor[] propertyDescriptorsFor(Object fromObj, Class<
       }
     }
 
+    /**
+     * Returns the description of the read accessor method with the provided
+     * name on the provided object's interface.
+     * This is what you need when you try to find a property from a target object
+     * when it doesn't follow standard JavaBean specification, a Java Record for example.
+     *
+     * @param propertyName the object property name.
+     * @param fromObj the object to check.
+     * @return the descriptor of the method, or null if the method does not exist.
+     * @throws IllegalArgumentException if there's an introspection failure
+     * @see Java Records
+     *
+     */
+    public static MethodDescriptor getMethodDescriptor(String propertyName, Object fromObj) throws IllegalArgumentException {
+        for (MethodDescriptor method : recordReadAccessorMethodDescriptorsFor(fromObj, null)) {
+            if (method.getName().equals(propertyName)) {
+                return method;
+            }
+        }
+
+        return null;
+    }
+
+    /**
+     * Returns read accessor method descriptors for the class associated with the given object.
+     * This is useful when you find getter methods for the fields from the object
+     * when it doesn't follow standard JavaBean specification, a Java Record for example.
+     * Be careful as this doesn't return standard JavaBean getter methods, like a method starting with {@code get-}.
+     *
+     * @param fromObj Use the class of this object
+     * @param stopClass Don't include any properties from this ancestor class upwards.
+     * @return Method descriptors for read accessor methods
+     * @throws IllegalArgumentException if there's an introspection failure
+     */
+    public static MethodDescriptor[] recordReadAccessorMethodDescriptorsFor(Object fromObj, Class stopClass) throws IllegalArgumentException {
+        try {
+            Set recordComponentNames = getFieldNames(fromObj);
+            MethodDescriptor[] methodDescriptors = Introspector.getBeanInfo(fromObj.getClass(), stopClass).getMethodDescriptors();
+
+            return Arrays.stream(methodDescriptors)
+                    .filter(x -> recordComponentNames.contains(x.getDisplayName()))
+                    .filter(x -> x.getMethod().getReturnType() != void.class)
+                    .filter(x -> x.getMethod().getParameterCount() == 0)
+                    .toArray(MethodDescriptor[]::new);
+        } catch (IntrospectionException e) {
+            throw new IllegalArgumentException("Could not get method descriptors for " + fromObj.getClass(), e);
+        }
+    }
+
+    /**
+     * Returns the field names of the given object.
+     * It can be the names of the record components of Java Records, for example.
+     *
+     * @param fromObj the object to check
+     * @return The field names
+     * @throws IllegalArgumentException if there's a security issue reading the fields
+     */
+    public static Set getFieldNames(Object fromObj) throws IllegalArgumentException {
+        try {
+            return Arrays.stream(fromObj.getClass().getDeclaredFields())
+                    .map(Field::getName)
+                    .collect(Collectors.toSet());
+        } catch (SecurityException e) {
+            throw new IllegalArgumentException("Could not get record component names for " + fromObj.getClass(), e);
+        }
+    }
+
     /**
      * Empty object array, used for documenting that we are deliberately passing no arguments to a method.
      */
diff --git a/hamcrest/src/main/java/org/hamcrest/beans/SamePropertyValuesAs.java b/hamcrest/src/main/java/org/hamcrest/beans/SamePropertyValuesAs.java
index 94f4dba1..fbb0175f 100644
--- a/hamcrest/src/main/java/org/hamcrest/beans/SamePropertyValuesAs.java
+++ b/hamcrest/src/main/java/org/hamcrest/beans/SamePropertyValuesAs.java
@@ -4,6 +4,8 @@
 import org.hamcrest.DiagnosingMatcher;
 import org.hamcrest.Matcher;
 
+import java.beans.FeatureDescriptor;
+import java.beans.MethodDescriptor;
 import java.beans.PropertyDescriptor;
 import java.lang.reflect.Method;
 import java.util.*;
@@ -11,6 +13,7 @@
 import static java.util.Arrays.asList;
 import static org.hamcrest.beans.PropertyUtil.NO_ARGUMENTS;
 import static org.hamcrest.beans.PropertyUtil.propertyDescriptorsFor;
+import static org.hamcrest.beans.PropertyUtil.recordReadAccessorMethodDescriptorsFor;
 import static org.hamcrest.core.IsEqual.equalTo;
 
 /**
@@ -33,7 +36,11 @@ public class SamePropertyValuesAs extends DiagnosingMatcher {
      */
     @SuppressWarnings("WeakerAccess")
     public SamePropertyValuesAs(T expectedBean, List ignoredProperties) {
-        PropertyDescriptor[] descriptors = propertyDescriptorsFor(expectedBean, Object.class);
+        FeatureDescriptor[] descriptors = propertyDescriptorsFor(expectedBean, Object.class);
+        if (descriptors == null || descriptors.length == 0) {
+            descriptors = recordReadAccessorMethodDescriptorsFor(expectedBean, Object.class);
+        }
+
         this.expectedBean = expectedBean;
         this.ignoredFields = ignoredProperties;
         this.propertyNames = propertyNamesFrom(descriptors, ignoredProperties);
@@ -87,27 +94,27 @@ private boolean hasMatchingValues(Object actual, Description mismatchDescription
         return true;
     }
 
-    private static  List propertyMatchersFor(T bean, PropertyDescriptor[] descriptors, List ignoredFields) {
+    private static  List propertyMatchersFor(T bean, FeatureDescriptor[] descriptors, List ignoredFields) {
         List result = new ArrayList<>(descriptors.length);
-        for (PropertyDescriptor propertyDescriptor : descriptors) {
-            if (isIgnored(ignoredFields, propertyDescriptor)) {
-                result.add(new PropertyMatcher(propertyDescriptor, bean));
+        for (FeatureDescriptor descriptor : descriptors) {
+            if (isNotIgnored(ignoredFields, descriptor)) {
+                result.add(new PropertyMatcher(descriptor, bean));
             }
         }
         return result;
     }
 
-    private static Set propertyNamesFrom(PropertyDescriptor[] descriptors, List ignoredFields) {
+    private static Set propertyNamesFrom(FeatureDescriptor[] descriptors, List ignoredFields) {
         HashSet result = new HashSet<>();
-        for (PropertyDescriptor propertyDescriptor : descriptors) {
-            if (isIgnored(ignoredFields, propertyDescriptor)) {
-                result.add(propertyDescriptor.getDisplayName());
+        for (FeatureDescriptor descriptor : descriptors) {
+            if (isNotIgnored(ignoredFields, descriptor)) {
+                result.add(descriptor.getDisplayName());
             }
         }
         return result;
     }
 
-    private static boolean isIgnored(List ignoredFields, PropertyDescriptor propertyDescriptor) {
+    private static boolean isNotIgnored(List ignoredFields, FeatureDescriptor propertyDescriptor) {
         return ! ignoredFields.contains(propertyDescriptor.getDisplayName());
     }
 
@@ -117,9 +124,11 @@ private static class PropertyMatcher extends DiagnosingMatcher {
         private final Matcher matcher;
         private final String propertyName;
 
-        public PropertyMatcher(PropertyDescriptor descriptor, Object expectedObject) {
+        public PropertyMatcher(FeatureDescriptor descriptor, Object expectedObject) {
             this.propertyName = descriptor.getDisplayName();
-            this.readMethod = descriptor.getReadMethod();
+            this.readMethod = descriptor instanceof PropertyDescriptor ?
+                    ((PropertyDescriptor) descriptor).getReadMethod() :
+                    ((MethodDescriptor) descriptor).getMethod();
             this.matcher = equalTo(readProperty(readMethod, expectedObject));
         }
 
diff --git a/hamcrest/src/test/java/org/hamcrest/beans/HasPropertyTest.java b/hamcrest/src/test/java/org/hamcrest/beans/HasPropertyTest.java
index 5143a3ad..9a5b06d4 100644
--- a/hamcrest/src/test/java/org/hamcrest/beans/HasPropertyTest.java
+++ b/hamcrest/src/test/java/org/hamcrest/beans/HasPropertyTest.java
@@ -16,6 +16,7 @@
 public final class HasPropertyTest {
 
     private final HasPropertyWithValueTest.BeanWithoutInfo bean = new HasPropertyWithValueTest.BeanWithoutInfo("a bean", false);
+    private final HasPropertyWithValueTest.RecordLikeBeanWithoutInfo record = new HasPropertyWithValueTest.RecordLikeBeanWithoutInfo("a record", false);
 
     @Test public void
     copesWithNullsAndUnknownTypes() {
@@ -28,11 +29,14 @@ public final class HasPropertyTest {
     @Test public void
     matchesWhenThePropertyExists() {
         assertMatches(hasProperty("writeOnlyProperty"), bean);
+        assertMatches(hasProperty("property"), record);
     }
 
     @Test public void
     doesNotMatchIfPropertyDoesNotExist() {
         assertDoesNotMatch(hasProperty("aNonExistentProp"), bean);
+        assertDoesNotMatch(hasProperty("aNonExistentProp"), record);
+        assertDoesNotMatch(hasProperty("notAGetterMethod"), record);
     }
 
     @Test public void
@@ -44,6 +48,8 @@ public final class HasPropertyTest {
     describesAMismatch() {
         assertMismatchDescription("no \"aNonExistentProp\" in <[Person: a bean]>",
                                   hasProperty("aNonExistentProp"), bean);
+        assertMismatchDescription("no \"aNonExistentProp\" in <[Person: a record]>",
+                                  hasProperty("aNonExistentProp"), record);
     }
 
 }
diff --git a/hamcrest/src/test/java/org/hamcrest/beans/HasPropertyWithValueTest.java b/hamcrest/src/test/java/org/hamcrest/beans/HasPropertyWithValueTest.java
index 6fec083f..a7dcc00d 100644
--- a/hamcrest/src/test/java/org/hamcrest/beans/HasPropertyWithValueTest.java
+++ b/hamcrest/src/test/java/org/hamcrest/beans/HasPropertyWithValueTest.java
@@ -8,6 +8,7 @@
 import java.beans.IntrospectionException;
 import java.beans.PropertyDescriptor;
 import java.beans.SimpleBeanInfo;
+import java.util.Objects;
 
 import static org.hamcrest.MatcherAssert.assertThat;
 import static org.hamcrest.test.MatcherAssertions.*;
@@ -30,6 +31,9 @@ public class HasPropertyWithValueTest extends AbstractMatcherTest {
   private final BeanWithoutInfo shouldMatch = new BeanWithoutInfo("is expected", true);
   private final BeanWithoutInfo shouldNotMatch = new BeanWithoutInfo("not expected", false);
 
+  private final RecordLikeBeanWithoutInfo recordShouldMatch = new RecordLikeBeanWithoutInfo("is expected", true);
+  private final RecordLikeBeanWithoutInfo recordShouldNotMatch = new RecordLikeBeanWithoutInfo("not expected", false);
+
   private final BeanWithInfo beanWithInfo = new BeanWithInfo("with info");
 
   @Override
@@ -45,6 +49,14 @@ public void testMatchesBeanWithoutInfoWithMatchedNamedProperty() {
     assertMismatchDescription("property 'property' was \"not expected\"", propertyMatcher, shouldNotMatch);
   }
 
+  @Test
+  public void testMatchesRecordLikeBeanWithoutInfoWithMatchedNamedProperty() {
+    final Matcher propertyMatcher = hasProperty("property", equalTo("is expected"));
+
+    assertMatches("with property", propertyMatcher, recordShouldMatch);
+    assertMismatchDescription("property 'property' was \"not expected\"", propertyMatcher, recordShouldNotMatch);
+  }
+
   @Test
   public void testMatchesBeanWithoutInfoWithMatchedNamedBooleanProperty() {
     final Matcher booleanPropertyMatcher = hasProperty("booleanProperty", is(true));
@@ -53,6 +65,14 @@ public void testMatchesBeanWithoutInfoWithMatchedNamedBooleanProperty() {
     assertMismatchDescription("property 'booleanProperty' was ", booleanPropertyMatcher, shouldNotMatch);
   }
 
+  @Test
+  public void testMatchesRecordLikeBeanWithoutInfoWithMatchedNamedBooleanProperty() {
+    final Matcher booleanPropertyMatcher = hasProperty("booleanProperty", is(true));
+
+    assertMatches("with property", booleanPropertyMatcher, recordShouldMatch);
+    assertMismatchDescription("property 'booleanProperty' was ", booleanPropertyMatcher, recordShouldNotMatch);
+  }
+
   @Test
   public void testMatchesBeanWithInfoWithMatchedNamedProperty() {
     assertMatches("with bean info", hasProperty("property", equalTo("with info")), beanWithInfo);
@@ -64,9 +84,15 @@ public void testMatchesBeanWithInfoWithMatchedNamedProperty() {
   public void testDoesNotMatchBeanWithoutInfoOrMatchedNamedProperty() {
     assertMismatchDescription("No property \"nonExistentProperty\"",
                               hasProperty("nonExistentProperty", anything()), shouldNotMatch);
-   }
+  }
 
-   @Test
+  @Test
+  public void testDoesNotMatchRecordLikeBeanWithoutInfoOrMatchedNamedProperty() {
+    assertMismatchDescription("No property \"nonExistentProperty\"",
+                              hasProperty("nonExistentProperty", anything()), recordShouldNotMatch);
+  }
+
+  @Test
   public void testDoesNotMatchWriteOnlyProperty() {
     assertMismatchDescription("property \"writeOnlyProperty\" is not readable",
                               hasProperty("writeOnlyProperty", anything()), shouldNotMatch);
@@ -82,6 +108,16 @@ public void testMatchesPath() {
     assertMismatchDescription("inner.inner.property.was \"not expected\"", hasPropertyAtPath("inner.inner.property", equalTo("something")), new BeanWithInner(new BeanWithInner(shouldNotMatch)));
   }
 
+  @Test
+  public void testMatchesPathForJavaRecords() {
+    assertMatches("1-step path", hasPropertyAtPath("property", equalTo("is expected")), recordShouldMatch);
+    assertMatches("2-step path", hasPropertyAtPath("inner.property", equalTo("is expected")), new BeanWithInner(recordShouldMatch));
+    assertMatches("3-step path", hasPropertyAtPath("inner.inner.property", equalTo("is expected")), new BeanWithInner(new BeanWithInner(recordShouldMatch)));
+
+    assertMismatchDescription("inner.No property \"wrong\"", hasPropertyAtPath("inner.wrong.property", anything()), new BeanWithInner(new BeanWithInner(recordShouldMatch)));
+    assertMismatchDescription("inner.inner.property.was \"not expected\"", hasPropertyAtPath("inner.inner.property", equalTo("something")), new BeanWithInner(new BeanWithInner(recordShouldNotMatch)));
+  }
+
   @Test
   public void testDescribeTo() {
     assertDescription("hasProperty(\"property\", )", hasProperty("property", equalTo(true)));
@@ -92,6 +128,11 @@ public void testMatchesPropertyAndValue() {
     assertMatches("property with value", hasProperty("property", anything()), beanWithInfo);
   }
 
+  @Test
+  public void testMatchesPropertyAndValueWithJavaRecords() {
+    assertMatches("property with value", hasProperty("property", anything()), recordShouldMatch);
+  }
+
   @Test
   public void testDoesNotWriteMismatchIfPropertyMatches() {
     Description description = new StringDescription();
@@ -157,6 +198,45 @@ public String toString() {
     }
   }
 
+  /**
+   * A Java Record-like class to test the functionality of
+   * {@link HasProperty}, {@link HasPropertyWithValue}
+   * with Java Records in JDK 8 environment.
+   *
+   * @see https://docs.oracle.com/en/java/javase/17/language/records.html
+   */
+  public static final class RecordLikeBeanWithoutInfo {
+    private final String property;
+    private final boolean booleanProperty;
+
+    public RecordLikeBeanWithoutInfo(String property, boolean booleanProperty) {
+      this.property = property;
+      this.booleanProperty = booleanProperty;
+    }
+
+    public String property() { return this.property; }
+    public boolean booleanProperty() { return this.booleanProperty; }
+    public void notAGetterMethod() {}
+
+    @Override
+    public boolean equals(Object o) {
+      if (this == o) return true;
+      if (!(o instanceof RecordLikeBeanWithoutInfo)) return false;
+      RecordLikeBeanWithoutInfo that = (RecordLikeBeanWithoutInfo) o;
+      return Objects.equals(this.property, that.property) && this.booleanProperty == that.booleanProperty;
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(property, booleanProperty);
+    }
+
+    @Override
+    public String toString() {
+      return "[Person: " + property + "]";
+    }
+  }
+
   @SuppressWarnings("WeakerAccess")
   public static class BeanWithInner {
     private final Object inner;
@@ -173,7 +253,7 @@ public static class BeanWithInfo {
     public String property() { return propertyValue; }
   }
 
-  public static class BeanWithInfoBeanInfo extends SimpleBeanInfo {
+  public static class BeanWithInfoBeanInfo extends SimpleBeanInfo { // TODO: No usage. Can be removed.
     @Override
     public PropertyDescriptor[] getPropertyDescriptors() {
       try {
diff --git a/hamcrest/src/test/java/org/hamcrest/beans/PropertyUtilTest.java b/hamcrest/src/test/java/org/hamcrest/beans/PropertyUtilTest.java
new file mode 100644
index 00000000..bc9ba6a0
--- /dev/null
+++ b/hamcrest/src/test/java/org/hamcrest/beans/PropertyUtilTest.java
@@ -0,0 +1,150 @@
+package org.hamcrest.beans;
+
+import org.junit.jupiter.api.Test;
+
+import java.beans.MethodDescriptor;
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.*;
+import java.util.stream.Collectors;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.*;
+
+class PropertyUtilTest {
+
+    @Test
+    void testReturnsTheNamesOfAllFieldsFromTargetClass() {
+        SamePropertyValuesAsTest.ExampleBean input = new SamePropertyValuesAsTest.ExampleBean("test", 1, null);
+
+        Set output = PropertyUtil.getFieldNames(input);
+
+        assertThat(output, hasSize(3));
+        assertThat(output, hasItems("stringProperty", "intProperty", "valueProperty"));
+        assertThat(output, not(hasItem("nonexistentField")));
+    }
+
+    @Test
+    void testReturnsTheNamesOfAllFieldsFromTargetRecord() {
+        RecordLikeClass.SmallClass smallClass1 = new RecordLikeClass.SmallClass();
+        RecordLikeClass.SmallClass smallClass2 = new RecordLikeClass.SmallClass("small", 3, BigDecimal.ONE, LocalDateTime.of(2024, 1, 2, 3, 4, 5));
+        RecordLikeClass input = new RecordLikeClass("uno", 22, true, new Long[] {1L, 2L, 3L}, new ArrayList<>(Arrays.asList(smallClass1, smallClass2)));
+
+        Set output = PropertyUtil.getFieldNames(input);
+
+        assertThat(output, hasSize(5));
+        assertThat(output, hasItems("numberArray", "test", "smallClasses", "name", "age"));
+        assertThat(output, not(hasItem("notAGetter1")));
+        assertThat(output, not(hasItem("notAGetter2")));
+        assertThat(output, not(hasItem("getAge")));
+        assertThat(output, not(hasItem("field1")));
+        assertThat(output, not(hasItem("nonexistentField")));
+    }
+
+    @Test
+    void testReturnsArrayOfMethodDescriptorFromTargetClass() {
+        SamePropertyValuesAsTest.ExampleBean input = new SamePropertyValuesAsTest.ExampleBean("test", 1, null);
+
+        MethodDescriptor[] output = PropertyUtil.recordReadAccessorMethodDescriptorsFor(input, Object.class);
+
+        assertThat(output, arrayWithSize(0));
+    }
+
+    @Test
+    void testReturnsArrayOfMethodDescriptorFromTargetRecord() {
+        RecordLikeClass.SmallClass smallClass1 = new RecordLikeClass.SmallClass();
+        RecordLikeClass.SmallClass smallClass2 = new RecordLikeClass.SmallClass("small", 3, BigDecimal.ONE, LocalDateTime.of(2024, 1, 2, 3, 4, 5));
+        RecordLikeClass input = new RecordLikeClass("uno", 22, true, new Long[] {1L, 2L, 3L}, new ArrayList<>(Arrays.asList(smallClass1, smallClass2)));
+
+        MethodDescriptor[] output = PropertyUtil.recordReadAccessorMethodDescriptorsFor(input, Object.class);
+
+        assertThat(output, arrayWithSize(5));
+        assertThat(Arrays.stream(output).map(MethodDescriptor::getDisplayName).collect(Collectors.toList()),
+                   hasItems("numberArray", "test", "smallClasses", "name", "age"));
+    }
+
+
+    /**
+     * A Java Record-like class to test the functionality of
+     * {@link PropertyUtil} with Java Records in JDK 8 environment.
+     *
+     * @see https://docs.oracle.com/en/java/javase/17/language/records.html
+     */
+    @SuppressWarnings("unused")
+    static final class RecordLikeClass {
+        private final String name;
+        private final int age;
+        private final boolean test;
+        private final Long[] numberArray;
+        private final List smallClasses;
+
+        public RecordLikeClass(String name, int age, boolean test, Long[] numberArray, List smallClasses) {
+            this.name = name;
+            this.age = age;
+            this.test = test;
+            this.numberArray = numberArray;
+            this.smallClasses = smallClasses;
+        }
+
+        public String name() { return name; }
+        public int age() { return age; }
+        public boolean test() { return test; }
+        public Long[] numberArray() { return numberArray; }
+        public List smallClasses() { return smallClasses; }
+
+        public void notAGetter1() {}
+        public String notAGetter2() { return "I'm nothing"; }
+        public String name(String fake1, String fake2) { return name; }
+        public void name(String fake1) {}
+        public int getAge() { return 0; }
+
+        @Override
+        public boolean equals(Object o) {
+            if (!(o instanceof RecordLikeClass)) return false;
+            RecordLikeClass that = (RecordLikeClass) o;
+            return this.age() == that.age() &&
+                    this.test() == that.test() &&
+                    Objects.equals(this.name(), that.name()) &&
+                    Objects.deepEquals(this.numberArray(), that.numberArray())&&
+                    Objects.equals(this.smallClasses(), that.smallClasses());
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hash(name(), age(), test(), Arrays.hashCode(numberArray()), smallClasses());
+        }
+
+        @Override
+        public String toString() {
+            return "RecordLikeClass{" +
+                    "name='" + name + '\'' +
+                    ", age=" + age +
+                    ", test=" + test +
+                    ", numberArray=" + Arrays.toString(numberArray) +
+                    ", smallClasses=" + smallClasses +
+                    '}';
+        }
+
+        static class SmallClass {
+            private String field1;
+            private Integer field2;
+            private BigDecimal field3;
+            private LocalDateTime field4;
+
+            public SmallClass() {}
+
+            public SmallClass(String field1, Integer field2, BigDecimal field3, LocalDateTime field4) {
+                this.field1 = field1;
+                this.field2 = field2;
+                this.field3 = field3;
+                this.field4 = field4;
+            }
+
+            @Override
+            public String toString() {
+                return "SmallClass{field1='" + field1 + "', field2=" + field2 + ", field3=" + field3 + ", field4=" + field4 + '}';
+            }
+        }
+    }
+
+}
diff --git a/hamcrest/src/test/java/org/hamcrest/beans/SamePropertyValuesAsTest.java b/hamcrest/src/test/java/org/hamcrest/beans/SamePropertyValuesAsTest.java
index 3af77cf0..bd5f1796 100644
--- a/hamcrest/src/test/java/org/hamcrest/beans/SamePropertyValuesAsTest.java
+++ b/hamcrest/src/test/java/org/hamcrest/beans/SamePropertyValuesAsTest.java
@@ -4,6 +4,8 @@
 import org.hamcrest.Matcher;
 import org.junit.jupiter.api.Test;
 
+import java.util.Objects;
+
 import static org.hamcrest.test.MatcherAssertions.*;
 import static org.hamcrest.beans.SamePropertyValuesAs.samePropertyValuesAs;
 
@@ -13,6 +15,9 @@ public class SamePropertyValuesAsTest extends AbstractMatcherTest {
   private static final Value aValue = new Value("expected");
   private static final ExampleBean expectedBean = new ExampleBean("same", 1, aValue);
   private static final ExampleBean actualBean = new ExampleBean("same", 1, aValue);
+  private static final ExampleRecord expectedRecord = new ExampleRecord("same", 1, aValue);
+  private static final ExampleRecord actualRecord = new ExampleRecord("same", 1, aValue);
+
 
   @Override
   protected Matcher createMatcher() {
@@ -22,12 +27,15 @@ protected Matcher createMatcher() {
   @Test
   public void test_reports_match_when_all_properties_match() {
     assertMatches("matched properties", samePropertyValuesAs(expectedBean), actualBean);
+    assertMatches("matched properties", samePropertyValuesAs(expectedRecord), actualRecord);
   }
 
   @Test
   public void test_reports_mismatch_when_actual_type_is_not_assignable_to_expected_type() {
     assertMismatchDescription("is incompatible type: ExampleBean",
                               samePropertyValuesAs((Object)aValue), actualBean);
+    assertMismatchDescription("is incompatible type: ExampleRecord",
+                              samePropertyValuesAs((Object)aValue), actualRecord);
   }
 
   @Test
@@ -38,6 +46,13 @@ public void test_reports_mismatch_on_first_property_difference() {
         samePropertyValuesAs(expectedBean), new ExampleBean("same", 2, aValue));
     assertMismatchDescription("valueProperty was ",
         samePropertyValuesAs(expectedBean), new ExampleBean("same", 1, new Value("other")));
+
+    assertMismatchDescription("stringProperty was \"different\"",
+        samePropertyValuesAs(expectedRecord), new ExampleRecord("different", 1, aValue));
+    assertMismatchDescription("intProperty was <2>",
+        samePropertyValuesAs(expectedRecord), new ExampleRecord("same", 2, aValue));
+    assertMismatchDescription("valueProperty was ",
+        samePropertyValuesAs(expectedRecord), new ExampleRecord("same", 1, new Value("other")));
   }
 
   @Test
@@ -61,21 +76,29 @@ public void test_ignores_extra_subtype_properties() {
   @Test
   public void test_ignores_different_properties() {
     final ExampleBean differentBean = new ExampleBean("different", 1, aValue);
+    final ExampleRecord differentRecord = new ExampleRecord("different", 1, aValue);
     assertMatches("different property", samePropertyValuesAs(expectedBean, "stringProperty"), differentBean);
+    assertMatches("different property", samePropertyValuesAs(expectedRecord, "stringProperty"), differentRecord);
   }
 
   @Test
   public void test_accepts_missing_properties_to_ignore() {
     assertMatches("ignored property", samePropertyValuesAs(expectedBean, "notAProperty"), actualBean);
+    assertMatches("ignored property", samePropertyValuesAs(expectedRecord, "notAProperty"), actualRecord);
   }
 
   @Test
   public void test_can_ignore_all_properties() {
     final ExampleBean differentBean = new ExampleBean("different", 2, new Value("not expected"));
+    final ExampleRecord differentRecord = new ExampleRecord("different", 2, new Value("not expected"));
     assertMatches(
             "different property",
             samePropertyValuesAs(expectedBean, "stringProperty", "intProperty", "valueProperty"),
             differentBean);
+    assertMatches(
+            "different property",
+            samePropertyValuesAs(expectedRecord, "stringProperty", "intProperty", "valueProperty"),
+            differentRecord);
   }
 
   @Test
@@ -83,10 +106,16 @@ public void testDescribesItself() {
     assertDescription(
             "same property values as ExampleBean [intProperty: <1>, stringProperty: \"same\", valueProperty: ]",
             samePropertyValuesAs(expectedBean));
+    assertDescription(
+            "same property values as ExampleRecord [valueProperty: , stringProperty: \"same\", intProperty: <1>]",
+            samePropertyValuesAs(expectedRecord));
 
     assertDescription(
             "same property values as ExampleBean [intProperty: <1>, stringProperty: \"same\", valueProperty: ] ignoring [\"ignored1\", \"ignored2\"]",
             samePropertyValuesAs(expectedBean, "ignored1", "ignored2"));
+    assertDescription(
+            "same property values as ExampleRecord [valueProperty: , stringProperty: \"same\", intProperty: <1>] ignoring [\"ignored1\", \"ignored2\"]",
+            samePropertyValuesAs(expectedRecord, "ignored1", "ignored2"));
   }
 
   public static class Value {
@@ -126,6 +155,45 @@ public Value getValueProperty() {
     @Override public String toString() { return "an ExampleBean"; }
   }
 
+  /**
+   * A Java Record-like class to test the functionality of
+   * {@link SamePropertyValuesAs} with Java Records in JDK 8 environment.
+   * The basic property structure is the same as {@link ExampleBean ExampleBean} for the exact comparison.
+   *
+   * @see ExampleBean ExampleBean
+   * @see https://docs.oracle.com/en/java/javase/17/language/records.html
+   */
+  @SuppressWarnings("unused")
+  public static final class ExampleRecord {
+    private final String stringProperty;
+    private final int intProperty;
+    private final Value valueProperty;
+
+    public ExampleRecord(String stringProperty, int intProperty, Value valueProperty) {
+      this.stringProperty = stringProperty;
+      this.intProperty = intProperty;
+      this.valueProperty = valueProperty;
+    }
+
+    public String stringProperty() { return stringProperty; }
+    public int intProperty() { return intProperty; }
+    public Value valueProperty() { return valueProperty; }
+
+    @Override
+    public boolean equals(Object o) {
+      if (!(o instanceof ExampleRecord)) return false;
+      ExampleRecord that = (ExampleRecord) o;
+      return this.intProperty == that.intProperty && Objects.equals(this.stringProperty, that.stringProperty) && Objects.equals(this.valueProperty, that.valueProperty);
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(stringProperty, intProperty, valueProperty);
+    }
+
+    @Override public String toString() { return "an ExampleRecord"; }
+  }
+
   public static class SubBeanWithNoExtraProperties extends ExampleBean {
     public SubBeanWithNoExtraProperties(String stringProperty, int intProperty, Value valueProperty) {
       super(stringProperty, intProperty, valueProperty);