diff --git a/spring-test/src/main/java/org/springframework/test/json/AbstractJsonValueAssert.java b/spring-test/src/main/java/org/springframework/test/json/AbstractJsonValueAssert.java
new file mode 100644
index 00000000000..f3c9181369d
--- /dev/null
+++ b/spring-test/src/main/java/org/springframework/test/json/AbstractJsonValueAssert.java
@@ -0,0 +1,235 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.test.json;
+
+import java.lang.reflect.Array;
+import java.lang.reflect.Type;
+import java.util.List;
+import java.util.Map;
+
+import org.assertj.core.api.AbstractBooleanAssert;
+import org.assertj.core.api.AbstractMapAssert;
+import org.assertj.core.api.AbstractObjectAssert;
+import org.assertj.core.api.AbstractStringAssert;
+import org.assertj.core.api.Assertions;
+import org.assertj.core.api.ObjectArrayAssert;
+import org.assertj.core.error.BasicErrorMessageFactory;
+import org.assertj.core.internal.Failures;
+
+import org.springframework.core.ParameterizedTypeReference;
+import org.springframework.core.ResolvableType;
+import org.springframework.http.HttpInputMessage;
+import org.springframework.http.MediaType;
+import org.springframework.http.converter.GenericHttpMessageConverter;
+import org.springframework.lang.Nullable;
+import org.springframework.mock.http.MockHttpInputMessage;
+import org.springframework.mock.http.MockHttpOutputMessage;
+import org.springframework.util.ObjectUtils;
+import org.springframework.util.StringUtils;
+
+/**
+ * Base AssertJ {@link org.assertj.core.api.Assert assertions} that can be
+ * applied to a JSON value. In JSON, values must be one of the following data
+ * types:
+ *
+ * a {@linkplain #asString() string}
+ * a {@linkplain #asNumber() number}
+ * a {@linkplain #asBoolean() boolean}
+ * an {@linkplain #asArray() array}
+ * an {@linkplain #asMap() object} (JSON object)
+ * {@linkplain #isNull() null}
+ *
+ * This base class offers direct access for each of those types as well as a
+ * conversion methods based on an optional {@link GenericHttpMessageConverter}.
+ *
+ * @author Stephane Nicoll
+ * @since 6.2
+ * @param the type of assertions
+ */
+public abstract class AbstractJsonValueAssert>
+ extends AbstractObjectAssert {
+
+ private final Failures failures = Failures.instance();
+
+ @Nullable
+ private final GenericHttpMessageConverter httpMessageConverter;
+
+
+ protected AbstractJsonValueAssert(@Nullable Object actual, Class> selfType,
+ @Nullable GenericHttpMessageConverter httpMessageConverter) {
+ super(actual, selfType);
+ this.httpMessageConverter = httpMessageConverter;
+ }
+
+ /**
+ * Verify that the actual value is a non-{@code null} {@link String}
+ * and return a new {@linkplain AbstractStringAssert assertion} object that
+ * provides dedicated {@code String} assertions for it.
+ */
+ @Override
+ public AbstractStringAssert> asString() {
+ return Assertions.assertThat(castTo(String.class, "a string"));
+ }
+
+ /**
+ * Verify that the actual value is a non-{@code null} {@link Number},
+ * usually an {@link Integer} or {@link Double} and return a new
+ * {@linkplain AbstractObjectAssert assertion} object for it.
+ */
+ public AbstractObjectAssert, Number> asNumber() {
+ return Assertions.assertThat(castTo(Number.class, "a number"));
+ }
+
+ /**
+ * Verify that the actual value is a non-{@code null} {@link Boolean}
+ * and return a new {@linkplain AbstractBooleanAssert assertion} object
+ * that provides dedicated {@code Boolean} assertions for it.
+ */
+ public AbstractBooleanAssert> asBoolean() {
+ return Assertions.assertThat(castTo(Boolean.class, "a boolean"));
+ }
+
+ /**
+ * Verify that the actual value is a non-{@code null} {@link Array}
+ * and return a new {@linkplain ObjectArrayAssert assertion} object
+ * that provides dedicated {@code Array} assertions for it.
+ */
+ public ObjectArrayAssert asArray() {
+ List> list = castTo(List.class, "an array");
+ Object[] array = list.toArray(new Object[0]);
+ return Assertions.assertThat(array);
+ }
+
+ /**
+ * Verify that the actual value is a non-{@code null} JSON object and
+ * return a new {@linkplain AbstractMapAssert assertion} object that
+ * provides dedicated assertions on individual elements of the
+ * object. The returned map assertion object uses the attribute name as the
+ * key, and the value can itself be any of the valid JSON values.
+ */
+ @SuppressWarnings("unchecked")
+ public AbstractMapAssert, Map, String, Object> asMap() {
+ return Assertions.assertThat(castTo(Map.class, "a map"));
+ }
+
+ private T castTo(Class expectedType, String description) {
+ if (this.actual == null) {
+ throw valueProcessingFailed("To be %s%n".formatted(description));
+ }
+ if (!expectedType.isInstance(this.actual)) {
+ throw valueProcessingFailed("To be %s%nBut was:%n %s%n".formatted(description, this.actual.getClass().getName()));
+ }
+ return expectedType.cast(this.actual);
+ }
+
+ /**
+ * Verify that the actual value can be converted to an instance of the
+ * given {@code target} and produce a new {@linkplain AbstractObjectAssert
+ * assertion} object narrowed to that type.
+ * @param target the {@linkplain Class type} to convert the actual value to
+ */
+ public AbstractObjectAssert, T> convertTo(Class target) {
+ isNotNull();
+ T value = convertToTargetType(target);
+ return Assertions.assertThat(value);
+ }
+
+ /**
+ * Verify that the actual value can be converted to an instance of the
+ * given {@code target} and produce a new {@linkplain AbstractObjectAssert
+ * assertion} object narrowed to that type.
+ * @param target the {@linkplain ParameterizedTypeReference parameterized
+ * type} to convert the actual value to
+ */
+ public AbstractObjectAssert, T> convertTo(ParameterizedTypeReference target) {
+ isNotNull();
+ T value = convertToTargetType(target.getType());
+ return Assertions.assertThat(value);
+ }
+
+ /**
+ * Verify that the actual value is empty, that is a {@code null} scalar
+ * value or an empty list or map. Can also be used when the path is using a
+ * filter operator to validate that it dit not match.
+ */
+ public SELF isEmpty() {
+ if (!ObjectUtils.isEmpty(this.actual)) {
+ throw valueProcessingFailed("To be empty");
+ }
+ return this.myself;
+ }
+
+ /**
+ * Verify that the actual value is not empty, that is a non-{@code null}
+ * scalar value or a non-empty list or map. Can also be used when the path is
+ * using a filter operator to validate that it dit match at least one
+ * element.
+ */
+ public SELF isNotEmpty() {
+ if (ObjectUtils.isEmpty(this.actual)) {
+ throw valueProcessingFailed("To not be empty");
+ }
+ return this.myself;
+ }
+
+
+ @SuppressWarnings("unchecked")
+ private T convertToTargetType(Type targetType) {
+ if (this.httpMessageConverter == null) {
+ throw new IllegalStateException(
+ "No JSON message converter available to convert %s".formatted(actualToString()));
+ }
+ try {
+ MockHttpOutputMessage outputMessage = new MockHttpOutputMessage();
+ this.httpMessageConverter.write(this.actual, ResolvableType.forInstance(this.actual).getType(),
+ MediaType.APPLICATION_JSON, outputMessage);
+ return (T) this.httpMessageConverter.read(targetType, getClass(),
+ fromHttpOutputMessage(outputMessage));
+ }
+ catch (Exception ex) {
+ throw valueProcessingFailed("To convert successfully to:%n %s%nBut it failed:%n %s%n"
+ .formatted(targetType.getTypeName(), ex.getMessage()));
+ }
+ }
+
+ private HttpInputMessage fromHttpOutputMessage(MockHttpOutputMessage message) {
+ MockHttpInputMessage inputMessage = new MockHttpInputMessage(message.getBodyAsBytes());
+ inputMessage.getHeaders().addAll(message.getHeaders());
+ return inputMessage;
+ }
+
+ protected String getExpectedErrorMessagePrefix() {
+ return "Expected:";
+ }
+
+ private AssertionError valueProcessingFailed(String errorMessage) {
+ throw this.failures.failure(this.info, new ValueProcessingFailed(
+ getExpectedErrorMessagePrefix(), actualToString(), errorMessage));
+ }
+
+ private String actualToString() {
+ return ObjectUtils.nullSafeToString(StringUtils.quoteIfString(this.actual));
+ }
+
+ private static final class ValueProcessingFailed extends BasicErrorMessageFactory {
+
+ private ValueProcessingFailed(String prefix, String actualToString, String errorMessage) {
+ super("%n%s%n %s%n%s".formatted(prefix, actualToString, errorMessage));
+ }
+ }
+
+}
diff --git a/spring-test/src/main/java/org/springframework/test/json/JsonPathAssert.java b/spring-test/src/main/java/org/springframework/test/json/JsonPathAssert.java
new file mode 100644
index 00000000000..0064b58140d
--- /dev/null
+++ b/spring-test/src/main/java/org/springframework/test/json/JsonPathAssert.java
@@ -0,0 +1,165 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.test.json;
+
+import java.util.function.Consumer;
+
+import com.jayway.jsonpath.JsonPath;
+import com.jayway.jsonpath.PathNotFoundException;
+import org.assertj.core.api.AbstractAssert;
+import org.assertj.core.api.AssertProvider;
+import org.assertj.core.error.BasicErrorMessageFactory;
+import org.assertj.core.internal.Failures;
+
+import org.springframework.http.converter.GenericHttpMessageConverter;
+import org.springframework.lang.Nullable;
+import org.springframework.util.Assert;
+
+/**
+ * AssertJ {@link org.assertj.core.api.Assert assertions} that can be applied
+ * to a {@link CharSequence} representation of a json document using
+ * {@linkplain JsonPath JSON path}.
+ *
+ * @author Stephane Nicoll
+ * @since 6.2
+ */
+public class JsonPathAssert extends AbstractAssert {
+
+ private static final Failures failures = Failures.instance();
+
+ @Nullable
+ private final GenericHttpMessageConverter jsonMessageConverter;
+
+ public JsonPathAssert(CharSequence json,
+ @Nullable GenericHttpMessageConverter jsonMessageConverter) {
+ super(json, JsonPathAssert.class);
+ this.jsonMessageConverter = jsonMessageConverter;
+ }
+
+ /**
+ * Verify that the given JSON {@code path} is present and extract the JSON
+ * value for further {@linkplain JsonPathValueAssert assertions}.
+ * @param path the {@link JsonPath} expression
+ * @see #hasPathSatisfying(String, Consumer)
+ */
+ public JsonPathValueAssert extractingPath(String path) {
+ Object value = new JsonPathValue(path).getValue();
+ return new JsonPathValueAssert(value, path, this.jsonMessageConverter);
+ }
+
+ /**
+ * Verify that the given JSON {@code path} is present with a JSON value
+ * satisfying the given {@code valueRequirements}.
+ * @param path the {@link JsonPath} expression
+ * @param valueRequirements a {@link Consumer} of the assertion object
+ */
+ public JsonPathAssert hasPathSatisfying(String path, Consumer> valueRequirements) {
+ Object value = new JsonPathValue(path).assertHasPath();
+ JsonPathValueAssert valueAssert = new JsonPathValueAssert(value, path, this.jsonMessageConverter);
+ valueRequirements.accept(() -> valueAssert);
+ return this;
+ }
+
+ /**
+ * Verify that the given JSON {@code path} matches. For paths with an
+ * operator, this validates that the path expression is valid, but does not
+ * validate that it yield any results.
+ * @param path the {@link JsonPath} expression
+ */
+ public JsonPathAssert hasPath(String path) {
+ new JsonPathValue(path).assertHasPath();
+ return this;
+ }
+
+ /**
+ * Verify that the given JSON {@code path} does not match.
+ * @param path the {@link JsonPath} expression
+ */
+ public JsonPathAssert doesNotHavePath(String path) {
+ new JsonPathValue(path).assertDoesNotHavePath();
+ return this;
+ }
+
+
+ private AssertionError failure(BasicErrorMessageFactory errorMessageFactory) {
+ throw failures.failure(this.info, errorMessageFactory);
+ }
+
+
+ /**
+ * A {@link JsonPath} value.
+ */
+ private class JsonPathValue {
+
+ private final String path;
+
+ private final JsonPath jsonPath;
+
+ private final String json;
+
+ JsonPathValue(String path) {
+ Assert.hasText(path, "'path' must not be null or empty");
+ this.path = path;
+ this.jsonPath = JsonPath.compile(this.path);
+ this.json = JsonPathAssert.this.actual.toString();
+ }
+
+ @Nullable
+ Object assertHasPath() {
+ return getValue();
+ }
+
+ void assertDoesNotHavePath() {
+ try {
+ read();
+ throw failure(new JsonPathNotExpected(this.json, this.path));
+ }
+ catch (PathNotFoundException ignore) {
+ }
+ }
+
+ @Nullable
+ Object getValue() {
+ try {
+ return read();
+ }
+ catch (PathNotFoundException ex) {
+ throw failure(new JsonPathNotFound(this.json, this.path));
+ }
+ }
+
+ @Nullable
+ private Object read() {
+ return this.jsonPath.read(this.json);
+ }
+
+
+ static final class JsonPathNotFound extends BasicErrorMessageFactory {
+
+ private JsonPathNotFound(String actual, String path) {
+ super("%nExpecting:%n %s%nTo match JSON path:%n %s%n", actual, path);
+ }
+ }
+
+ static final class JsonPathNotExpected extends BasicErrorMessageFactory {
+
+ private JsonPathNotExpected(String actual, String path) {
+ super("%nExpecting:%n %s%nTo not match JSON path:%n %s%n", actual, path);
+ }
+ }
+ }
+}
diff --git a/spring-test/src/main/java/org/springframework/test/json/JsonPathValueAssert.java b/spring-test/src/main/java/org/springframework/test/json/JsonPathValueAssert.java
new file mode 100644
index 00000000000..468c4ec5061
--- /dev/null
+++ b/spring-test/src/main/java/org/springframework/test/json/JsonPathValueAssert.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.test.json;
+
+import com.jayway.jsonpath.JsonPath;
+
+import org.springframework.http.converter.GenericHttpMessageConverter;
+import org.springframework.lang.Nullable;
+
+/**
+ * AssertJ {@link org.assertj.core.api.Assert assertions} that can be applied
+ * to a JSON value produced by evaluating a {@linkplain JsonPath JSON path}
+ * expression.
+ *
+ * @author Stephane Nicoll
+ * @since 6.2
+ */
+public class JsonPathValueAssert
+ extends AbstractJsonValueAssert {
+
+ private final String expression;
+
+
+ JsonPathValueAssert(@Nullable Object actual, String expression,
+ @Nullable GenericHttpMessageConverter httpMessageConverter) {
+ super(actual, JsonPathValueAssert.class, httpMessageConverter);
+ this.expression = expression;
+ }
+
+ @Override
+ protected String getExpectedErrorMessagePrefix() {
+ return "Expected value at JSON path \"%s\":".formatted(this.expression);
+ }
+}
diff --git a/spring-test/src/test/java/org/springframework/test/json/JsonPathAssertTests.java b/spring-test/src/test/java/org/springframework/test/json/JsonPathAssertTests.java
new file mode 100644
index 00000000000..b48914ec543
--- /dev/null
+++ b/spring-test/src/test/java/org/springframework/test/json/JsonPathAssertTests.java
@@ -0,0 +1,322 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.test.json;
+
+import java.util.List;
+import java.util.Map;
+import java.util.function.Consumer;
+import java.util.stream.Stream;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.assertj.core.api.AssertProvider;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import org.springframework.core.ParameterizedTypeReference;
+import org.springframework.core.io.ClassPathResource;
+import org.springframework.http.converter.GenericHttpMessageConverter;
+import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
+import org.springframework.lang.Nullable;
+import org.springframework.util.FileCopyUtils;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
+import static org.assertj.core.api.Assertions.entry;
+
+/**
+ * Tests for {@link JsonPathAssert}.
+ *
+ * @author Phillip Webb
+ * @author Stephane Nicoll
+ */
+class JsonPathAssertTests {
+
+ private static final String TYPES = loadJson("types.json");
+
+ private static final String SIMPSONS = loadJson("simpsons.json");
+
+ private static final String NULLS = loadJson("nulls.json");
+
+ private static final MappingJackson2HttpMessageConverter jsonHttpMessageConverter =
+ new MappingJackson2HttpMessageConverter(new ObjectMapper());
+
+
+ @Nested
+ class HasPathTests {
+
+ @Test
+ void hasPathForPresentAndNotNull() {
+ assertThat(forJson(NULLS)).hasPath("$.valuename");
+ }
+
+ @Test
+ void hasPathForPresentAndNull() {
+ assertThat(forJson(NULLS)).hasPath("$.nullname");
+ }
+
+ @Test
+ void hasPathForOperatorMatching() {
+ assertThat(forJson(SIMPSONS)).
+ hasPath("$.familyMembers[?(@.name == 'Homer')]");
+ }
+
+ @Test
+ void hasPathForOperatorNotMatching() {
+ assertThat(forJson(SIMPSONS)).
+ hasPath("$.familyMembers[?(@.name == 'Dilbert')]");
+ }
+
+ @Test
+ void hasPathForNotPresent() {
+ String expression = "$.missing";
+ assertThatExceptionOfType(AssertionError.class)
+ .isThrownBy(() -> assertThat(forJson(NULLS)).hasPath(expression))
+ .satisfies(hasFailedToMatchPath("$.missing"));
+ }
+
+ @Test
+ void hasPathSatisfying() {
+ assertThat(forJson(TYPES)).hasPathSatisfying("$.str", value -> assertThat(value).isEqualTo("foo"))
+ .hasPathSatisfying("$.num", value -> assertThat(value).isEqualTo(5));
+ }
+
+ @Test
+ void hasPathSatisfyingForPathNotPresent() {
+ String expression = "missing";
+ assertThatExceptionOfType(AssertionError.class)
+ .isThrownBy(() -> assertThat(forJson(NULLS)).hasPathSatisfying(expression, value -> {}))
+ .satisfies(hasFailedToMatchPath(expression));
+ }
+
+ @Test
+ void doesNotHavePathForMissing() {
+ assertThat(forJson(NULLS)).doesNotHavePath("$.missing");
+ }
+
+
+ @Test
+ void doesNotHavePathForPresent() {
+ String expression = "$.valuename";
+ assertThatExceptionOfType(AssertionError.class)
+ .isThrownBy(() -> assertThat(forJson(NULLS)).doesNotHavePath(expression))
+ .satisfies(hasFailedToNotMatchPath(expression));
+ }
+ }
+
+
+ @Nested
+ class ExtractingPathTests {
+
+ @Test
+ void isNullWithNullPathValue() {
+ assertThat(forJson(NULLS)).extractingPath("$.nullname").isNull();
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = { "$.str", "$.emptyString", "$.num", "$.bool", "$.arr",
+ "$.emptyArray", "$.colorMap", "$.emptyMap" })
+ void isNotNullWithValue(String path) {
+ assertThat(forJson(TYPES)).extractingPath(path).isNotNull();
+ }
+
+ @ParameterizedTest
+ @MethodSource
+ void isEqualToOnRawValue(String path, Object expected) {
+ assertThat(forJson(TYPES)).extractingPath(path).isEqualTo(expected);
+ }
+
+ static Stream isEqualToOnRawValue() {
+ return Stream.of(
+ Arguments.of("$.str", "foo"),
+ Arguments.of("$.num", 5),
+ Arguments.of("$.bool", true),
+ Arguments.of("$.arr", List.of(42)),
+ Arguments.of("$.colorMap", Map.of("red", "rojo")));
+ }
+
+ @Test
+ void asStringWithActualValue() {
+ assertThat(forJson(TYPES)).extractingPath("@.str").asString().startsWith("f").endsWith("o");
+ }
+
+ @Test
+ void asStringIsEmpty() {
+ assertThat(forJson(TYPES)).extractingPath("@.emptyString").asString().isEmpty();
+ }
+
+ @Test
+ void asNumberWithActualValue() {
+ assertThat(forJson(TYPES)).extractingPath("@.num").asNumber().isEqualTo(5);
+ }
+
+ @Test
+ void asBooleanWithActualValue() {
+ assertThat(forJson(TYPES)).extractingPath("@.bool").asBoolean().isTrue();
+ }
+
+ @Test
+ void asArrayWithActualValue() {
+ assertThat(forJson(TYPES)).extractingPath("@.arr").asArray().containsOnly(42);
+ }
+
+ @Test
+ void asArrayIsEmpty() {
+ assertThat(forJson(TYPES)).extractingPath("@.emptyArray").asArray().isEmpty();
+ }
+
+ @Test
+ void asArrayWithFilterPredicatesMatching() {
+ assertThat(forJson(SIMPSONS))
+ .extractingPath("$.familyMembers[?(@.name == 'Bart')]").asArray().hasSize(1);
+ }
+
+ @Test
+ void asArrayWithFilterPredicatesNotMatching() {
+ assertThat(forJson(SIMPSONS)).
+ extractingPath("$.familyMembers[?(@.name == 'Dilbert')]").asArray().isEmpty();
+ }
+
+ @Test
+ void asMapWithActualValue() {
+ assertThat(forJson(TYPES)).extractingPath("@.colorMap").asMap().containsOnly(entry("red", "rojo"));
+ }
+
+ @Test
+ void asMapIsEmpty() {
+ assertThat(forJson(TYPES)).extractingPath("@.emptyMap").asMap().isEmpty();
+ }
+
+ @Test
+ void convertToWithoutHttpMessageConverterShouldFail() {
+ JsonPathValueAssert path = assertThat(forJson(SIMPSONS)).extractingPath("$.familyMembers[0]");
+ assertThatIllegalStateException().isThrownBy(() -> path.convertTo(Member.class))
+ .withMessage("No JSON message converter available to convert {name=Homer}");
+ }
+
+ @Test
+ void convertToTargetType() {
+ assertThat(forJson(SIMPSONS, jsonHttpMessageConverter))
+ .extractingPath("$.familyMembers[0]").convertTo(Member.class)
+ .satisfies(member -> assertThat(member.name).isEqualTo("Homer"));
+ }
+
+ @Test
+ void convertToIncompatibleTargetTypeShouldFail() {
+ JsonPathValueAssert path = assertThat(forJson(SIMPSONS, jsonHttpMessageConverter))
+ .extractingPath("$.familyMembers[0]");
+ assertThatExceptionOfType(AssertionError.class)
+ .isThrownBy(() -> path.convertTo(Customer.class))
+ .withMessageContainingAll("Expected value at JSON path \"$.familyMembers[0]\":",
+ Customer.class.getName(), "name");
+ }
+
+ @Test
+ void convertArrayToParameterizedType() {
+ assertThat(forJson(SIMPSONS, jsonHttpMessageConverter))
+ .extractingPath("$.familyMembers")
+ .convertTo(new ParameterizedTypeReference>() {})
+ .satisfies(family -> assertThat(family).hasSize(5).element(0).isEqualTo(new Member("Homer")));
+ }
+
+ @Test
+ void isEmptyWithPathHavingNullValue() {
+ assertThat(forJson(NULLS)).extractingPath("nullname").isEmpty();
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = { "$.emptyString", "$.emptyArray", "$.emptyMap" })
+ void isEmptyWithEmptyValue(String path) {
+ assertThat(forJson(TYPES)).extractingPath(path).isEmpty();
+ }
+
+ @Test
+ void isEmptyForPathWithFilterMatching() {
+ String expression = "$.familyMembers[?(@.name == 'Bart')]";
+ assertThatExceptionOfType(AssertionError.class)
+ .isThrownBy(() -> assertThat(forJson(SIMPSONS)).extractingPath(expression).isEmpty())
+ .withMessageContainingAll("Expected value at JSON path \"" + expression + "\"",
+ "[{\"name\":\"Bart\"}]", "To be empty");
+ }
+
+ @Test
+ void isEmptyForPathWithFilterNotMatching() {
+ assertThat(forJson(SIMPSONS)).extractingPath("$.familyMembers[?(@.name == 'Dilbert')]").isEmpty();
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = { "$.str", "$.num", "$.bool", "$.arr", "$.colorMap" })
+ void isNotEmptyWithNonNullValue(String path) {
+ assertThat(forJson(TYPES)).extractingPath(path).isNotEmpty();
+ }
+
+ @Test
+ void isNotEmptyForPathWithFilterMatching() {
+ assertThat(forJson(SIMPSONS)).extractingPath("$.familyMembers[?(@.name == 'Bart')]").isNotEmpty();
+ }
+
+ @Test
+ void isNotEmptyForPathWithFilterNotMatching() {
+ String expression = "$.familyMembers[?(@.name == 'Dilbert')]";
+ assertThatExceptionOfType(AssertionError.class)
+ .isThrownBy(() -> assertThat(forJson(SIMPSONS)).extractingPath(expression).isNotEmpty())
+ .withMessageContainingAll("Expected value at JSON path \"" + expression + "\"",
+ "To not be empty");
+ }
+
+
+ private record Member(String name) {}
+
+ private record Customer(long id, String username) {}
+
+ }
+
+ private Consumer hasFailedToMatchPath(String expression) {
+ return error -> assertThat(error.getMessage()).containsSubsequence("Expecting:",
+ "To match JSON path:", "\"" + expression + "\"");
+ }
+
+ private Consumer hasFailedToNotMatchPath(String expression) {
+ return error -> assertThat(error.getMessage()).containsSubsequence("Expecting:",
+ "To not match JSON path:", "\"" + expression + "\"");
+ }
+
+
+ private static String loadJson(String path) {
+ try {
+ ClassPathResource resource = new ClassPathResource(path, JsonPathAssertTests.class);
+ return new String(FileCopyUtils.copyToByteArray(resource.getInputStream()));
+ }
+ catch (Exception ex) {
+ throw new IllegalStateException(ex);
+ }
+ }
+
+ private AssertProvider forJson(String json) {
+ return forJson(json, null);
+ }
+
+ private AssertProvider forJson(String json,
+ @Nullable GenericHttpMessageConverter jsonHttpMessageConverter) {
+ return () -> new JsonPathAssert(json, jsonHttpMessageConverter);
+ }
+
+}
diff --git a/spring-test/src/test/java/org/springframework/test/json/JsonPathValueAssertTests.java b/spring-test/src/test/java/org/springframework/test/json/JsonPathValueAssertTests.java
new file mode 100644
index 00000000000..4ed5f604cac
--- /dev/null
+++ b/spring-test/src/test/java/org/springframework/test/json/JsonPathValueAssertTests.java
@@ -0,0 +1,333 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.test.json;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Consumer;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.assertj.core.api.AssertProvider;
+import org.assertj.core.api.InstanceOfAssertFactories;
+import org.assertj.core.data.Offset;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.core.ParameterizedTypeReference;
+import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
+import org.springframework.lang.Nullable;
+import org.springframework.util.StringUtils;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
+
+/**
+ * Tests for {@link JsonPathValueAssert}.
+ *
+ * @author Stephane Nicoll
+ */
+class JsonPathValueAssertTests {
+
+ @Nested
+ class AsStringTests {
+
+ @Test
+ void asStringWithStringValue() {
+ assertThat(forValue("test")).asString().isEqualTo("test");
+ }
+
+ @Test
+ void asStringWithEmptyValue() {
+ assertThat(forValue("")).asString().isEmpty();
+ }
+
+ @Test
+ void asStringWithNonStringFails() {
+ int value = 123;
+ AssertProvider actual = forValue(value);
+ assertThatExceptionOfType(AssertionError.class)
+ .isThrownBy(() -> assertThat(actual).asString().isEqualTo("123"))
+ .satisfies(hasFailedToBeOfType(value, "a string"));
+ }
+
+ @Test
+ void asStringWithNullFails() {
+ AssertProvider actual = forValue(null);
+ assertThatExceptionOfType(AssertionError.class)
+ .isThrownBy(() -> assertThat(actual).asString().isEqualTo("null"))
+ .satisfies(hasFailedToBeOfTypeWhenNull("a string"));
+ }
+ }
+
+ @Nested
+ class AsNumberTests {
+
+ @Test
+ void asNumberWithIntegerValue() {
+ assertThat(forValue(123)).asNumber().isEqualTo(123);
+ }
+
+ @Test
+ void asNumberWithDoubleValue() {
+ assertThat(forValue(3.1415926)).asNumber()
+ .asInstanceOf(InstanceOfAssertFactories.DOUBLE)
+ .isEqualTo(3.14, Offset.offset(0.01));
+ }
+
+ @Test
+ void asNumberWithNonNumberFails() {
+ String value = "123";
+ AssertProvider actual = forValue(value);
+ assertThatExceptionOfType(AssertionError.class)
+ .isThrownBy(() -> assertThat(actual).asNumber().isEqualTo(123))
+ .satisfies(hasFailedToBeOfType(value, "a number"));
+ }
+
+ @Test
+ void asNumberWithNullFails() {
+ AssertProvider actual = forValue(null);
+ assertThatExceptionOfType(AssertionError.class)
+ .isThrownBy(() -> assertThat(actual).asNumber().isEqualTo(0))
+ .satisfies(hasFailedToBeOfTypeWhenNull("a number"));
+ }
+ }
+
+ @Nested
+ class AsBooleanTests {
+
+ @Test
+ void asBooleanWithBooleanPrimitiveValue() {
+ assertThat(forValue(true)).asBoolean().isEqualTo(true);
+ }
+
+ @Test
+ void asBooleanWithBooleanWrapperValue() {
+ assertThat(forValue(Boolean.FALSE)).asBoolean().isEqualTo(false);
+ }
+
+ @Test
+ void asBooleanWithNonBooleanFails() {
+ String value = "false";
+ AssertProvider actual = forValue(value);
+ assertThatExceptionOfType(AssertionError.class)
+ .isThrownBy(() -> assertThat(actual).asBoolean().isEqualTo(false))
+ .satisfies(hasFailedToBeOfType(value, "a boolean"));
+ }
+
+ @Test
+ void asBooleanWithNullFails() {
+ AssertProvider actual = forValue(null);
+ assertThatExceptionOfType(AssertionError.class)
+ .isThrownBy(() -> assertThat(actual).asBoolean().isEqualTo(false))
+ .satisfies(hasFailedToBeOfTypeWhenNull("a boolean"));
+ }
+ }
+
+ @Nested
+ class AsArrayTests { // json path uses List for arrays
+
+ @Test
+ void asArrayWithStringValues() {
+ assertThat(forValue(List.of("a", "b", "c"))).asArray().contains("a", "c");
+ }
+
+ @Test
+ void asArrayWithEmptyArray() {
+ assertThat(forValue(Collections.emptyList())).asArray().isEmpty();
+ }
+
+ @Test
+ void asArrayWithNonArrayFails() {
+ String value = "test";
+ AssertProvider actual = forValue(value);
+ assertThatExceptionOfType(AssertionError.class)
+ .isThrownBy(() -> assertThat(actual).asArray().contains("t"))
+ .satisfies(hasFailedToBeOfType(value, "an array"));
+ }
+
+ @Test
+ void asArrayWithNullFails() {
+ AssertProvider actual = forValue(null);
+ assertThatExceptionOfType(AssertionError.class)
+ .isThrownBy(() -> assertThat(actual).asArray().isEqualTo(false))
+ .satisfies(hasFailedToBeOfTypeWhenNull("an array"));
+ }
+ }
+
+ @Nested
+ class AsMapTests {
+
+ @Test
+ void asMapWithMapValue() {
+ assertThat(forValue(Map.of("zero", 0, "one", 1))).asMap().containsKeys("zero", "one")
+ .containsValues(0, 1);
+ }
+
+ @Test
+ void asArrayWithEmptyMap() {
+ assertThat(forValue(Collections.emptyMap())).asMap().isEmpty();
+ }
+
+ @Test
+ void asMapWithNonMapFails() {
+ List value = List.of("a", "b");
+ AssertProvider actual = forValue(value);
+ assertThatExceptionOfType(AssertionError.class)
+ .isThrownBy(() -> assertThat(actual).asMap().containsKey("a"))
+ .satisfies(hasFailedToBeOfType(value, "a map"));
+ }
+
+ @Test
+ void asMapWithNullFails() {
+ AssertProvider actual = forValue(null);
+ assertThatExceptionOfType(AssertionError.class)
+ .isThrownBy(() -> assertThat(actual).asMap().isEmpty())
+ .satisfies(hasFailedToBeOfTypeWhenNull("a map"));
+ }
+ }
+
+ @Nested
+ class ConvertToTests {
+
+ private static final MappingJackson2HttpMessageConverter jsonHttpMessageConverter =
+ new MappingJackson2HttpMessageConverter(new ObjectMapper());
+
+ @Test
+ void convertToWithoutHttpMessageConverter() {
+ AssertProvider actual = () -> new JsonPathValueAssert("123", "$.test", null);
+ assertThatIllegalStateException().isThrownBy(() -> assertThat(actual).convertTo(Integer.class))
+ .withMessage("No JSON message converter available to convert '123'");
+ }
+
+ @Test
+ void convertObjectToPojo() {
+ assertThat(forValue(Map.of("id", 1234, "name", "John", "active", true))).convertTo(User.class)
+ .satisfies(user -> {
+ assertThat(user.id).isEqualTo(1234);
+ assertThat(user.name).isEqualTo("John");
+ assertThat(user.active).isTrue();
+ });
+ }
+
+ @Test
+ void convertArrayToListOfPojo() {
+ Map, ?> user1 = Map.of("id", 1234, "name", "John", "active", true);
+ Map, ?> user2 = Map.of("id", 5678, "name", "Sarah", "active", false);
+ Map, ?> user3 = Map.of("id", 9012, "name", "Sophia", "active", true);
+ assertThat(forValue(List.of(user1, user2, user3)))
+ .convertTo(new ParameterizedTypeReference>() {})
+ .satisfies(users -> assertThat(users).hasSize(3).extracting("name")
+ .containsExactly("John", "Sarah", "Sophia"));
+ }
+
+ @Test
+ void convertObjectToPojoWithMissingMandatoryField() {
+ Map, ?> value = Map.of("firstName", "John");
+ AssertProvider actual = forValue(value);
+ assertThatExceptionOfType(AssertionError.class)
+ .isThrownBy(() -> assertThat(actual).convertTo(User.class))
+ .satisfies(hasFailedToConvertToType(value, User.class))
+ .withMessageContaining("firstName");
+ }
+
+
+ private AssertProvider forValue(@Nullable Object actual) {
+ return () -> new JsonPathValueAssert(actual, "$.test", jsonHttpMessageConverter);
+ }
+
+
+ private record User(long id, String name, boolean active) {}
+
+ }
+
+ @Nested
+ class EmptyNotEmptyTests {
+
+ @Test
+ void isEmptyWithEmptyString() {
+ assertThat(forValue("")).isEmpty();
+ }
+
+ @Test
+ void isEmptyWithNull() {
+ assertThat(forValue(null)).isEmpty();
+ }
+
+ @Test
+ void isEmptyWithEmptyArray() {
+ assertThat(forValue(Collections.emptyList())).isEmpty();
+ }
+
+ @Test
+ void isEmptyWithEmptyObject() {
+ assertThat(forValue(Collections.emptyMap())).isEmpty();
+ }
+
+ @Test
+ void isEmptyWithWhitespace() {
+ AssertProvider actual = forValue(" ");
+ assertThatExceptionOfType(AssertionError.class)
+ .isThrownBy(() -> assertThat(actual).isEmpty())
+ .satisfies(hasFailedEmptyCheck(" "));
+ }
+
+ @Test
+ void isNotEmptyWithString() {
+ assertThat(forValue("test")).isNotEmpty();
+ }
+
+ @Test
+ void isNotEmptyWithArray() {
+ assertThat(forValue(List.of("test"))).isNotEmpty();
+ }
+
+ @Test
+ void isNotEmptyWithObject() {
+ assertThat(forValue(Map.of("test", "value"))).isNotEmpty();
+ }
+
+ private Consumer hasFailedEmptyCheck(Object actual) {
+ return error -> assertThat(error.getMessage()).containsSubsequence("Expected value at JSON path \"$.test\":",
+ "" + StringUtils.quoteIfString(actual), "To be empty");
+ }
+ }
+
+
+ private Consumer hasFailedToBeOfType(Object actual, String expectedDescription) {
+ return error -> assertThat(error.getMessage()).containsSubsequence("Expected value at JSON path \"$.test\":",
+ "" + StringUtils.quoteIfString(actual), "To be " + expectedDescription, "But was:", actual.getClass().getName());
+ }
+
+ private Consumer hasFailedToBeOfTypeWhenNull(String expectedDescription) {
+ return error -> assertThat(error.getMessage()).containsSubsequence("Expected value at JSON path \"$.test\":", "null",
+ "To be " + expectedDescription);
+ }
+
+ private Consumer hasFailedToConvertToType(Object actual, Class> targetType) {
+ return error -> assertThat(error.getMessage()).containsSubsequence("Expected value at JSON path \"$.test\":",
+ "" + StringUtils.quoteIfString(actual), "To convert successfully to:", targetType.getTypeName(), "But it failed:");
+ }
+
+
+
+ private AssertProvider forValue(@Nullable Object actual) {
+ return () -> new JsonPathValueAssert(actual, "$.test", null);
+ }
+
+}