From 76f45c42895eb10af187bb1988adcb0a6c9f252e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Nicoll?= Date: Fri, 15 Mar 2024 13:16:51 +0100 Subject: [PATCH] Add support for JSON assertions using JSON path This commit moves JSON path AssertJ support from Spring Boot. See gh-21178 Co-authored-by: Brian Clozel --- .../test/json/AbstractJsonValueAssert.java | 235 ++++++++++++ .../test/json/JsonPathAssert.java | 165 +++++++++ .../test/json/JsonPathValueAssert.java | 48 +++ .../test/json/JsonPathAssertTests.java | 322 +++++++++++++++++ .../test/json/JsonPathValueAssertTests.java | 333 ++++++++++++++++++ 5 files changed, 1103 insertions(+) create mode 100644 spring-test/src/main/java/org/springframework/test/json/AbstractJsonValueAssert.java create mode 100644 spring-test/src/main/java/org/springframework/test/json/JsonPathAssert.java create mode 100644 spring-test/src/main/java/org/springframework/test/json/JsonPathValueAssert.java create mode 100644 spring-test/src/test/java/org/springframework/test/json/JsonPathAssertTests.java create mode 100644 spring-test/src/test/java/org/springframework/test/json/JsonPathValueAssertTests.java 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: + * + * 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 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, 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 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 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); + } + +}