From 97ebc43ea94d8e24a3a8142485cf806866006f32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Nicoll?= Date: Fri, 15 Mar 2024 13:14:38 +0100 Subject: [PATCH 1/7] Add support for JSON assertions using JSON compare This commit moves JSON content AssertJ support from Spring Boot. See gh-21178 Co-authored-by: Brian Clozel --- spring-test/spring-test.gradle | 1 + .../test/json/JsonContent.java | 73 +++ .../test/json/JsonContentAssert.java | 367 ++++++++++++++ .../springframework/test/json/JsonLoader.java | 74 +++ .../test/json/package-info.java | 9 + .../test/json/JsonContentAssertTests.java | 479 ++++++++++++++++++ .../test/json/JsonContentTests.java | 60 +++ .../springframework/test/json/different.json | 6 + .../springframework/test/json/example.json | 4 + .../test/json/lenient-same.json | 6 + .../org/springframework/test/json/nulls.json | 4 + .../springframework/test/json/simpsons.json | 36 ++ .../org/springframework/test/json/source.json | 6 + .../org/springframework/test/json/types.json | 18 + 14 files changed, 1143 insertions(+) create mode 100644 spring-test/src/main/java/org/springframework/test/json/JsonContent.java create mode 100644 spring-test/src/main/java/org/springframework/test/json/JsonContentAssert.java create mode 100644 spring-test/src/main/java/org/springframework/test/json/JsonLoader.java create mode 100644 spring-test/src/main/java/org/springframework/test/json/package-info.java create mode 100644 spring-test/src/test/java/org/springframework/test/json/JsonContentAssertTests.java create mode 100644 spring-test/src/test/java/org/springframework/test/json/JsonContentTests.java create mode 100644 spring-test/src/test/resources/org/springframework/test/json/different.json create mode 100644 spring-test/src/test/resources/org/springframework/test/json/example.json create mode 100644 spring-test/src/test/resources/org/springframework/test/json/lenient-same.json create mode 100644 spring-test/src/test/resources/org/springframework/test/json/nulls.json create mode 100644 spring-test/src/test/resources/org/springframework/test/json/simpsons.json create mode 100644 spring-test/src/test/resources/org/springframework/test/json/source.json create mode 100644 spring-test/src/test/resources/org/springframework/test/json/types.json diff --git a/spring-test/spring-test.gradle b/spring-test/spring-test.gradle index a7e09611ba1f..cfe5e5913a62 100644 --- a/spring-test/spring-test.gradle +++ b/spring-test/spring-test.gradle @@ -32,6 +32,7 @@ dependencies { optional("org.apache.groovy:groovy") optional("org.apache.tomcat.embed:tomcat-embed-core") optional("org.aspectj:aspectjweaver") + optional("org.assertj:assertj-core") optional("org.hamcrest:hamcrest") optional("org.htmlunit:htmlunit") { exclude group: "commons-logging", module: "commons-logging" diff --git a/spring-test/src/main/java/org/springframework/test/json/JsonContent.java b/spring-test/src/main/java/org/springframework/test/json/JsonContent.java new file mode 100644 index 000000000000..5725ac9bb171 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/json/JsonContent.java @@ -0,0 +1,73 @@ +/* + * 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 org.assertj.core.api.AssertProvider; + +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +/** + * JSON content usually created from a JSON tester. Generally used only to + * {@link AssertProvider provide} {@link JsonContentAssert} to AssertJ + * {@code assertThat} calls. + * + * @author Phillip Webb + * @author Diego Berrueta + * @since 6.2 + */ +public final class JsonContent implements AssertProvider { + + private final String json; + + @Nullable + private final Class resourceLoadClass; + + /** + * Create a new {@link JsonContent} instance. + * @param json the actual JSON content + * @param resourceLoadClass the source class used to load resources + */ + JsonContent(String json, @Nullable Class resourceLoadClass) { + Assert.notNull(json, "JSON must not be null"); + this.json = json; + this.resourceLoadClass = resourceLoadClass; + } + + /** + * Use AssertJ's {@link org.assertj.core.api.Assertions#assertThat assertThat} + * instead. + */ + @Override + public JsonContentAssert assertThat() { + return new JsonContentAssert(this.json, this.resourceLoadClass, null); + } + + /** + * Return the actual JSON content string. + * @return the JSON content + */ + public String getJson() { + return this.json; + } + + @Override + public String toString() { + return "JsonContent " + this.json; + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/json/JsonContentAssert.java b/spring-test/src/main/java/org/springframework/test/json/JsonContentAssert.java new file mode 100644 index 000000000000..a606ce940a2e --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/json/JsonContentAssert.java @@ -0,0 +1,367 @@ +/* + * 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.io.File; +import java.io.InputStream; +import java.nio.charset.Charset; +import java.nio.file.Path; + +import org.assertj.core.api.AbstractAssert; +import org.skyscreamer.jsonassert.JSONCompare; +import org.skyscreamer.jsonassert.JSONCompareMode; +import org.skyscreamer.jsonassert.JSONCompareResult; +import org.skyscreamer.jsonassert.comparator.JSONComparator; + +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.FileSystemResource; +import org.springframework.core.io.InputStreamResource; +import org.springframework.core.io.Resource; +import org.springframework.lang.Nullable; +import org.springframework.util.function.ThrowingBiFunction; + +/** + * AssertJ {@link org.assertj.core.api.Assert assertions} that can be applied + * to a {@link CharSequence} representation of a json document, mostly to + * compare the json document against a target, using {@linkplain JSONCompare + * JSON Assert}. + * + * @author Phillip Webb + * @author Andy Wilkinson + * @author Diego Berrueta + * @author Camille Vienot + * @author Stephane Nicoll + * @since 6.2 + */ +public class JsonContentAssert extends AbstractAssert { + + private final JsonLoader loader; + + /** + * Create a new {@link JsonContentAssert} instance that will load resources + * relative to the given {@code resourceLoadClass}, using the given + * {@code charset}. + * @param json the actual JSON content + * @param resourceLoadClass the source class used to load resources + * @param charset the charset of the JSON resources + */ + public JsonContentAssert(@Nullable CharSequence json, @Nullable Class resourceLoadClass, + @Nullable Charset charset) { + + super(json, JsonContentAssert.class); + this.loader = new JsonLoader(resourceLoadClass, charset); + } + + /** + * Create a new {@link JsonContentAssert} instance that will load resources + * relative to the given {@code resourceLoadClass}, using {@code UTF-8}. + * @param json the actual JSON content + * @param resourceLoadClass the source class used to load resources + */ + public JsonContentAssert(@Nullable CharSequence json, @Nullable Class resourceLoadClass) { + this(json, resourceLoadClass, null); + } + + + /** + * Verify that the actual value is equal to the given JSON. The + * {@code expected} value can contain the JSON itself or, if it ends with + * {@code .json}, the name of a resource to be loaded from the classpath. + * @param expected the expected JSON or the name of a resource containing + * the expected JSON + * @param compareMode the compare mode used when checking + */ + public JsonContentAssert isEqualTo(@Nullable CharSequence expected, JSONCompareMode compareMode) { + String expectedJson = this.loader.getJson(expected); + return assertNotFailed(compare(expectedJson, compareMode)); + } + + /** + * Verify that the actual value is equal to the given JSON {@link Resource}. + *

The resource abstraction allows to provide several input types: + *

    + *
  • a {@code byte} array, using {@link ByteArrayResource}
  • + *
  • a {@code classpath} resource, using {@link ClassPathResource}
  • + *
  • a {@link File} or {@link Path}, using {@link FileSystemResource}
  • + *
  • an {@link InputStream}, using {@link InputStreamResource}
  • + *
+ * @param expected a resource containing the expected JSON + * @param compareMode the compare mode used when checking + */ + public JsonContentAssert isEqualTo(Resource expected, JSONCompareMode compareMode) { + String expectedJson = this.loader.getJson(expected); + return assertNotFailed(compare(expectedJson, compareMode)); + } + + /** + * Verify that the actual value is equal to the given JSON. The + * {@code expected} value can contain the JSON itself or, if it ends with + * {@code .json}, the name of a resource to be loaded from the classpath. + * @param expected the expected JSON or the name of a resource containing + * the expected JSON + * @param comparator the comparator used when checking + */ + public JsonContentAssert isEqualTo(@Nullable CharSequence expected, JSONComparator comparator) { + String expectedJson = this.loader.getJson(expected); + return assertNotFailed(compare(expectedJson, comparator)); + } + + /** + * Verify that the actual value is equal to the given JSON {@link Resource}. + *

The resource abstraction allows to provide several input types: + *

    + *
  • a {@code byte} array, using {@link ByteArrayResource}
  • + *
  • a {@code classpath} resource, using {@link ClassPathResource}
  • + *
  • a {@link File} or {@link Path}, using {@link FileSystemResource}
  • + *
  • an {@link InputStream}, using {@link InputStreamResource}
  • + *
+ * @param expected a resource containing the expected JSON + * @param comparator the comparator used when checking + */ + public JsonContentAssert isEqualTo(Resource expected, JSONComparator comparator) { + String expectedJson = this.loader.getJson(expected); + return assertNotFailed(compare(expectedJson, comparator)); + } + + /** + * Verify that the actual value is {@link JSONCompareMode#LENIENT leniently} + * equal to the given JSON. The {@code expected} value can contain the JSON + * itself or, if it ends with {@code .json}, the name of a resource to be + * loaded from the classpath. + * @param expected the expected JSON or the name of a resource containing + * the expected JSON + */ + public JsonContentAssert isLenientlyEqualTo(@Nullable CharSequence expected) { + return isEqualTo(expected, JSONCompareMode.LENIENT); + } + + /** + * Verify that the actual value is {@link JSONCompareMode#LENIENT leniently} + * equal to the given JSON {@link Resource}. + *

The resource abstraction allows to provide several input types: + *

    + *
  • a {@code byte} array, using {@link ByteArrayResource}
  • + *
  • a {@code classpath} resource, using {@link ClassPathResource}
  • + *
  • a {@link File} or {@link Path}, using {@link FileSystemResource}
  • + *
  • an {@link InputStream}, using {@link InputStreamResource}
  • + *
+ * @param expected a resource containing the expected JSON + */ + public JsonContentAssert isLenientlyEqualTo(Resource expected) { + return isEqualTo(expected, JSONCompareMode.LENIENT); + } + + /** + * Verify that the actual value is {@link JSONCompareMode#STRICT strictly} + * equal to the given JSON. The {@code expected} value can contain the JSON + * itself or, if it ends with {@code .json}, the name of a resource to be + * loaded from the classpath. + * @param expected the expected JSON or the name of a resource containing + * the expected JSON + */ + public JsonContentAssert isStrictlyEqualTo(@Nullable CharSequence expected) { + return isEqualTo(expected, JSONCompareMode.STRICT); + } + + /** + * Verify that the actual value is {@link JSONCompareMode#STRICT strictly} + * equal to the given JSON {@link Resource}. + *

The resource abstraction allows to provide several input types: + *

    + *
  • a {@code byte} array, using {@link ByteArrayResource}
  • + *
  • a {@code classpath} resource, using {@link ClassPathResource}
  • + *
  • a {@link File} or {@link Path}, using {@link FileSystemResource}
  • + *
  • an {@link InputStream}, using {@link InputStreamResource}
  • + *
+ * @param expected a resource containing the expected JSON + */ + public JsonContentAssert isStrictlyEqualTo(Resource expected) { + return isEqualTo(expected, JSONCompareMode.STRICT); + } + + /** + * Verify that the actual value is not equal to the given JSON. The + * {@code expected} value can contain the JSON itself or, if it ends with + * {@code .json}, the name of a resource to be loaded from the classpath. + * @param expected the expected JSON or the name of a resource containing + * the expected JSON + * @param compareMode the compare mode used when checking + */ + public JsonContentAssert isNotEqualTo(@Nullable CharSequence expected, JSONCompareMode compareMode) { + String expectedJson = this.loader.getJson(expected); + return assertNotPassed(compare(expectedJson, compareMode)); + } + + /** + * Verify that the actual value is not equal to the given JSON {@link Resource}. + *

The resource abstraction allows to provide several input types: + *

    + *
  • a {@code byte} array, using {@link ByteArrayResource}
  • + *
  • a {@code classpath} resource, using {@link ClassPathResource}
  • + *
  • a {@link File} or {@link Path}, using {@link FileSystemResource}
  • + *
  • an {@link InputStream}, using {@link InputStreamResource}
  • + *
+ * @param expected a resource containing the expected JSON + * @param compareMode the compare mode used when checking + */ + public JsonContentAssert isNotEqualTo(Resource expected, JSONCompareMode compareMode) { + String expectedJson = this.loader.getJson(expected); + return assertNotPassed(compare(expectedJson, compareMode)); + } + + /** + * Verify that the actual value is not equal to the given JSON. The + * {@code expected} value can contain the JSON itself or, if it ends with + * {@code .json}, the name of a resource to be loaded from the classpath. + * @param expected the expected JSON or the name of a resource containing + * the expected JSON + * @param comparator the comparator used when checking + */ + public JsonContentAssert isNotEqualTo(@Nullable CharSequence expected, JSONComparator comparator) { + String expectedJson = this.loader.getJson(expected); + return assertNotPassed(compare(expectedJson, comparator)); + } + + /** + * Verify that the actual value is not equal to the given JSON {@link Resource}. + *

The resource abstraction allows to provide several input types: + *

    + *
  • a {@code byte} array, using {@link ByteArrayResource}
  • + *
  • a {@code classpath} resource, using {@link ClassPathResource}
  • + *
  • a {@link File} or {@link Path}, using {@link FileSystemResource}
  • + *
  • an {@link InputStream}, using {@link InputStreamResource}
  • + *
+ * @param expected a resource containing the expected JSON + * @param comparator the comparator used when checking + */ + public JsonContentAssert isNotEqualTo(Resource expected, JSONComparator comparator) { + String expectedJson = this.loader.getJson(expected); + return assertNotPassed(compare(expectedJson, comparator)); + } + + /** + * Verify that the actual value is not {@link JSONCompareMode#LENIENT + * leniently} equal to the given JSON. The {@code expected} value can + * contain the JSON itself or, if it ends with {@code .json}, the name of a + * resource to be loaded from the classpath. + * @param expected the expected JSON or the name of a resource containing + * the expected JSON + */ + public JsonContentAssert isNotLenientlyEqualTo(@Nullable CharSequence expected) { + return isNotEqualTo(expected, JSONCompareMode.LENIENT); + } + + /** + * Verify that the actual value is not {@link JSONCompareMode#LENIENT + * leniently} equal to the given JSON {@link Resource}. + *

The resource abstraction allows to provide several input types: + *

    + *
  • a {@code byte} array, using {@link ByteArrayResource}
  • + *
  • a {@code classpath} resource, using {@link ClassPathResource}
  • + *
  • a {@link File} or {@link Path}, using {@link FileSystemResource}
  • + *
  • an {@link InputStream}, using {@link InputStreamResource}
  • + *
+ * @param expected a resource containing the expected JSON + */ + public JsonContentAssert isNotLenientlyEqualTo(Resource expected) { + return isNotEqualTo(expected, JSONCompareMode.LENIENT); + } + + /** + * Verify that the actual value is not {@link JSONCompareMode#STRICT + * strictly} equal to the given JSON. The {@code expected} value can + * contain the JSON itself or, if it ends with {@code .json}, the name of a + * resource to be loaded from the classpath. + * @param expected the expected JSON or the name of a resource containing + * the expected JSON + */ + public JsonContentAssert isNotStrictlyEqualTo(@Nullable CharSequence expected) { + return isNotEqualTo(expected, JSONCompareMode.STRICT); + } + + /** + * Verify that the actual value is not {@link JSONCompareMode#STRICT + * strictly} equal to the given JSON {@link Resource}. + *

The resource abstraction allows to provide several input types: + *

    + *
  • a {@code byte} array, using {@link ByteArrayResource}
  • + *
  • a {@code classpath} resource, using {@link ClassPathResource}
  • + *
  • a {@link File} or {@link Path}, using {@link FileSystemResource}
  • + *
  • an {@link InputStream}, using {@link InputStreamResource}
  • + *
+ * @param expected a resource containing the expected JSON + */ + public JsonContentAssert isNotStrictlyEqualTo(Resource expected) { + return isNotEqualTo(expected, JSONCompareMode.STRICT); + } + + + private JSONCompareResult compare(@Nullable CharSequence expectedJson, JSONCompareMode compareMode) { + return compare(this.actual, expectedJson, (actualJsonString, expectedJsonString) -> + JSONCompare.compareJSON(expectedJsonString, actualJsonString, compareMode)); + } + + private JSONCompareResult compare(@Nullable CharSequence expectedJson, JSONComparator comparator) { + return compare(this.actual, expectedJson, (actualJsonString, expectedJsonString) -> + JSONCompare.compareJSON(expectedJsonString, actualJsonString, comparator)); + } + + private JSONCompareResult compare(@Nullable CharSequence actualJson, @Nullable CharSequence expectedJson, + ThrowingBiFunction comparator) { + + if (actualJson == null) { + return compareForNull(expectedJson); + } + if (expectedJson == null) { + return compareForNull(actualJson.toString()); + } + try { + return comparator.applyWithException(actualJson.toString(), expectedJson.toString()); + } + catch (Exception ex) { + if (ex instanceof RuntimeException runtimeException) { + throw runtimeException; + } + throw new IllegalStateException(ex); + } + } + + private JSONCompareResult compareForNull(@Nullable CharSequence expectedJson) { + JSONCompareResult result = new JSONCompareResult(); + result.passed(); + if (expectedJson != null) { + result.fail("Expected null JSON"); + } + return result; + } + + private JsonContentAssert assertNotFailed(JSONCompareResult result) { + if (result.failed()) { + failWithMessage("JSON Comparison failure: %s", result.getMessage()); + } + return this; + } + + private JsonContentAssert assertNotPassed(JSONCompareResult result) { + if (result.passed()) { + failWithMessage("JSON Comparison failure: %s", result.getMessage()); + } + return this; + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/json/JsonLoader.java b/spring-test/src/main/java/org/springframework/test/json/JsonLoader.java new file mode 100644 index 000000000000..8fc0efb650d2 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/json/JsonLoader.java @@ -0,0 +1,74 @@ +/* + * 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.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; + +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; +import org.springframework.lang.Nullable; +import org.springframework.util.FileCopyUtils; + +/** + * Internal helper used to load JSON from various sources. + * + * @author Phillip Webb + * @author Andy Wilkinson + * @author Stephane Nicoll + * @since 6.2 + */ +class JsonLoader { + + @Nullable + private final Class resourceLoadClass; + + private final Charset charset; + + JsonLoader(@Nullable Class resourceLoadClass, @Nullable Charset charset) { + this.resourceLoadClass = resourceLoadClass; + this.charset = (charset != null ? charset : StandardCharsets.UTF_8); + } + + @Nullable + String getJson(@Nullable CharSequence source) { + if (source == null) { + return null; + } + if (source.toString().endsWith(".json")) { + return getJson(new ClassPathResource(source.toString(), this.resourceLoadClass)); + } + return source.toString(); + } + + String getJson(Resource source) { + try { + return getJson(source.getInputStream()); + } + catch (IOException ex) { + throw new IllegalStateException("Unable to load JSON from " + source, ex); + } + } + + private String getJson(InputStream source) throws IOException { + return FileCopyUtils.copyToString(new InputStreamReader(source, this.charset)); + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/json/package-info.java b/spring-test/src/main/java/org/springframework/test/json/package-info.java new file mode 100644 index 000000000000..cf1085f3b403 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/json/package-info.java @@ -0,0 +1,9 @@ +/** + * Testing support for JSON. + */ +@NonNullApi +@NonNullFields +package org.springframework.test.json; + +import org.springframework.lang.NonNullApi; +import org.springframework.lang.NonNullFields; diff --git a/spring-test/src/test/java/org/springframework/test/json/JsonContentAssertTests.java b/spring-test/src/test/java/org/springframework/test/json/JsonContentAssertTests.java new file mode 100644 index 000000000000..02c839bd8e03 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/json/JsonContentAssertTests.java @@ -0,0 +1,479 @@ +/* + * 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.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.stream.Stream; + +import org.assertj.core.api.AssertProvider; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.TestInstance.Lifecycle; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.skyscreamer.jsonassert.JSONCompareMode; +import org.skyscreamer.jsonassert.comparator.DefaultComparator; +import org.skyscreamer.jsonassert.comparator.JSONComparator; + +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.FileSystemResource; +import org.springframework.core.io.InputStreamResource; +import org.springframework.core.io.Resource; +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; + +/** + * Tests for {@link JsonContentAssert}. + * + * @author Stephane Nicoll + * @author Phillip Webb + */ +@TestInstance(Lifecycle.PER_CLASS) +class JsonContentAssertTests { + + private static final String SOURCE = loadJson("source.json"); + + private static final String LENIENT_SAME = loadJson("lenient-same.json"); + + private static final String DIFFERENT = loadJson("different.json"); + + private static final JSONComparator COMPARATOR = new DefaultComparator(JSONCompareMode.LENIENT); + + @Test + void isEqualToWhenStringIsMatchingShouldPass() { + assertThat(forJson(SOURCE)).isEqualTo(SOURCE); + } + + @Test + void isEqualToWhenNullActualShouldFail() { + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + assertThat(forJson(null)).isEqualTo(SOURCE)); + } + + @Test + void isEqualToWhenExpectedIsNotAStringShouldFail() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(forJson(SOURCE)).isEqualTo(SOURCE.getBytes())); + } + + @Test + void isEqualToWhenExpectedIsNullShouldFail() { + CharSequence actual = null; + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(forJson(SOURCE)).isEqualTo(actual, JSONCompareMode.LENIENT)); + } + + @Test + void isEqualToWhenStringIsMatchingAndLenientShouldPass() { + assertThat(forJson(SOURCE)).isEqualTo(LENIENT_SAME, JSONCompareMode.LENIENT); + } + + @Test + void isEqualToWhenStringIsNotMatchingAndLenientShouldFail() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(forJson(SOURCE)).isEqualTo(DIFFERENT, JSONCompareMode.LENIENT)); + } + + @Test + void isEqualToWhenResourcePathIsMatchingAndLenientShouldPass() { + assertThat(forJson(SOURCE)).isEqualTo("lenient-same.json", JSONCompareMode.LENIENT); + } + + @Test + void isEqualToWhenResourcePathIsNotMatchingAndLenientShouldFail() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(forJson(SOURCE)).isEqualTo("different.json", JSONCompareMode.LENIENT)); + } + + Stream source() { + return Stream.of( + Arguments.of(new ClassPathResource("source.json", JsonContentAssertTests.class)), + Arguments.of(new ByteArrayResource(SOURCE.getBytes())), + Arguments.of(new FileSystemResource(createFile(SOURCE))), + Arguments.of(new InputStreamResource(createInputStream(SOURCE)))); + } + + Stream lenientSame() { + return Stream.of( + Arguments.of(new ClassPathResource("lenient-same.json", JsonContentAssertTests.class)), + Arguments.of(new ByteArrayResource(LENIENT_SAME.getBytes())), + Arguments.of(new FileSystemResource(createFile(LENIENT_SAME))), + Arguments.of(new InputStreamResource(createInputStream(LENIENT_SAME)))); + } + + Stream different() { + return Stream.of( + Arguments.of(new ClassPathResource("different.json", JsonContentAssertTests.class)), + Arguments.of(new ByteArrayResource(DIFFERENT.getBytes())), + Arguments.of(new FileSystemResource(createFile(DIFFERENT))), + Arguments.of(new InputStreamResource(createInputStream(DIFFERENT)))); + } + + @ParameterizedTest + @MethodSource("lenientSame") + void isEqualToWhenResourceIsMatchingAndLenientSameShouldPass(Resource expected) { + assertThat(forJson(SOURCE)).isEqualTo(expected, JSONCompareMode.LENIENT); + } + + @ParameterizedTest + @MethodSource("different") + void isEqualToWhenResourceIsNotMatchingAndLenientShouldFail(Resource expected) { + assertThatExceptionOfType(AssertionError.class).isThrownBy( + () -> assertThat(forJson(SOURCE)).isEqualTo(expected, JSONCompareMode.LENIENT)); + } + + + @Test + void isEqualToWhenStringIsMatchingAndComparatorShouldPass() { + assertThat(forJson(SOURCE)).isEqualTo(LENIENT_SAME, COMPARATOR); + } + + @Test + void isEqualToWhenStringIsNotMatchingAndComparatorShouldFail() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(forJson(SOURCE)).isEqualTo(DIFFERENT, COMPARATOR)); + } + + @Test + void isEqualToWhenResourcePathIsMatchingAndComparatorShouldPass() { + assertThat(forJson(SOURCE)).isEqualTo("lenient-same.json", COMPARATOR); + } + + @Test + void isEqualToWhenResourcePathIsNotMatchingAndComparatorShouldFail() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(forJson(SOURCE)).isEqualTo("different.json", COMPARATOR)); + } + + @ParameterizedTest + @MethodSource("lenientSame") + void isEqualToWhenResourceIsMatchingAndComparatorShouldPass(Resource expected) { + assertThat(forJson(SOURCE)).isEqualTo(expected, COMPARATOR); + } + + @ParameterizedTest + @MethodSource("different") + void isEqualToWhenResourceIsNotMatchingAndComparatorShouldFail(Resource expected) { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(forJson(SOURCE)).isEqualTo(expected, COMPARATOR)); + } + + @Test + void isLenientlyEqualToWhenStringIsMatchingShouldPass() { + assertThat(forJson(SOURCE)).isLenientlyEqualTo(LENIENT_SAME); + } + + @Test + void isLenientlyEqualToWhenNullActualShouldFail() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(forJson(null)).isLenientlyEqualTo(SOURCE)); + } + + @Test + void isLenientlyEqualToWhenStringIsNotMatchingShouldFail() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(forJson(SOURCE)).isLenientlyEqualTo(DIFFERENT)); + } + + @Test + void isLenientlyEqualToWhenExpectedDoesNotExistShouldFail() { + assertThatIllegalStateException() + .isThrownBy(() -> assertThat(forJson(SOURCE)).isLenientlyEqualTo("does-not-exist.json")) + .withMessage("Unable to load JSON from class path resource [org/springframework/test/json/does-not-exist.json]"); + } + + @Test + void isLenientlyEqualToWhenResourcePathIsMatchingShouldPass() { + assertThat(forJson(SOURCE)).isLenientlyEqualTo("lenient-same.json"); + } + + @Test + void isLenientlyEqualToWhenResourcePathIsNotMatchingShouldFail() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(forJson(SOURCE)).isLenientlyEqualTo("different.json")); + } + + @ParameterizedTest + @MethodSource("lenientSame") + void isLenientlyEqualToWhenResourceIsMatchingShouldPass(Resource expected) { + assertThat(forJson(SOURCE)).isLenientlyEqualTo(expected); + } + + @ParameterizedTest + @MethodSource("different") + void isLenientlyEqualToWhenResourceIsNotMatchingShouldFail(Resource expected) { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(forJson(SOURCE)).isLenientlyEqualTo(expected)); + } + + @Test + void isStrictlyEqualToWhenStringIsMatchingShouldPass() { + assertThat(forJson(SOURCE)).isStrictlyEqualTo(SOURCE); + } + + @Test + void isStrictlyEqualToWhenStringIsNotMatchingShouldFail() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(forJson(SOURCE)).isStrictlyEqualTo(LENIENT_SAME)); + } + + @Test + void isStrictlyEqualToWhenResourcePathIsMatchingShouldPass() { + assertThat(forJson(SOURCE)).isStrictlyEqualTo("source.json"); + } + + @Test + void isStrictlyEqualToWhenResourcePathIsNotMatchingShouldFail() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(forJson(SOURCE)).isStrictlyEqualTo("lenient-same.json")); + } + + @ParameterizedTest + @MethodSource("source") + void isStrictlyEqualToWhenResourceIsMatchingShouldPass(Resource expected) { + assertThat(forJson(SOURCE)).isStrictlyEqualTo(expected); + } + + @ParameterizedTest + @MethodSource("lenientSame") + void isStrictlyEqualToWhenResourceIsNotMatchingShouldFail(Resource expected) { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(forJson(SOURCE)).isStrictlyEqualTo(expected)); + } + + + @Test + void isNotEqualToWhenStringIsMatchingShouldFail() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(forJson(SOURCE)).isNotEqualTo(SOURCE)); + } + + @Test + void isNotEqualToWhenNullActualShouldPass() { + assertThat(forJson(null)).isNotEqualTo(SOURCE); + } + + @Test + void isNotEqualToWhenStringIsNotMatchingShouldPass() { + assertThat(forJson(SOURCE)).isNotEqualTo(DIFFERENT); + } + + @Test + void isNotEqualToAsObjectWhenExpectedIsNotAStringShouldNotFail() { + assertThat(forJson(SOURCE)).isNotEqualTo(SOURCE.getBytes()); + } + + @Test + void isNotEqualToWhenStringIsMatchingAndLenientShouldFail() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(forJson(SOURCE)).isNotEqualTo(LENIENT_SAME, JSONCompareMode.LENIENT)); + } + + @Test + void isNotEqualToWhenStringIsNotMatchingAndLenientShouldPass() { + assertThat(forJson(SOURCE)).isNotEqualTo(DIFFERENT, JSONCompareMode.LENIENT); + } + + @Test + void isNotEqualToWhenResourcePathIsMatchingAndLenientShouldFail() { + assertThatExceptionOfType(AssertionError.class).isThrownBy( + () -> assertThat(forJson(SOURCE)).isNotEqualTo("lenient-same.json", JSONCompareMode.LENIENT)); + } + + @Test + void isNotEqualToWhenResourcePathIsNotMatchingAndLenientShouldPass() { + assertThat(forJson(SOURCE)).isNotEqualTo("different.json", JSONCompareMode.LENIENT); + } + + @ParameterizedTest + @MethodSource("lenientSame") + void isNotEqualToWhenResourceIsMatchingAndLenientShouldFail(Resource expected) { + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> assertThat(forJson(SOURCE)) + .isNotEqualTo(expected, JSONCompareMode.LENIENT)); + } + + @ParameterizedTest + @MethodSource("different") + void isNotEqualToWhenResourceIsNotMatchingAndLenientShouldPass(Resource expected) { + assertThat(forJson(SOURCE)).isNotEqualTo(expected, JSONCompareMode.LENIENT); + } + + @Test + void isNotEqualToWhenStringIsMatchingAndComparatorShouldFail() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(forJson(SOURCE)).isNotEqualTo(LENIENT_SAME, COMPARATOR)); + } + + @Test + void isNotEqualToWhenStringIsNotMatchingAndComparatorShouldPass() { + assertThat(forJson(SOURCE)).isNotEqualTo(DIFFERENT, COMPARATOR); + } + + @Test + void isNotEqualToWhenResourcePathIsMatchingAndComparatorShouldFail() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(forJson(SOURCE)).isNotEqualTo("lenient-same.json", COMPARATOR)); + } + + @Test + void isNotEqualToWhenResourcePathIsNotMatchingAndComparatorShouldPass() { + assertThat(forJson(SOURCE)).isNotEqualTo("different.json", COMPARATOR); + } + + @ParameterizedTest + @MethodSource("lenientSame") + void isNotEqualToWhenResourceIsMatchingAndComparatorShouldFail(Resource expected) { + assertThatExceptionOfType(AssertionError.class).isThrownBy( + () -> assertThat(forJson(SOURCE)).isNotEqualTo(expected, COMPARATOR)); + } + + @ParameterizedTest + @MethodSource("different") + void isNotEqualToWhenResourceIsNotMatchingAndComparatorShouldPass(Resource expected) { + assertThat(forJson(SOURCE)).isNotEqualTo(expected, COMPARATOR); + } + + @Test + void isNotEqualToWhenResourceIsMatchingAndComparatorShouldFail() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(forJson(SOURCE)).isNotEqualTo(createResource(LENIENT_SAME), COMPARATOR)); + } + + @Test + void isNotEqualToWhenResourceIsNotMatchingAndComparatorShouldPass() { + assertThat(forJson(SOURCE)).isNotEqualTo(createResource(DIFFERENT), COMPARATOR); + } + + @Test + void isNotLenientlyEqualToWhenNullActualShouldPass() { + assertThat(forJson(null)).isNotLenientlyEqualTo(SOURCE); + } + + @Test + void isNotLenientlyEqualToWhenStringIsNotMatchingShouldPass() { + assertThat(forJson(SOURCE)).isNotLenientlyEqualTo(DIFFERENT); + } + + @Test + void isNotLenientlyEqualToWhenResourcePathIsMatchingShouldFail() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(forJson(SOURCE)).isNotLenientlyEqualTo("lenient-same.json")); + } + + @Test + void isNotLenientlyEqualToWhenResourcePathIsNotMatchingShouldPass() { + assertThat(forJson(SOURCE)).isNotLenientlyEqualTo("different.json"); + } + + @ParameterizedTest + @MethodSource("lenientSame") + void isNotLenientlyEqualToWhenResourceIsMatchingShouldFail(Resource expected) { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(forJson(SOURCE)).isNotLenientlyEqualTo(expected)); + } + + @ParameterizedTest + @MethodSource("different") + void isNotLenientlyEqualToWhenResourceIsNotMatchingShouldPass(Resource expected) { + assertThat(forJson(SOURCE)).isNotLenientlyEqualTo(expected); + } + + @Test + void isNotStrictlyEqualToWhenStringIsMatchingShouldFail() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(forJson(SOURCE)).isNotStrictlyEqualTo(SOURCE)); + } + + @Test + void isNotStrictlyEqualToWhenStringIsNotMatchingShouldPass() { + assertThat(forJson(SOURCE)).isNotStrictlyEqualTo(LENIENT_SAME); + } + + @Test + void isNotStrictlyEqualToWhenResourcePathIsMatchingShouldFail() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(forJson(SOURCE)).isNotStrictlyEqualTo("source.json")); + } + + @Test + void isNotStrictlyEqualToWhenResourcePathIsNotMatchingShouldPass() { + assertThat(forJson(SOURCE)).isNotStrictlyEqualTo("lenient-same.json"); + } + + @ParameterizedTest + @MethodSource("source") + void isNotStrictlyEqualToWhenResourceIsMatchingShouldFail(Resource expected) { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(forJson(SOURCE)).isNotStrictlyEqualTo(expected)); + } + + @ParameterizedTest + @MethodSource("lenientSame") + void isNotStrictlyEqualToWhenResourceIsNotMatchingShouldPass(Resource expected) { + assertThat(forJson(SOURCE)).isNotStrictlyEqualTo(expected); + } + + @Test + void isNullWhenActualIsNullShouldPass() { + assertThat(forJson(null)).isNull(); + } + + private Path createFile(String content) { + try { + Path temp = Files.createTempFile("file", ".json"); + Files.writeString(temp, content); + return temp; + } + catch (IOException ex) { + throw new IllegalStateException(ex); + } + } + + private InputStream createInputStream(String content) { + return new ByteArrayInputStream(content.getBytes()); + } + + private Resource createResource(String content) { + return new ByteArrayResource(content.getBytes()); + } + + private static String loadJson(String path) { + try { + ClassPathResource resource = new ClassPathResource(path, JsonContentAssertTests.class); + return new String(FileCopyUtils.copyToByteArray(resource.getInputStream())); + } + catch (Exception ex) { + throw new IllegalStateException(ex); + } + + } + + private AssertProvider forJson(@Nullable String json) { + return () -> new JsonContentAssert(json, JsonContentAssertTests.class); + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/json/JsonContentTests.java b/spring-test/src/test/java/org/springframework/test/json/JsonContentTests.java new file mode 100644 index 000000000000..6e4131c46f66 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/json/JsonContentTests.java @@ -0,0 +1,60 @@ +/* + * 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 org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Tests for {@link JsonContent}. + * + * @author Phillip Webb + */ +class JsonContentTests { + + private static final String JSON = "{\"name\":\"spring\", \"age\":100}"; + + @Test + void createWhenJsonIsNullShouldThrowException() { + assertThatIllegalArgumentException() + .isThrownBy( + () -> new JsonContent(null, null)) + .withMessageContaining("JSON must not be null"); + } + + @Test + @SuppressWarnings("deprecation") + void assertThatShouldReturnJsonContentAssert() { + JsonContent content = new JsonContent(JSON, getClass()); + assertThat(content.assertThat()).isInstanceOf(JsonContentAssert.class); + } + + @Test + void getJsonShouldReturnJson() { + JsonContent content = new JsonContent(JSON, getClass()); + assertThat(content.getJson()).isEqualTo(JSON); + } + + @Test + void toStringShouldReturnString() { + JsonContent content = new JsonContent(JSON, getClass()); + assertThat(content.toString()).isEqualTo("JsonContent " + JSON); + } + +} diff --git a/spring-test/src/test/resources/org/springframework/test/json/different.json b/spring-test/src/test/resources/org/springframework/test/json/different.json new file mode 100644 index 000000000000..d641ea86e155 --- /dev/null +++ b/spring-test/src/test/resources/org/springframework/test/json/different.json @@ -0,0 +1,6 @@ +{ + "gnirps": [ + "boot", + "framework" + ] +} diff --git a/spring-test/src/test/resources/org/springframework/test/json/example.json b/spring-test/src/test/resources/org/springframework/test/json/example.json new file mode 100644 index 000000000000..cb218493f63a --- /dev/null +++ b/spring-test/src/test/resources/org/springframework/test/json/example.json @@ -0,0 +1,4 @@ +{ + "name": "Spring", + "age": 123 +} diff --git a/spring-test/src/test/resources/org/springframework/test/json/lenient-same.json b/spring-test/src/test/resources/org/springframework/test/json/lenient-same.json new file mode 100644 index 000000000000..89367f7bf4a2 --- /dev/null +++ b/spring-test/src/test/resources/org/springframework/test/json/lenient-same.json @@ -0,0 +1,6 @@ +{ + "spring": [ + "framework", + "boot" + ] +} diff --git a/spring-test/src/test/resources/org/springframework/test/json/nulls.json b/spring-test/src/test/resources/org/springframework/test/json/nulls.json new file mode 100644 index 000000000000..1c1d3078254a --- /dev/null +++ b/spring-test/src/test/resources/org/springframework/test/json/nulls.json @@ -0,0 +1,4 @@ +{ + "valuename": "spring", + "nullname": null +} diff --git a/spring-test/src/test/resources/org/springframework/test/json/simpsons.json b/spring-test/src/test/resources/org/springframework/test/json/simpsons.json new file mode 100644 index 000000000000..1117d6864e17 --- /dev/null +++ b/spring-test/src/test/resources/org/springframework/test/json/simpsons.json @@ -0,0 +1,36 @@ +{ + "familyMembers": [ + { + "name": "Homer" + }, + { + "name": "Marge" + }, + { + "name": "Bart" + }, + { + "name": "Lisa" + }, + { + "name": "Maggie" + } + ], + "indexedFamilyMembers": { + "father": { + "name": "Homer" + }, + "mother": { + "name": "Marge" + }, + "son": { + "name": "Bart" + }, + "daughter": { + "name": "Lisa" + }, + "baby": { + "name": "Maggie" + } + } +} diff --git a/spring-test/src/test/resources/org/springframework/test/json/source.json b/spring-test/src/test/resources/org/springframework/test/json/source.json new file mode 100644 index 000000000000..1b179b925301 --- /dev/null +++ b/spring-test/src/test/resources/org/springframework/test/json/source.json @@ -0,0 +1,6 @@ +{ + "spring": [ + "boot", + "framework" + ] +} diff --git a/spring-test/src/test/resources/org/springframework/test/json/types.json b/spring-test/src/test/resources/org/springframework/test/json/types.json new file mode 100644 index 000000000000..dd2dda3f1901 --- /dev/null +++ b/spring-test/src/test/resources/org/springframework/test/json/types.json @@ -0,0 +1,18 @@ +{ + "str": "foo", + "num": 5, + "pi": 3.1415926, + "bool": true, + "arr": [ + 42 + ], + "colorMap": { + "red": "rojo" + }, + "whitespace": " ", + "emptyString": "", + "emptyArray": [ + ], + "emptyMap": { + } +} 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 2/7] 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 000000000000..f3c9181369d9 --- /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 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 000000000000..0064b58140db --- /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 000000000000..468c4ec50613 --- /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 000000000000..b48914ec5434 --- /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 000000000000..4ed5f604cace --- /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); + } + +} From e97ae434fb3cc47c6f23a70fb1f9661eb624d810 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Nicoll?= Date: Fri, 15 Mar 2024 13:22:15 +0100 Subject: [PATCH 3/7] Add AssertJ support for HTTP requests and responses This commit adds AssertJ compatible assertions for HTTP request and responses, including headers, media type, and response body. The latter delegate to the recently included JSON assert support. See gh-21178 --- .../test/http/HttpHeadersAssert.java | 129 ++++++++++++ .../test/http/MediaTypeAssert.java | 107 ++++++++++ .../test/http/package-info.java | 9 + .../springframework/test/web/UriAssert.java | 101 ++++++++++ .../AbstractHttpServletRequestAssert.java | 122 +++++++++++ .../AbstractHttpServletResponseAssert.java | 167 +++++++++++++++ .../AbstractMockHttpServletRequestAssert.java | 38 ++++ ...AbstractMockHttpServletResponseAssert.java | 109 ++++++++++ .../servlet/assertj/ResponseBodyAssert.java | 125 ++++++++++++ .../web/servlet/assertj/package-info.java | 9 + .../test/http/HttpHeadersAssertTests.java | 190 ++++++++++++++++++ .../test/http/MediaTypeAssertTests.java | 157 +++++++++++++++ .../test/web/UriAssertTests.java | 77 +++++++ ...AbstractHttpServletRequestAssertTests.java | 143 +++++++++++++ ...bstractHttpServletResponseAssertTests.java | 138 +++++++++++++ ...ractMockHttpServletRequestAssertTests.java | 48 +++++ ...actMockHttpServletResponseAssertTests.java | 106 ++++++++++ .../assertj/ResponseBodyAssertTests.java | 88 ++++++++ 18 files changed, 1863 insertions(+) create mode 100644 spring-test/src/main/java/org/springframework/test/http/HttpHeadersAssert.java create mode 100644 spring-test/src/main/java/org/springframework/test/http/MediaTypeAssert.java create mode 100644 spring-test/src/main/java/org/springframework/test/http/package-info.java create mode 100644 spring-test/src/main/java/org/springframework/test/web/UriAssert.java create mode 100644 spring-test/src/main/java/org/springframework/test/web/servlet/assertj/AbstractHttpServletRequestAssert.java create mode 100644 spring-test/src/main/java/org/springframework/test/web/servlet/assertj/AbstractHttpServletResponseAssert.java create mode 100644 spring-test/src/main/java/org/springframework/test/web/servlet/assertj/AbstractMockHttpServletRequestAssert.java create mode 100644 spring-test/src/main/java/org/springframework/test/web/servlet/assertj/AbstractMockHttpServletResponseAssert.java create mode 100644 spring-test/src/main/java/org/springframework/test/web/servlet/assertj/ResponseBodyAssert.java create mode 100644 spring-test/src/main/java/org/springframework/test/web/servlet/assertj/package-info.java create mode 100644 spring-test/src/test/java/org/springframework/test/http/HttpHeadersAssertTests.java create mode 100644 spring-test/src/test/java/org/springframework/test/http/MediaTypeAssertTests.java create mode 100644 spring-test/src/test/java/org/springframework/test/web/UriAssertTests.java create mode 100644 spring-test/src/test/java/org/springframework/test/web/servlet/assertj/AbstractHttpServletRequestAssertTests.java create mode 100644 spring-test/src/test/java/org/springframework/test/web/servlet/assertj/AbstractHttpServletResponseAssertTests.java create mode 100644 spring-test/src/test/java/org/springframework/test/web/servlet/assertj/AbstractMockHttpServletRequestAssertTests.java create mode 100644 spring-test/src/test/java/org/springframework/test/web/servlet/assertj/AbstractMockHttpServletResponseAssertTests.java create mode 100644 spring-test/src/test/java/org/springframework/test/web/servlet/assertj/ResponseBodyAssertTests.java diff --git a/spring-test/src/main/java/org/springframework/test/http/HttpHeadersAssert.java b/spring-test/src/main/java/org/springframework/test/http/HttpHeadersAssert.java new file mode 100644 index 000000000000..04f3ca41d591 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/http/HttpHeadersAssert.java @@ -0,0 +1,129 @@ +/* + * 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.http; + +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.temporal.ChronoUnit; +import java.util.List; + +import org.assertj.core.api.AbstractMapAssert; +import org.assertj.core.api.Assertions; + +import org.springframework.http.HttpHeaders; + +/** + * AssertJ {@link org.assertj.core.api.Assert assertions} that can be applied to + * {@link HttpHeaders}. + * + * @author Stephane Nicoll + * @since 6.2 + */ +public class HttpHeadersAssert extends AbstractMapAssert> { + + private static final ZoneId GMT = ZoneId.of("GMT"); + + + public HttpHeadersAssert(HttpHeaders actual) { + super(actual, HttpHeadersAssert.class); + as("HTTP headers"); + } + + /** + * Verify that the actual HTTP headers contain a header with the given + * {@code name}. + * @param name the name of an expected HTTP header + * @see #containsKey + */ + public HttpHeadersAssert containsHeader(String name) { + return containsKey(name); + } + + /** + * Verify that the actual HTTP headers contain the headers with the given + * {@code names}. + * @param names the names of expected HTTP headers + * @see #containsKeys + */ + public HttpHeadersAssert containsHeaders(String... names) { + return containsKeys(names); + } + + /** + * Verify that the actual HTTP headers do not contain a header with the + * given {@code name}. + * @param name the name of an HTTP header that should not be present + * @see #doesNotContainKey + */ + public HttpHeadersAssert doesNotContainsHeader(String name) { + return doesNotContainKey(name); + } + + /** + * Verify that the actual HTTP headers do not contain any of the headers + * with the given {@code names}. + * @param names the names of HTTP headers that should not be present + * @see #doesNotContainKeys + */ + public HttpHeadersAssert doesNotContainsHeaders(String... names) { + return doesNotContainKeys(names); + } + + /** + * Verify that the actual HTTP headers contain a header with the given + * {@code name} and {@link String} {@code value}. + * @param name the name of the cookie + * @param value the expected value of the header + */ + public HttpHeadersAssert hasValue(String name, String value) { + containsKey(name); + Assertions.assertThat(this.actual.getFirst(name)) + .as("check primary value for HTTP header '%s'", name) + .isEqualTo(value); + return this.myself; + } + + /** + * Verify that the actual HTTP headers contain a header with the given + * {@code name} and {@link Long} {@code value}. + * @param name the name of the cookie + * @param value the expected value of the header + */ + public HttpHeadersAssert hasValue(String name, long value) { + containsKey(name); + Assertions.assertThat(this.actual.getFirst(name)) + .as("check primary long value for HTTP header '%s'", name) + .asLong().isEqualTo(value); + return this.myself; + } + + /** + * Verify that the actual HTTP headers contain a header with the given + * {@code name} and {@link Instant} {@code value}. + * @param name the name of the cookie + * @param value the expected value of the header + */ + public HttpHeadersAssert hasValue(String name, Instant value) { + containsKey(name); + Assertions.assertThat(this.actual.getFirstZonedDateTime(name)) + .as("check primary date value for HTTP header '%s'", name) + .isCloseTo(ZonedDateTime.ofInstant(value, GMT), Assertions.within(999, ChronoUnit.MILLIS)); + return this.myself; + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/http/MediaTypeAssert.java b/spring-test/src/main/java/org/springframework/test/http/MediaTypeAssert.java new file mode 100644 index 000000000000..599a1ccb4408 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/http/MediaTypeAssert.java @@ -0,0 +1,107 @@ +/* + * 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.http; + +import org.assertj.core.api.AbstractObjectAssert; +import org.assertj.core.api.Assertions; +import org.assertj.core.error.BasicErrorMessageFactory; +import org.assertj.core.internal.Failures; + +import org.springframework.http.InvalidMediaTypeException; +import org.springframework.http.MediaType; +import org.springframework.lang.Nullable; +import org.springframework.util.StringUtils; + +/** + * AssertJ {@link org.assertj.core.api.Assert assertions} that can be applied + * to a {@link MediaType}. + * + * @author Brian Clozel + * @author Stephane Nicoll + * @since 6.2 + */ +public class MediaTypeAssert extends AbstractObjectAssert { + + public MediaTypeAssert(@Nullable MediaType mediaType) { + super(mediaType, MediaTypeAssert.class); + as("Media type"); + } + + public MediaTypeAssert(@Nullable String actual) { + this(StringUtils.hasText(actual) ? MediaType.parseMediaType(actual) : null); + } + + /** + * Verify that the actual media type is equal to the given string + * representation. + * @param expected the expected media type + */ + public MediaTypeAssert isEqualTo(String expected) { + return isEqualTo(parseMediaType(expected)); + } + + /** + * Verify that the actual media type is + * {@linkplain MediaType#isCompatibleWith(MediaType) compatible} with the + * given one. Example:

+	 * // Check that actual is compatible with "application/json"
+	 * assertThat(mediaType).isCompatibleWith(MediaType.APPLICATION_JSON);
+	 * 
+ * @param mediaType the media type with which to compare + */ + public MediaTypeAssert isCompatibleWith(MediaType mediaType) { + Assertions.assertThat(this.actual) + .withFailMessage("Expecting null to be compatible with '%s'", mediaType).isNotNull(); + Assertions.assertThat(mediaType) + .withFailMessage("Expecting '%s' to be compatible with null", this.actual).isNotNull(); + Assertions.assertThat(this.actual.isCompatibleWith(mediaType)) + .as("check media type '%s' is compatible with '%s'", this.actual.toString(), mediaType.toString()) + .isTrue(); + return this; + } + + /** + * Verify that the actual media type is + * {@linkplain MediaType#isCompatibleWith(MediaType) compatible} with the + * given one. Example:

+	 * // Check that actual is compatible with "text/plain"
+	 * assertThat(mediaType).isCompatibleWith("text/plain");
+	 * 
+ * @param mediaType the media type with which to compare + */ + public MediaTypeAssert isCompatibleWith(String mediaType) { + return isCompatibleWith(parseMediaType(mediaType)); + } + + + private MediaType parseMediaType(String value) { + try { + return MediaType.parseMediaType(value); + } + catch (InvalidMediaTypeException ex) { + throw Failures.instance().failure(this.info, new ShouldBeValidMediaType(value, ex.getMessage())); + } + } + + private static final class ShouldBeValidMediaType extends BasicErrorMessageFactory { + + private ShouldBeValidMediaType(String mediaType, String errorMessage) { + super("%nExpecting:%n %s%nTo be a valid media type but got:%n %s%n", mediaType, errorMessage); + } + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/http/package-info.java b/spring-test/src/main/java/org/springframework/test/http/package-info.java new file mode 100644 index 000000000000..6613b8a01284 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/http/package-info.java @@ -0,0 +1,9 @@ +/** + * Test support for HTTP concepts. + */ +@NonNullApi +@NonNullFields +package org.springframework.test.http; + +import org.springframework.lang.NonNullApi; +import org.springframework.lang.NonNullFields; diff --git a/spring-test/src/main/java/org/springframework/test/web/UriAssert.java b/spring-test/src/main/java/org/springframework/test/web/UriAssert.java new file mode 100644 index 000000000000..d916b7de59d7 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/UriAssert.java @@ -0,0 +1,101 @@ +/* + * 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.web; + +import org.assertj.core.api.AbstractStringAssert; +import org.assertj.core.api.Assertions; +import org.assertj.core.error.BasicErrorMessageFactory; +import org.assertj.core.internal.Failures; + +import org.springframework.lang.Nullable; +import org.springframework.util.AntPathMatcher; +import org.springframework.web.util.UriComponentsBuilder; + +/** + * AssertJ {@link org.assertj.core.api.Assert assertions} that can be applied + * to a {@link String} representing a URI. + * + * @author Stephane Nicoll + * @since 6.2 + */ +public class UriAssert extends AbstractStringAssert { + + private static final AntPathMatcher pathMatcher = new AntPathMatcher(); + + private final String displayName; + + public UriAssert(@Nullable String actual, String displayName) { + super(actual, UriAssert.class); + this.displayName = displayName; + as(displayName); + } + + /** + * Verify that the actual URI is equal to the URI built using the given + * {@code uriTemplate} and {@code uriVars}. + * Example:

+	 * // Verify that uri is equal to "/orders/1/items/2"
+	 * assertThat(uri).isEqualToTemplate("/orders/{orderId}/items/{itemId}", 1, 2));
+	 * 
+ * @param uriTemplate the expected URI string, with a number of URI + * template variables + * @param uriVars the values to replace the URI template variables + * @see UriComponentsBuilder#buildAndExpand(Object...) + */ + public UriAssert isEqualToTemplate(String uriTemplate, Object... uriVars) { + String uri = buildUri(uriTemplate, uriVars); + return isEqualTo(uri); + } + + /** + * Verify that the actual URI matches the given {@linkplain AntPathMatcher + * Ant-style} {@code uriPattern}. + * Example:

+	 * // Verify that pattern matches "/orders/1/items/2"
+	 * assertThat(uri).matchPattern("/orders/*"));
+	 * 
+ * @param uriPattern the pattern that is expected to match + */ + public UriAssert matchPattern(String uriPattern) { + Assertions.assertThat(pathMatcher.isPattern(uriPattern)) + .withFailMessage("'%s' is not an Ant-style path pattern", uriPattern).isTrue(); + Assertions.assertThat(pathMatcher.match(uriPattern, this.actual)) + .withFailMessage("%s '%s' does not match the expected URI pattern '%s'", + this.displayName, this.actual, uriPattern).isTrue(); + return this; + } + + private String buildUri(String uriTemplate, Object... uriVars) { + try { + return UriComponentsBuilder.fromUriString(uriTemplate) + .buildAndExpand(uriVars).encode().toUriString(); + } + catch (Exception ex) { + throw Failures.instance().failure(this.info, + new ShouldBeValidUriTemplate(uriTemplate, ex.getMessage())); + } + } + + + private static final class ShouldBeValidUriTemplate extends BasicErrorMessageFactory { + + private ShouldBeValidUriTemplate(String uriTemplate, String errorMessage) { + super("%nExpecting:%n %s%nTo be a valid URI template but got:%n %s%n", uriTemplate, errorMessage); + } + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/AbstractHttpServletRequestAssert.java b/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/AbstractHttpServletRequestAssert.java new file mode 100644 index 000000000000..0934e5d13b94 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/AbstractHttpServletRequestAssert.java @@ -0,0 +1,122 @@ +/* + * 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.web.servlet.assertj; + +import java.util.Enumeration; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.Callable; +import java.util.function.Function; +import java.util.function.Supplier; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; +import org.assertj.core.api.AbstractObjectAssert; +import org.assertj.core.api.Assertions; +import org.assertj.core.api.MapAssert; + +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.util.function.SingletonSupplier; +import org.springframework.web.context.request.async.DeferredResult; + +/** + * Base AssertJ {@link org.assertj.core.api.Assert assertions} that can be + * applied to a {@link HttpServletRequest}. + * + * @author Stephane Nicoll + * @since 6.2 + * @param the type of assertions + * @param the type of the object to assert + */ +public abstract class AbstractHttpServletRequestAssert, ACTUAL extends HttpServletRequest> + extends AbstractObjectAssert { + + private final Supplier> attributesAssertProvider; + + private final Supplier> sessionAttributesAssertProvider; + + protected AbstractHttpServletRequestAssert(ACTUAL actual, Class selfType) { + super(actual, selfType); + this.attributesAssertProvider = SingletonSupplier.of(() -> createAttributesAssert(actual)); + this.sessionAttributesAssertProvider = SingletonSupplier.of(() -> createSessionAttributesAssert(actual)); + } + + private static MapAssert createAttributesAssert(HttpServletRequest request) { + Map map = toMap(request.getAttributeNames(), request::getAttribute); + return Assertions.assertThat(map).as("Request Attributes"); + } + + private static MapAssert createSessionAttributesAssert(HttpServletRequest request) { + HttpSession session = request.getSession(); + Assertions.assertThat(session).as("HTTP session").isNotNull(); + Map map = toMap(session.getAttributeNames(), session::getAttribute); + return Assertions.assertThat(map).as("Session Attributes"); + } + + /** + * Return a new {@linkplain MapAssert assertion} object that uses the request + * attributes as the object to test, with values mapped by attribute name. + * Examples:

+	 * // Check for the presence of a request attribute named "attributeName":
+	 * assertThat(request).attributes().containsKey("attributeName");
+	 * 
+ */ + public MapAssert attributes() { + return this.attributesAssertProvider.get(); + } + + /** + * Return a new {@linkplain MapAssert assertion} object that uses the session + * attributes as the object to test, with values mapped by attribute name. + * Examples:

+	 * // Check for the presence of a session attribute named "username":
+	 * assertThat(request).sessionAttributes().containsKey("username");
+	 * 
+ */ + public MapAssert sessionAttributes() { + return this.sessionAttributesAssertProvider.get(); + } + + /** + * Verify that whether asynchronous processing started, usually as a result + * of a controller method returning {@link Callable} or {@link DeferredResult}. + *

The test will await the completion of a {@code Callable} so that + * {@link MvcResultAssert#asyncResult()} can be used to assert the resulting + * value. + *

Neither a {@code Callable} nor a {@code DeferredResult} will complete + * processing all the way since a {@link MockHttpServletRequest} does not + * perform asynchronous dispatches. + * @param started whether asynchronous processing should have started + */ + public SELF hasAsyncStarted(boolean started) { + Assertions.assertThat(this.actual.isAsyncStarted()) + .withFailMessage("Async expected to %s started", (started ? "have" : "not have")) + .isEqualTo(started); + return this.myself; + } + + + private static Map toMap(Enumeration keys, Function valueProvider) { + Map map = new LinkedHashMap<>(); + while (keys.hasMoreElements()) { + String key = keys.nextElement(); + map.put(key, valueProvider.apply(key)); + } + return map; + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/AbstractHttpServletResponseAssert.java b/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/AbstractHttpServletResponseAssert.java new file mode 100644 index 000000000000..f5388116ccd8 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/AbstractHttpServletResponseAssert.java @@ -0,0 +1,167 @@ +/* + * 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.web.servlet.assertj; + +import java.util.ArrayList; +import java.util.function.Supplier; + +import jakarta.servlet.http.HttpServletResponse; +import org.assertj.core.api.AbstractIntegerAssert; +import org.assertj.core.api.AbstractMapAssert; +import org.assertj.core.api.AbstractObjectAssert; +import org.assertj.core.api.Assertions; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatus.Series; +import org.springframework.test.http.HttpHeadersAssert; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.util.function.SingletonSupplier; + +/** + * Base AssertJ {@link org.assertj.core.api.Assert assertions} that can be + * applied to any object that provides an {@link HttpServletResponse}. This + * allows to provide direct access to response assertions while providing + * access to a different top-level object. + * + * @author Stephane Nicoll + * @since 6.2 + * @param the type of {@link HttpServletResponse} + * @param the type of assertions + * @param the type of the object to assert + */ +public abstract class AbstractHttpServletResponseAssert, ACTUAL> + extends AbstractObjectAssert { + + private final Supplier> statusAssert; + + private final Supplier headersAssertSupplier; + + + protected AbstractHttpServletResponseAssert(ACTUAL actual, Class selfType) { + super(actual, selfType); + this.statusAssert = SingletonSupplier.of(() -> Assertions.assertThat(getResponse().getStatus()).as("HTTP status code")); + this.headersAssertSupplier = SingletonSupplier.of(() -> new HttpHeadersAssert(getHttpHeaders(getResponse()))); + } + + /** + * Provide the response to use if it is available. Throw an + * {@link AssertionError} if the request has failed to process and the + * response is not available. + * @return the response to use + */ + protected abstract R getResponse(); + + /** + * Return a new {@linkplain HttpHeadersAssert assertion} object that uses + * the {@link HttpHeaders} as the object to test. The return assertion + * object provides all the regular {@linkplain AbstractMapAssert map + * assertions}, with headers mapped by header name. + * Examples:


+	 * // Check for the presence of the Accept header:
+	 * assertThat(response).headers().containsHeader(HttpHeaders.ACCEPT);
+	 * // Check for the absence of the Content-Length header:
+	 * assertThat(response).headers().doesNotContainsHeader(HttpHeaders.CONTENT_LENGTH);
+	 * 
+ */ + public HttpHeadersAssert headers() { + return this.headersAssertSupplier.get(); + } + + /** + * Verify that the HTTP status is equal to the specified status code. + * @param status the expected HTTP status code + */ + public SELF hasStatus(int status) { + status().isEqualTo(status); + return this.myself; + } + + /** + * Verify that the HTTP status is equal to the specified + * {@linkplain HttpStatus status}. + * @param status the expected HTTP status code + */ + public SELF hasStatus(HttpStatus status) { + return hasStatus(status.value()); + } + + /** + * Verify that the HTTP status is equal to {@link HttpStatus#OK}. + * @see #hasStatus(HttpStatus) + */ + public SELF hasStatusOk() { + return hasStatus(HttpStatus.OK); + } + + /** + * Verify that the HTTP status code is in the 1xx range. + * @see RFC 2616 + */ + public SELF hasStatus1xxInformational() { + return hasStatusSeries(Series.INFORMATIONAL); + } + + /** + * Verify that the HTTP status code is in the 2xx range. + * @see RFC 2616 + */ + public SELF hasStatus2xxSuccessful() { + return hasStatusSeries(Series.SUCCESSFUL); + } + + /** + * Verify that the HTTP status code is in the 3xx range. + * @see RFC 2616 + */ + public SELF hasStatus3xxRedirection() { + return hasStatusSeries(Series.REDIRECTION); + } + + /** + * Verify that the HTTP status code is in the 4xx range. + * @see RFC 2616 + */ + public SELF hasStatus4xxClientError() { + return hasStatusSeries(Series.CLIENT_ERROR); + } + + /** + * Verify that the HTTP status code is in the 5xx range. + * @see RFC 2616 + */ + public SELF hasStatus5xxServerError() { + return hasStatusSeries(Series.SERVER_ERROR); + } + + private SELF hasStatusSeries(Series series) { + Assertions.assertThat(Series.resolve(getResponse().getStatus())).as("HTTP status series").isEqualTo(series); + return this.myself; + } + + private AbstractIntegerAssert status() { + return this.statusAssert.get(); + } + + private static HttpHeaders getHttpHeaders(HttpServletResponse response) { + MultiValueMap headers = new LinkedMultiValueMap<>(); + response.getHeaderNames().forEach(name -> headers.put(name, new ArrayList<>(response.getHeaders(name)))); + return new HttpHeaders(headers); + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/AbstractMockHttpServletRequestAssert.java b/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/AbstractMockHttpServletRequestAssert.java new file mode 100644 index 000000000000..db549a437682 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/AbstractMockHttpServletRequestAssert.java @@ -0,0 +1,38 @@ +/* + * 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.web.servlet.assertj; + +import org.springframework.mock.web.MockHttpServletRequest; + +/** + * AssertJ {@link org.assertj.core.api.Assert assertions} that can be applied to + * {@link MockHttpServletRequest}. + * + * @author Stephane Nicoll + * @since 6.2 + * @param the type of assertions + */ +public abstract class AbstractMockHttpServletRequestAssert> + extends AbstractHttpServletRequestAssert { + + protected AbstractMockHttpServletRequestAssert(MockHttpServletRequest request, Class selfType) { + super(request, selfType); + } + + + +} diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/AbstractMockHttpServletResponseAssert.java b/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/AbstractMockHttpServletResponseAssert.java new file mode 100644 index 000000000000..2df9de488ff8 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/AbstractMockHttpServletResponseAssert.java @@ -0,0 +1,109 @@ +/* + * 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.web.servlet.assertj; + +import java.nio.charset.Charset; + +import org.springframework.http.converter.GenericHttpMessageConverter; +import org.springframework.lang.Nullable; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.test.web.UriAssert; + +/** + * Extension of {@link AbstractHttpServletResponseAssert} for + * {@link MockHttpServletResponse}. + * + * @author Stephane Nicoll + * @since 6.2 + * @param the type of assertions + * @param the type of the object to assert + */ +public abstract class AbstractMockHttpServletResponseAssert, ACTUAL> + extends AbstractHttpServletResponseAssert { + + @Nullable + private final GenericHttpMessageConverter jsonMessageConverter; + + protected AbstractMockHttpServletResponseAssert( + @Nullable GenericHttpMessageConverter jsonMessageConverter, ACTUAL actual, Class selfType) { + + super(actual, selfType); + this.jsonMessageConverter = jsonMessageConverter; + } + + /** + * Return a new {@linkplain ResponseBodyAssert assertion} object that uses + * the response body as the object to test. The return assertion object + * provides access to the raw byte array, a String value decoded using the + * response's character encoding, and dedicated json testing support. + * Examples:

+	 * // Check that the response body is equal to "Hello World":
+	 * assertThat(response).body().isEqualTo("Hello World");
+	 * // Check that the response body is strictly equal to the content of "test.json":
+	 * assertThat(response).body().json().isStrictlyEqualToJson("test.json");
+	 * 
+ */ + public ResponseBodyAssert body() { + return new ResponseBodyAssert(getResponse().getContentAsByteArray(), + Charset.forName(getResponse().getCharacterEncoding()), this.jsonMessageConverter); + } + + /** + * Return a new {@linkplain UriAssert assertion} object that uses the + * forwarded URL as the object to test. If a simple equality check is + * required consider using {@link #hasForwardedUrl(String)} instead. + * Example:

+	 * // Check that the forwarded URL starts with "/orders/":
+	 * assertThat(response).forwardedUrl().matchPattern("/orders/*);
+	 * 
+ */ + public UriAssert forwardedUrl() { + return new UriAssert(getResponse().getForwardedUrl(), "Forwarded URL"); + } + + /** + * Return a new {@linkplain UriAssert assertion} object that uses the + * redirected URL as the object to test. If a simple equality check is + * required consider using {@link #hasRedirectedUrl(String)} instead. + * Example:

+	 * // Check that the redirected URL starts with "/orders/":
+	 * assertThat(response).redirectedUrl().matchPattern("/orders/*);
+	 * 
+ */ + public UriAssert redirectedUrl() { + return new UriAssert(getResponse().getRedirectedUrl(), "Redirected URL"); + } + + /** + * Verify that the forwarded URL is equal to the given value. + * @param forwardedUrl the expected forwarded URL (can be null) + */ + public SELF hasForwardedUrl(@Nullable String forwardedUrl) { + forwardedUrl().isEqualTo(forwardedUrl); + return this.myself; + } + + /** + * Verify that the redirected URL is equal to the given value. + * @param redirectedUrl the expected redirected URL (can be null) + */ + public SELF hasRedirectedUrl(@Nullable String redirectedUrl) { + redirectedUrl().isEqualTo(redirectedUrl); + return this.myself; + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/ResponseBodyAssert.java b/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/ResponseBodyAssert.java new file mode 100644 index 000000000000..3edad9b2627d --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/ResponseBodyAssert.java @@ -0,0 +1,125 @@ +/* + * 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.web.servlet.assertj; + +import java.nio.charset.Charset; + +import jakarta.servlet.http.HttpServletResponse; +import org.assertj.core.api.AbstractByteArrayAssert; +import org.assertj.core.api.AbstractStringAssert; + +import org.springframework.core.io.ClassPathResource; +import org.springframework.http.converter.GenericHttpMessageConverter; +import org.springframework.lang.Nullable; +import org.springframework.test.json.JsonContentAssert; +import org.springframework.test.json.JsonPathAssert; + +/** + * AssertJ {@link org.assertj.core.api.Assert assertions} that can be applied to + * the response body. + * + * @author Stephane Nicoll + * @author Brian Clozel + * @since 6.2 + */ +public class ResponseBodyAssert extends AbstractByteArrayAssert { + + private final Charset characterEncoding; + + @Nullable + private final GenericHttpMessageConverter jsonMessageConverter; + + ResponseBodyAssert(byte[] actual, Charset characterEncoding, + @Nullable GenericHttpMessageConverter jsonMessageConverter) { + + super(actual, ResponseBodyAssert.class); + this.characterEncoding = characterEncoding; + this.jsonMessageConverter = jsonMessageConverter; + as("Response body"); + } + + /** + * Return a new {@linkplain JsonPathAssert assertion} object that provides + * {@linkplain com.jayway.jsonpath.JsonPath JSON path} assertions on the + * response body. + */ + public JsonPathAssert jsonPath() { + return new JsonPathAssert(getJson(), this.jsonMessageConverter); + } + + /** + * Return a new {@linkplain JsonContentAssert assertion} object that + * provides {@linkplain org.skyscreamer.jsonassert.JSONCompareMode JSON + * assert} comparison to expected json input that can be loaded from the + * classpath. Only absolute locations are supported, consider using + * {@link #json(Class)} to load json documents relative to a given class. + * Example:

+	 * // Check that the response is strictly equal to the content of
+	 * // "/com/acme/web/person/person-created.json":
+	 * assertThat(...).body().json()
+	 *         .isStrictlyEqualToJson("/com/acme/web/person/person-created.json");
+	 * 
+ */ + public JsonContentAssert json() { + return json(null); + } + + /** + * Return a new {@linkplain JsonContentAssert assertion} object that + * provides {@linkplain org.skyscreamer.jsonassert.JSONCompareMode JSON + * assert} comparison to expected json input that can be loaded from the + * classpath. Documents can be absolute using a leading slash, or relative + * to the given {@code resourceLoadClass}. + * Example:

+	 * // Check that the response is strictly equal to the content of
+	 * // the specified file:
+	 * assertThat(...).body().json(PersonController.class)
+	 *         .isStrictlyEqualToJson("person-created.json");
+	 * 
+ * @param resourceLoadClass the class used to load relative json documents + * @see ClassPathResource#ClassPathResource(String, Class) + */ + public JsonContentAssert json(@Nullable Class resourceLoadClass) { + return new JsonContentAssert(getJson(), resourceLoadClass, this.characterEncoding); + } + + /** + * Verifies that the response body is equal to the given {@link String}. + *

Convert the actual byte array to a String using the character encoding + * of the {@link HttpServletResponse}. + * @param expected the expected content of the response body + * @see #asString() + */ + public ResponseBodyAssert isEqualTo(String expected) { + asString().isEqualTo(expected); + return this; + } + + /** + * Override that uses the character encoding of {@link HttpServletResponse} to + * convert the byte[] to a String, rather than the platform's default charset. + */ + @Override + public AbstractStringAssert asString() { + return asString(this.characterEncoding); + } + + private String getJson() { + return new String(this.actual, this.characterEncoding); + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/package-info.java b/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/package-info.java new file mode 100644 index 000000000000..6fe626a51659 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/package-info.java @@ -0,0 +1,9 @@ +/** + * AssertJ support for MockMvc. + */ +@NonNullApi +@NonNullFields +package org.springframework.test.web.servlet.assertj; + +import org.springframework.lang.NonNullApi; +import org.springframework.lang.NonNullFields; diff --git a/spring-test/src/test/java/org/springframework/test/http/HttpHeadersAssertTests.java b/spring-test/src/test/java/org/springframework/test/http/HttpHeadersAssertTests.java new file mode 100644 index 000000000000..c82bb119a550 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/http/HttpHeadersAssertTests.java @@ -0,0 +1,190 @@ +/* + * 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.http; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; + +import org.springframework.http.HttpHeaders; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +/** + * Tests for {@link HttpHeadersAssert}. + * + * @author Stephane Nicoll + */ +class HttpHeadersAssertTests { + + @Test + void containsHeader() { + assertThat(Map.of("first", "1")).containsHeader("first"); + } + + @Test + void containsHeaderWithNameNotPresent() { + Map map = Map.of("first", "1"); + Assertions.assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(map).containsHeader("wrong-name")) + .withMessageContainingAll("HTTP headers", "first", "wrong-name"); + } + + @Test + void containsHeaders() { + assertThat(Map.of("first", "1", "second", "2", "third", "3")) + .containsHeaders("first", "third"); + } + + @Test + void containsHeadersWithSeveralNamesNotPresent() { + Map map = Map.of("first", "1", "second", "2", "third", "3"); + Assertions.assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(map).containsHeaders("first", "wrong-name", "another-wrong-name", "third")) + .withMessageContainingAll("HTTP headers", "first", "wrong-name", "another-wrong-name"); + } + + @Test + void doesNotContainsHeader() { + assertThat(Map.of("first", "1")).doesNotContainsHeader("second"); + } + + @Test + void doesNotContainsHeaderWithNamePresent() { + Map map = Map.of("first", "1"); + Assertions.assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(map).doesNotContainKey("first")) + .withMessageContainingAll("HTTP headers", "first"); + } + + @Test + void doesNotContainsHeaders() { + assertThat(Map.of("first", "1", "third", "3")) + .doesNotContainsHeaders("second", "fourth"); + } + + @Test + void doesNotContainsHeadersWithSeveralNamesPresent() { + Map map = Map.of("first", "1", "second", "2", "third", "3"); + Assertions.assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(map).doesNotContainsHeaders("first", "another-wrong-name", "second")) + .withMessageContainingAll("HTTP headers", "first", "second"); + } + + + @Test + void hasValueWithStringMatch() { + HttpHeaders headers = new HttpHeaders(); + headers.addAll("header", List.of("a", "b", "c")); + assertThat(headers).hasValue("header", "a"); + } + + @Test + void hasValueWithStringMatchOnSecondaryValue() { + HttpHeaders headers = new HttpHeaders(); + headers.addAll("header", List.of("first", "second", "third")); + Assertions.assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(headers).hasValue("header", "second")) + .withMessageContainingAll("check primary value for HTTP header 'header'", "first", "second"); + } + + @Test + void hasValueWithNoStringMatch() { + HttpHeaders headers = new HttpHeaders(); + headers.addAll("header", List.of("first", "second", "third")); + Assertions.assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(headers).hasValue("wrong-name", "second")) + .withMessageContainingAll("HTTP headers", "header", "wrong-name"); + } + + @Test + void hasValueWithNonPresentHeader() { + HttpHeaders map = new HttpHeaders(); + map.add("test-header", "a"); + Assertions.assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(map).hasValue("wrong-name", "a")) + .withMessageContainingAll("HTTP headers", "test-header", "wrong-name"); + } + + @Test + void hasValueWithLongMatch() { + HttpHeaders headers = new HttpHeaders(); + headers.addAll("header", List.of("123", "456", "789")); + assertThat(headers).hasValue("header", 123); + } + + @Test + void hasValueWithLongMatchOnSecondaryValue() { + HttpHeaders map = new HttpHeaders(); + map.addAll("header", List.of("123", "456", "789")); + Assertions.assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(map).hasValue("header", 456)) + .withMessageContainingAll("check primary long value for HTTP header 'header'", "123", "456"); + } + + @Test + void hasValueWithNoLongMatch() { + HttpHeaders map = new HttpHeaders(); + map.addAll("header", List.of("123", "456", "789")); + Assertions.assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(map).hasValue("wrong-name", 456)) + .withMessageContainingAll("HTTP headers", "header", "wrong-name"); + } + + @Test + void hasValueWithInstantMatch() { + Instant instant = Instant.now(); + HttpHeaders headers = new HttpHeaders(); + headers.setInstant("header", instant); + assertThat(headers).hasValue("header", instant); + } + + @Test + void hasValueWithNoInstantMatch() { + Instant instant = Instant.now(); + HttpHeaders map = new HttpHeaders(); + map.setInstant("header", instant); + Assertions.assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(map).hasValue("wrong-name", instant.minusSeconds(30))) + .withMessageContainingAll("HTTP headers", "header", "wrong-name"); + } + + @Test + void hasValueWithNoInstantMatchOneSecOfDifference() { + Instant instant = Instant.now(); + HttpHeaders map = new HttpHeaders(); + map.setInstant("header", instant); + Assertions.assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(map).hasValue("wrong-name", instant.minusSeconds(1))) + .withMessageContainingAll("HTTP headers", "header", "wrong-name"); + } + + + private static HttpHeadersAssert assertThat(Map values) { + MultiValueMap map = new LinkedMultiValueMap<>(); + values.forEach(map::add); + return assertThat(new HttpHeaders(map)); + } + + private static HttpHeadersAssert assertThat(HttpHeaders values) { + return new HttpHeadersAssert(values); + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/http/MediaTypeAssertTests.java b/spring-test/src/test/java/org/springframework/test/http/MediaTypeAssertTests.java new file mode 100644 index 000000000000..232d25400f86 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/http/MediaTypeAssertTests.java @@ -0,0 +1,157 @@ +/* + * 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.http; + + +import org.junit.jupiter.api.Test; + +import org.springframework.http.MediaType; +import org.springframework.lang.Nullable; + +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +/** + * Tests for {@link MediaTypeAssert}. + * + * @author Brian Clozel + * @author Stephane Nicoll + */ +class MediaTypeAssertTests { + + @Test + void actualCanBeNull() { + new MediaTypeAssert((MediaType) null).isNull(); + } + + @Test + void actualStringCanBeNull() { + new MediaTypeAssert((String) null).isNull(); + } + + @Test + void isEqualWhenSameShouldPass() { + assertThat(mediaType("application/json")).isEqualTo("application/json"); + } + + @Test + void isEqualWhenDifferentShouldFail() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(mediaType("application/json")).isEqualTo("text/html")) + .withMessageContaining("Media type"); + } + + @Test + void isEqualWhenActualIsNullShouldFail() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(null).isEqualTo(MediaType.APPLICATION_JSON)) + .withMessageContaining("Media type"); + } + + @Test + void isEqualWhenSameTypeShouldPass() { + assertThat(mediaType("application/json")).isEqualTo(MediaType.APPLICATION_JSON); + } + + @Test + void isEqualWhenDifferentTypeShouldFail() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(mediaType("application/json")).isEqualTo(MediaType.TEXT_HTML)) + .withMessageContaining("Media type"); + } + + @Test + void isCompatibleWhenSameShouldPass() { + assertThat(mediaType("application/json")).isCompatibleWith("application/json"); + } + + @Test + void isCompatibleWhenCompatibleShouldPass() { + assertThat(mediaType("application/json")).isCompatibleWith("application/*"); + } + + @Test + void isCompatibleWhenDifferentShouldFail() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(mediaType("application/json")).isCompatibleWith("text/html")) + .withMessageContaining("check media type 'application/json' is compatible with 'text/html'"); + } + + @Test + void isCompatibleWithStringAndNullActual() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(null).isCompatibleWith("text/html")) + .withMessageContaining("Expecting null to be compatible with 'text/html'"); + } + + @Test + void isCompatibleWithStringAndNullExpected() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(mediaType("application/json")).isCompatibleWith((String) null)) + .withMessageContainingAll("Expecting:", "null", "To be a valid media type but got:", + "'mimeType' must not be empty"); + } + + @Test + void isCompatibleWithStringAndEmptyExpected() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(mediaType("application/json")).isCompatibleWith("")) + .withMessageContainingAll("Expecting:", "", "To be a valid media type but got:", + "'mimeType' must not be empty"); + } + + @Test + void isCompatibleWithMediaTypeAndNullActual() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(null).isCompatibleWith(MediaType.TEXT_HTML)) + .withMessageContaining("Expecting null to be compatible with 'text/html'"); + } + + @Test + void isCompatibleWithMediaTypeAndNullExpected() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(mediaType("application/json")).isCompatibleWith((MediaType) null)) + .withMessageContaining("Expecting 'application/json' to be compatible with null"); + } + + @Test + void isCompatibleWhenSameTypeShouldPass() { + assertThat(mediaType("application/json")).isCompatibleWith(MediaType.APPLICATION_JSON); + } + + @Test + void isCompatibleWhenCompatibleTypeShouldPass() { + assertThat(mediaType("application/json")).isCompatibleWith(MediaType.parseMediaType("application/*")); + } + + @Test + void isCompatibleWhenDifferentTypeShouldFail() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(mediaType("application/json")).isCompatibleWith(MediaType.TEXT_HTML)) + .withMessageContaining("check media type 'application/json' is compatible with 'text/html'"); + } + + + @Nullable + private static MediaType mediaType(@Nullable String mediaType) { + return (mediaType != null ? MediaType.parseMediaType(mediaType) : null); + } + + private static MediaTypeAssert assertThat(@Nullable MediaType mediaType) { + return new MediaTypeAssert(mediaType); + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/web/UriAssertTests.java b/spring-test/src/test/java/org/springframework/test/web/UriAssertTests.java new file mode 100644 index 000000000000..f5eacdd1cfd7 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/UriAssertTests.java @@ -0,0 +1,77 @@ +/* + * 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.web; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +/** + * Tests for {@link UriAssert}. + * + * @author Stephane Nicoll + */ +class UriAssertTests { + + @Test + void isEqualToTemplate() { + assertThat("/orders/1/items/2").isEqualToTemplate("/orders/{orderId}/items/{itemId}", 1, 2); + } + + @Test + void isEqualToTemplateWithWrongValue() { + String expected = "/orders/1/items/3"; + String actual = "/orders/1/items/2"; + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(expected).isEqualToTemplate("/orders/{orderId}/items/{itemId}", 1, 2)) + .withMessageContainingAll("Test URI", expected, actual); + } + + @Test + void isEqualToTemplateMissingArg() { + String template = "/orders/{orderId}/items/{itemId}"; + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat("/orders/1/items/2").isEqualToTemplate(template, 1)) + .withMessageContainingAll("Expecting:", template, + "Not enough variable values available to expand 'itemId'"); + } + + @Test + void matchPattern() { + assertThat("/orders/1").matchPattern("/orders/*"); + } + + @Test + void matchPatternWithNonValidPattern() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat("/orders/1").matchPattern("/orders/")) + .withMessage("'/orders/' is not an Ant-style path pattern"); + } + + @Test + void matchPatternWithWrongValue() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat("/orders/1").matchPattern("/resources/*")) + .withMessageContainingAll("Test URI", "/resources/*", "/orders/1"); + } + + + UriAssert assertThat(String uri) { + return new UriAssert(uri, "Test URI"); + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/AbstractHttpServletRequestAssertTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/AbstractHttpServletRequestAssertTests.java new file mode 100644 index 000000000000..01c6a06fb7d2 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/AbstractHttpServletRequestAssertTests.java @@ -0,0 +1,143 @@ +/* + * 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.web.servlet.assertj; + +import java.util.LinkedHashMap; +import java.util.Map; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import org.springframework.mock.web.MockHttpServletRequest; + +import static java.util.Map.entry; + +/** + * Tests for {@link AbstractHttpServletRequestAssert}. + * + * @author Stephane Nicoll + */ +public class AbstractHttpServletRequestAssertTests { + + + @Nested + class AttributesTests { + + @Test + void attributesAreCopied() { + Map map = new LinkedHashMap<>(); + map.put("one", 1); + map.put("two", 2); + assertThat(createRequest(map)).attributes() + .containsExactly(entry("one", 1), entry("two", 2)); + } + + @Test + void attributesWithWrongKey() { + HttpServletRequest request = createRequest(Map.of("one", 1)); + Assertions.assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(request).attributes().containsKey("two")) + .withMessageContainingAll("Request Attributes", "two", "one"); + } + + private HttpServletRequest createRequest(Map attributes) { + MockHttpServletRequest request = new MockHttpServletRequest(); + attributes.forEach(request::setAttribute); + return request; + } + + } + + @Nested + class SessionAttributesTests { + + @Test + void sessionAttributesAreCopied() { + Map map = new LinkedHashMap<>(); + map.put("one", 1); + map.put("two", 2); + assertThat(createRequest(map)).sessionAttributes() + .containsExactly(entry("one", 1), entry("two", 2)); + } + + @Test + void sessionAttributesWithWrongKey() { + HttpServletRequest request = createRequest(Map.of("one", 1)); + Assertions.assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(request).sessionAttributes().containsKey("two")) + .withMessageContainingAll("Session Attributes", "two", "one"); + } + + + private HttpServletRequest createRequest(Map attributes) { + MockHttpServletRequest request = new MockHttpServletRequest(); + HttpSession session = request.getSession(); + Assertions.assertThat(session).isNotNull(); + attributes.forEach(session::setAttribute); + return request; + } + + } + + @Test + void hasAsyncStartedTrue() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setAsyncStarted(true); + assertThat(request).hasAsyncStarted(true); + } + + @Test + void hasAsyncStartedTrueWithFalse() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setAsyncStarted(false); + Assertions.assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(request).hasAsyncStarted(true)) + .withMessage("Async expected to have started"); + } + + @Test + void hasAsyncStartedFalse() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setAsyncStarted(false); + assertThat(request).hasAsyncStarted(false); + } + + @Test + void hasAsyncStartedFalseWithTrue() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setAsyncStarted(true); + Assertions.assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(request).hasAsyncStarted(false)) + .withMessage("Async expected to not have started"); + + } + + private static ResponseAssert assertThat(HttpServletRequest response) { + return new ResponseAssert(response); + } + + + private static final class ResponseAssert extends AbstractHttpServletRequestAssert { + + ResponseAssert(HttpServletRequest actual) { + super(actual, ResponseAssert.class); + } + } +} diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/AbstractHttpServletResponseAssertTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/AbstractHttpServletResponseAssertTests.java new file mode 100644 index 000000000000..3c8aee938c07 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/AbstractHttpServletResponseAssertTests.java @@ -0,0 +1,138 @@ +/* + * 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.web.servlet.assertj; + +import java.util.Map; + +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import org.springframework.http.HttpStatus; +import org.springframework.mock.web.MockHttpServletResponse; + +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +/** + * Tests for {@link AbstractHttpServletResponseAssert}. + * + * @author Stephane Nicoll + */ +class AbstractHttpServletResponseAssertTests { + + @Nested + class HeadersTests { + + @Test + void headersAreMatching() { + MockHttpServletResponse response = createResponse(Map.of("n1", "v1", "n2", "v2", "n3", "v3")); + assertThat(response).headers().containsHeaders("n1", "n2", "n3"); + } + + + private MockHttpServletResponse createResponse(Map headers) { + MockHttpServletResponse response = new MockHttpServletResponse(); + headers.forEach(response::addHeader); + return response; + } + } + + + @Nested + class StatusTests { + + @Test + void hasStatusWithCode() { + assertThat(createResponse(200)).hasStatus(200); + } + + @Test + void hasStatusWithHttpStatus() { + assertThat(createResponse(200)).hasStatus(HttpStatus.OK); + } + + @Test + void hasStatusOK() { + assertThat(createResponse(200)).hasStatusOk(); + } + + @Test + void hasStatusWithWrongCode() { + MockHttpServletResponse response = createResponse(200); + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> assertThat(response).hasStatus(300)) + .withMessageContainingAll("HTTP status code", "200", "300"); + } + + @Test + void hasStatus1xxInformational() { + assertThat(createResponse(199)).hasStatus1xxInformational(); + } + + @Test + void hasStatus2xxSuccessful() { + assertThat(createResponse(299)).hasStatus2xxSuccessful(); + } + + @Test + void hasStatus3xxRedirection() { + assertThat(createResponse(399)).hasStatus3xxRedirection(); + } + + @Test + void hasStatus4xxClientError() { + assertThat(createResponse(499)).hasStatus4xxClientError(); + } + + @Test + void hasStatus5xxServerError() { + assertThat(createResponse(599)).hasStatus5xxServerError(); + } + + @Test + void hasStatusWithWrongSeries() { + MockHttpServletResponse response = createResponse(500); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(response).hasStatus2xxSuccessful()) + .withMessageContainingAll("HTTP status series", "SUCCESSFUL", "SERVER_ERROR"); + } + + private MockHttpServletResponse createResponse(int status) { + MockHttpServletResponse response = new MockHttpServletResponse(); + response.setStatus(status); + return response; + } + } + + private static ResponseAssert assertThat(HttpServletResponse response) { + return new ResponseAssert(response); + } + + + private static final class ResponseAssert extends AbstractHttpServletResponseAssert { + + ResponseAssert(HttpServletResponse actual) { + super(actual, ResponseAssert.class); + } + + @Override + protected HttpServletResponse getResponse() { + return this.actual; + } + + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/AbstractMockHttpServletRequestAssertTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/AbstractMockHttpServletRequestAssertTests.java new file mode 100644 index 000000000000..d1c50876601c --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/AbstractMockHttpServletRequestAssertTests.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.web.servlet.assertj; + +import org.junit.jupiter.api.Test; + +import org.springframework.mock.web.MockHttpServletRequest; + +/** + * Tests for {@link AbstractMockHttpServletRequestAssert}. + * + * @author Stephane Nicoll + */ +class AbstractMockHttpServletRequestAssertTests { + + @Test + void requestCanBeAsserted() { + MockHttpServletRequest request = new MockHttpServletRequest(); + assertThat(request).satisfies(actual -> assertThat(actual).isSameAs(request)); + } + + + private static RequestAssert assertThat(MockHttpServletRequest request) { + return new RequestAssert(request); + } + + private static final class RequestAssert extends AbstractMockHttpServletRequestAssert { + + RequestAssert(MockHttpServletRequest actual) { + super(actual, RequestAssert.class); + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/AbstractMockHttpServletResponseAssertTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/AbstractMockHttpServletResponseAssertTests.java new file mode 100644 index 000000000000..badfb6d4f697 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/AbstractMockHttpServletResponseAssertTests.java @@ -0,0 +1,106 @@ +/* + * 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.web.servlet.assertj; + + +import java.io.UnsupportedEncodingException; +import java.nio.charset.StandardCharsets; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; + +import org.springframework.http.HttpHeaders; +import org.springframework.mock.web.MockHttpServletResponse; + +/** + * Tests for {@link AbstractMockHttpServletResponseAssert}. + * + * @author Stephane Nicoll + */ +public class AbstractMockHttpServletResponseAssertTests { + + @Test + void hasForwardedUrl() { + String forwardedUrl = "https://example.com/42"; + MockHttpServletResponse response = new MockHttpServletResponse(); + response.setForwardedUrl(forwardedUrl); + assertThat(response).hasForwardedUrl(forwardedUrl); + } + + @Test + void hasForwardedUrlWithWrongValue() { + String forwardedUrl = "https://example.com/42"; + MockHttpServletResponse response = new MockHttpServletResponse(); + response.setForwardedUrl(forwardedUrl); + Assertions.assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(response).hasForwardedUrl("another")) + .withMessageContainingAll("Forwarded URL", forwardedUrl, "another"); + } + + @Test + void hasRedirectedUrl() { + String redirectedUrl = "https://example.com/42"; + MockHttpServletResponse response = new MockHttpServletResponse(); + response.addHeader(HttpHeaders.LOCATION, redirectedUrl); + assertThat(response).hasRedirectedUrl(redirectedUrl); + } + + @Test + void hasRedirectedUrlWithWrongValue() { + String redirectedUrl = "https://example.com/42"; + MockHttpServletResponse response = new MockHttpServletResponse(); + response.addHeader(HttpHeaders.LOCATION, redirectedUrl); + Assertions.assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(response).hasRedirectedUrl("another")) + .withMessageContainingAll("Redirected URL", redirectedUrl, "another"); + } + + @Test + void bodyHasContent() throws UnsupportedEncodingException { + MockHttpServletResponse response = new MockHttpServletResponse(); + response.getWriter().write("OK"); + assertThat(response).body().asString().isEqualTo("OK"); + } + + @Test + void bodyHasContentWithResponseCharacterEncoding() throws UnsupportedEncodingException { + byte[] bytes = "OK".getBytes(StandardCharsets.UTF_8); + MockHttpServletResponse response = new MockHttpServletResponse(); + response.getWriter().write("OK"); + response.setContentType(StandardCharsets.UTF_8.name()); + assertThat(response).body().isEqualTo(bytes); + } + + + private static ResponseAssert assertThat(MockHttpServletResponse response) { + return new ResponseAssert(response); + } + + + private static final class ResponseAssert extends AbstractMockHttpServletResponseAssert { + + ResponseAssert(MockHttpServletResponse actual) { + super(null, actual, ResponseAssert.class); + } + + @Override + protected MockHttpServletResponse getResponse() { + return this.actual; + } + + } +} diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/ResponseBodyAssertTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/ResponseBodyAssertTests.java new file mode 100644 index 000000000000..0284636c3d03 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/ResponseBodyAssertTests.java @@ -0,0 +1,88 @@ +/* + * 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.web.servlet.assertj; + + +import java.io.UnsupportedEncodingException; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; + +import org.assertj.core.api.AssertProvider; +import org.junit.jupiter.api.Test; + +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.test.json.JsonContent; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link ResponseBodyAssert}. + * + * @author Brian Clozel + * @author Stephane Nicoll + */ +class ResponseBodyAssertTests { + + @Test + void isEqualToWithByteArray() { + MockHttpServletResponse response = createResponse("hello"); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + assertThat(fromResponse(response)).isEqualTo("hello".getBytes(StandardCharsets.UTF_8)); + } + + @Test + void isEqualToWithString() { + MockHttpServletResponse response = createResponse("hello"); + assertThat(fromResponse(response)).isEqualTo("hello"); + } + + @Test + void jsonPathWithJsonResponseShouldPass() { + MockHttpServletResponse response = createResponse("{\"message\": \"hello\"}"); + assertThat(fromResponse(response)).jsonPath().extractingPath("$.message").isEqualTo("hello"); + } + + @Test + void jsonPathWithJsonCompatibleResponseShouldPass() { + MockHttpServletResponse response = createResponse("{\"albumById\": {\"name\": \"Greatest hits\"}}"); + assertThat(fromResponse(response)).jsonPath() + .extractingPath("$.albumById.name").isEqualTo("Greatest hits"); + } + + @Test + void jsonCanLoadResourceRelativeToClass() { + MockHttpServletResponse response = createResponse("{ \"name\" : \"Spring\", \"age\" : 123 }"); + // See org/springframework/test/json/example.json + assertThat(fromResponse(response)).json(JsonContent.class).isLenientlyEqualTo("example.json"); + } + + private MockHttpServletResponse createResponse(String body) { + try { + MockHttpServletResponse response = new MockHttpServletResponse(); + response.getWriter().print(body); + return response; + } + catch (UnsupportedEncodingException ex) { + throw new IllegalStateException(ex); + } + } + + private AssertProvider fromResponse(MockHttpServletResponse response) { + return () -> new ResponseBodyAssert(response.getContentAsByteArray(), Charset.forName(response.getCharacterEncoding()), null); + } + +} From b46e5289229abace0fb892f4cbb501ea241ebe50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Nicoll?= Date: Fri, 15 Mar 2024 13:25:03 +0100 Subject: [PATCH 4/7] Add AssertJ support for the HTTP handler This commit adds AssertJ compatible assertions for the component that produces the result from the request. See gh-21178 --- .../test/util/MethodAssert.java | 62 ++++++++ .../servlet/assertj/HandlerResultAssert.java | 120 +++++++++++++++ .../test/util/MethodAssertTests.java | 92 ++++++++++++ .../assertj/HandlerResultAssertTests.java | 142 ++++++++++++++++++ 4 files changed, 416 insertions(+) create mode 100644 spring-test/src/main/java/org/springframework/test/util/MethodAssert.java create mode 100644 spring-test/src/main/java/org/springframework/test/web/servlet/assertj/HandlerResultAssert.java create mode 100644 spring-test/src/test/java/org/springframework/test/util/MethodAssertTests.java create mode 100644 spring-test/src/test/java/org/springframework/test/web/servlet/assertj/HandlerResultAssertTests.java diff --git a/spring-test/src/main/java/org/springframework/test/util/MethodAssert.java b/spring-test/src/main/java/org/springframework/test/util/MethodAssert.java new file mode 100644 index 000000000000..a346aa6a548f --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/util/MethodAssert.java @@ -0,0 +1,62 @@ +/* + * 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.util; + +import java.lang.reflect.Method; + +import org.assertj.core.api.AbstractObjectAssert; +import org.assertj.core.api.Assertions; + +import org.springframework.lang.Nullable; + +/** + * AssertJ {@link org.assertj.core.api.Assert assertions} that can be applied + * to a {@link Method}. + * + * @author Stephane Nicoll + * @since 6.2 + */ +public class MethodAssert extends AbstractObjectAssert { + + public MethodAssert(@Nullable Method actual) { + super(actual, MethodAssert.class); + as("Method %s", actual); + } + + /** + * Verify that the actual method has the given {@linkplain Method#getName() + * name}. + * @param name the expected method name + */ + public MethodAssert hasName(String name) { + isNotNull(); + Assertions.assertThat(this.actual.getName()).as("Method name").isEqualTo(name); + return this.myself; + } + + /** + * Verify that the actual method is declared in the given {@code type}. + * @param type the expected declaring class + */ + public MethodAssert hasDeclaringClass(Class type) { + isNotNull(); + Assertions.assertThat(this.actual.getDeclaringClass()) + .as("Method declaring class").isEqualTo(type); + return this.myself; + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/HandlerResultAssert.java b/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/HandlerResultAssert.java new file mode 100644 index 000000000000..2be4797fe3e6 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/HandlerResultAssert.java @@ -0,0 +1,120 @@ +/* + * 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.web.servlet.assertj; + +import java.lang.reflect.Method; + +import org.assertj.core.api.AbstractObjectAssert; +import org.assertj.core.api.Assertions; + +import org.springframework.cglib.core.internal.Function; +import org.springframework.lang.Nullable; +import org.springframework.test.util.MethodAssert; +import org.springframework.util.ClassUtils; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder; +import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder.MethodInvocationInfo; + +/** + * AssertJ {@link org.assertj.core.api.Assert assertions} that can be applied to + * a handler or handler method. + + * @author Stephane Nicoll + * @since 6.2 + */ +public class HandlerResultAssert extends AbstractObjectAssert { + + public HandlerResultAssert(@Nullable Object actual) { + super(actual, HandlerResultAssert.class); + as("Handler result"); + } + + /** + * Return a new {@linkplain MethodAssert assertion} object that uses + * the {@link Method} that handles the request as the object to test. + * Verify first that the handler is a {@linkplain #isMethodHandler() method + * handler}. + * Example:


+	 * // Check that a GET to "/greet" is invoked on a "handleGreet" method name
+	 * assertThat(mvc.perform(get("/greet")).handler().method().hasName("sayGreet");
+	 * 
+ */ + public MethodAssert method() { + return new MethodAssert(getHandlerMethod()); + } + + /** + * Verify that the handler is managed by a method invocation, typically on + * a controller. + */ + public HandlerResultAssert isMethodHandler() { + return isNotNull().isInstanceOf(HandlerMethod.class); + } + + /** + * Verify that the handler is managed by the given {@code handlerMethod}. + * This creates a "mock" for the given {@code controllerType} and record the + * method invocation in the {@code handlerMethod}. The arguments used by the + * target method invocation can be {@code null} as the purpose of the mock + * is to identify the method that was invoked. + * Example:

+	 * // If the method has a return type, you can return the result of the invocation
+	 * assertThat(mvc.perform(get("/greet")).handler().isInvokedOn(
+	 *         GreetController.class, controller -> controller.sayGreet());
+	 * // If the method has a void return type, the controller should be returned
+	 * assertThat(mvc.perform(post("/persons/")).handler().isInvokedOn(
+	 *         PersonController.class, controller -> controller.createPerson(null, null));
+	 * 
+ * @param controllerType the controller to mock + * @param handlerMethod the method + */ + public HandlerResultAssert isInvokedOn(Class controllerType, Function handlerMethod) { + MethodAssert actual = method(); + Object methodInvocationInfo = handlerMethod.apply(MvcUriComponentsBuilder.on(controllerType)); + Assertions.assertThat(methodInvocationInfo) + .as("Method invocation on controller '%s'", controllerType.getSimpleName()) + .isInstanceOfSatisfying(MethodInvocationInfo.class, mii -> + actual.isEqualTo(mii.getControllerMethod())); + return this; + } + + /** + * Verify that the handler is of the given {@code type}. For a controller + * method, this is the type of the controller. + * Example:

+	 * // Check that a GET to "/greet" is managed by GreetController
+	 * assertThat(mvc.perform(get("/greet")).handler().hasType(GreetController.class);
+	 * 
+ * @param type the expected type of the handler + */ + public HandlerResultAssert hasType(Class type) { + isNotNull(); + Class actualType = this.actual.getClass(); + if (this.actual instanceof HandlerMethod handlerMethod) { + actualType = handlerMethod.getBeanType(); + } + Assertions.assertThat(ClassUtils.getUserClass(actualType)).as("Handler result type").isEqualTo(type); + return this; + } + + private Method getHandlerMethod() { + isMethodHandler(); // validate type + return ((HandlerMethod) this.actual).getMethod(); + } + + +} diff --git a/spring-test/src/test/java/org/springframework/test/util/MethodAssertTests.java b/spring-test/src/test/java/org/springframework/test/util/MethodAssertTests.java new file mode 100644 index 000000000000..17f294d4edaf --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/util/MethodAssertTests.java @@ -0,0 +1,92 @@ +/* + * 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.util; + +import java.lang.reflect.Method; + +import org.junit.jupiter.api.Test; + +import org.springframework.lang.Nullable; +import org.springframework.util.ReflectionUtils; + +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +/** + * Tests for {@link MethodAssert}. + * + * @author Stephane Nicoll + */ +class MethodAssertTests { + + @Test + void isEqualTo() { + Method method = ReflectionUtils.findMethod(TestData.class, "counter"); + assertThat(method).isEqualTo(method); + } + + @Test + void hasName() { + assertThat(ReflectionUtils.findMethod(TestData.class, "counter")).hasName("counter"); + } + + @Test + void hasNameWithWrongName() { + Method method = ReflectionUtils.findMethod(TestData.class, "counter"); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(method).hasName("invalid")) + .withMessageContainingAll("Method name", "counter", "invalid"); + } + + @Test + void hasNameWithNullMethod() { + Method method = ReflectionUtils.findMethod(TestData.class, "notAMethod"); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(method).hasName("name")) + .withMessageContaining("Expecting actual not to be null"); + } + + @Test + void hasDeclaringClass() { + assertThat(ReflectionUtils.findMethod(TestData.class, "counter")).hasDeclaringClass(TestData.class); + } + + @Test + void haDeclaringClassWithWrongClass() { + Method method = ReflectionUtils.findMethod(TestData.class, "counter"); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(method).hasDeclaringClass(Method.class)) + .withMessageContainingAll("Method declaring class", + TestData.class.getCanonicalName(), Method.class.getCanonicalName()); + } + + @Test + void hasDeclaringClassWithNullMethod() { + Method method = ReflectionUtils.findMethod(TestData.class, "notAMethod"); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(method).hasDeclaringClass(TestData.class)) + .withMessageContaining("Expecting actual not to be null"); + } + + + private MethodAssert assertThat(@Nullable Method method) { + return new MethodAssert(method); + } + + + record TestData(String name, int counter) {} + +} diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/HandlerResultAssertTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/HandlerResultAssertTests.java new file mode 100644 index 000000000000..882ad0a2c3e0 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/HandlerResultAssertTests.java @@ -0,0 +1,142 @@ +/* + * 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.web.servlet.assertj; + +import java.lang.reflect.Method; + +import org.assertj.core.api.AssertProvider; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; + +import org.springframework.http.ResponseEntity; +import org.springframework.util.ReflectionUtils; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.resource.DefaultServletHttpRequestHandler; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +/** + * Tests for {@link HandlerResultAssert}. + * + * @author Stephane Nicoll + */ +class HandlerResultAssertTests { + + @Test + void hasTypeUseController() { + assertThat(handlerMethod(new TestController(), "greet")).hasType(TestController.class); + } + + @Test + void isMethodHandlerWithMethodHandler() { + assertThat(handlerMethod(new TestController(), "greet")).isMethodHandler(); + } + + @Test + void isMethodHandlerWithServletHandler() { + AssertProvider actual = handler(new DefaultServletHttpRequestHandler()); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(actual).isMethodHandler()) + .withMessageContainingAll(DefaultServletHttpRequestHandler.class.getName(), + HandlerMethod.class.getName()); + } + + @Test + void methodName() { + assertThat(handlerMethod(new TestController(), "greet")).method().hasName("greet"); + } + + @Test + void declaringClass() { + assertThat(handlerMethod(new TestController(), "greet")).method().hasDeclaringClass(TestController.class); + } + + @Test + void method() { + assertThat(handlerMethod(new TestController(), "greet")).method().isEqualTo( + ReflectionUtils.findMethod(TestController.class, "greet")); + } + + @Test + void methodWithServletHandler() { + AssertProvider actual = handler(new DefaultServletHttpRequestHandler()); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(actual).method()) + .withMessageContainingAll(DefaultServletHttpRequestHandler.class.getName(), + HandlerMethod.class.getName()); + } + + @Test + void isInvokedOn() { + assertThat(handlerMethod(new TestController(), "greet")) + .isInvokedOn(TestController.class, TestController::greet); + } + + @Test + void isInvokedOnWithVoidMethod() { + assertThat(handlerMethod(new TestController(), "update")) + .isInvokedOn(TestController.class, controller -> { + controller.update(); + return controller; + }); + } + + @Test + void isInvokedOnWithWrongMethod() { + AssertProvider actual = handlerMethod(new TestController(), "update"); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(actual).isInvokedOn(TestController.class, TestController::greet)) + .withMessageContainingAll( + method(TestController.class, "greet").toGenericString(), + method(TestController.class, "update").toGenericString()); + } + + + private static AssertProvider handler(Object instance) { + return () -> new HandlerResultAssert(instance); + } + + private static AssertProvider handlerMethod(Object instance, String name, Class... parameterTypes) { + HandlerMethod handlerMethod = new HandlerMethod(instance, method(instance.getClass(), name, parameterTypes)); + return () -> new HandlerResultAssert(handlerMethod); + } + + private static Method method(Class target, String name, Class... parameterTypes) { + Method method = ReflectionUtils.findMethod(target, name, parameterTypes); + Assertions.assertThat(method).isNotNull(); + return method; + } + + @RestController + public static class TestController { + + @GetMapping("/greet") + public ResponseEntity greet() { + return ResponseEntity.ok().body("Hello"); + } + + @PostMapping("/update") + public void update() { + } + + } + +} From 1cdbcc58f329f2ee46e7bb063353a9f737eb84d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Nicoll?= Date: Fri, 15 Mar 2024 13:26:53 +0100 Subject: [PATCH 5/7] Add AssertJ support for the Model This commit adds AssertJ compatible assertions for the Model that is generated from an HTTP request. See gh-21178 --- .../AbstractBindingResultAssert.java | 123 ++++++++++++ .../test/validation/package-info.java | 9 + .../test/web/servlet/assertj/ModelAssert.java | 163 ++++++++++++++++ .../AbstractBindingResultAssertTests.java | 136 ++++++++++++++ .../web/servlet/assertj/ModelAssertTests.java | 176 ++++++++++++++++++ 5 files changed, 607 insertions(+) create mode 100644 spring-test/src/main/java/org/springframework/test/validation/AbstractBindingResultAssert.java create mode 100644 spring-test/src/main/java/org/springframework/test/validation/package-info.java create mode 100644 spring-test/src/main/java/org/springframework/test/web/servlet/assertj/ModelAssert.java create mode 100644 spring-test/src/test/java/org/springframework/test/validation/AbstractBindingResultAssertTests.java create mode 100644 spring-test/src/test/java/org/springframework/test/web/servlet/assertj/ModelAssertTests.java diff --git a/spring-test/src/main/java/org/springframework/test/validation/AbstractBindingResultAssert.java b/spring-test/src/main/java/org/springframework/test/validation/AbstractBindingResultAssert.java new file mode 100644 index 000000000000..e6acd9523e61 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/validation/AbstractBindingResultAssert.java @@ -0,0 +1,123 @@ +/* + * 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.validation; + +import java.util.List; + +import org.assertj.core.api.AbstractAssert; +import org.assertj.core.api.AssertProvider; +import org.assertj.core.api.Assertions; +import org.assertj.core.api.ListAssert; +import org.assertj.core.error.BasicErrorMessageFactory; +import org.assertj.core.internal.Failures; + +import org.springframework.validation.BindingResult; +import org.springframework.validation.FieldError; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * AssertJ {@link org.assertj.core.api.Assert assertions} that can be applied to + * {@link BindingResult}. + * + * @author Stephane Nicoll + * @since 6.2 + * @param the type of assertions + */ +public abstract class AbstractBindingResultAssert> extends AbstractAssert { + + private final Failures failures = Failures.instance(); + + private final String name; + + protected AbstractBindingResultAssert(String name, BindingResult bindingResult, Class selfType) { + super(bindingResult, selfType); + this.name = name; + as("Binding result for attribute '%s", this.name); + } + + /** + * Verify that the total number of errors is equal to the given one. + * @param expected the expected number of errors + */ + public SELF hasErrorsCount(int expected) { + assertThat(this.actual.getErrorCount()) + .as("check errors for attribute '%s'", this.name).isEqualTo(expected); + return this.myself; + } + + /** + * Verify that the actual binding result contains fields in error with the + * given {@code fieldNames}. + * @param fieldNames the names of fields that should be in error + */ + public SELF hasFieldErrors(String... fieldNames) { + assertThat(fieldErrorNames()).contains(fieldNames); + return this.myself; + } + + /** + * Verify that the actual binding result contains only fields in + * error with the given {@code fieldNames}, and nothing else. + * @param fieldNames the exhaustive list of field name that should be in error + */ + public SELF hasOnlyFieldErrors(String... fieldNames) { + assertThat(fieldErrorNames()).containsOnly(fieldNames); + return this.myself; + } + + /** + * Verify that the field with the given {@code fieldName} has an error + * matching the given {@code errorCode}. + * @param fieldName the name of a field in error + * @param errorCode the error code for that field + */ + public SELF hasFieldErrorCode(String fieldName, String errorCode) { + Assertions.assertThat(getFieldError(fieldName).getCode()) + .as("check error code for field '%s'", fieldName).isEqualTo(errorCode); + return this.myself; + } + + protected AssertionError unexpectedBindingResult(String reason, Object... arguments) { + return this.failures.failure(this.info, new UnexpectedBindingResult(reason, arguments)); + } + + private AssertProvider> fieldErrorNames() { + return () -> { + List actual = this.actual.getFieldErrors().stream().map(FieldError::getField).toList(); + return new ListAssert<>(actual).as("check field errors"); + }; + } + + private FieldError getFieldError(String fieldName) { + FieldError fieldError = this.actual.getFieldError(fieldName); + if (fieldError == null) { + throw unexpectedBindingResult("to have at least an error for field '%s'", fieldName); + } + return fieldError; + } + + + private final class UnexpectedBindingResult extends BasicErrorMessageFactory { + + private UnexpectedBindingResult(String reason, Object... arguments) { + super("%nExpecting binding result:%n %s%n%s", AbstractBindingResultAssert.this.actual, + reason.formatted(arguments)); + } + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/validation/package-info.java b/spring-test/src/main/java/org/springframework/test/validation/package-info.java new file mode 100644 index 000000000000..caa3fdcadda3 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/validation/package-info.java @@ -0,0 +1,9 @@ +/** + * Testing support for validation. + */ +@NonNullApi +@NonNullFields +package org.springframework.test.validation; + +import org.springframework.lang.NonNullApi; +import org.springframework.lang.NonNullFields; diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/ModelAssert.java b/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/ModelAssert.java new file mode 100644 index 000000000000..b7bd5109855e --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/ModelAssert.java @@ -0,0 +1,163 @@ +/* + * 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.web.servlet.assertj; + +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; +import java.util.function.Predicate; + +import org.assertj.core.api.AbstractMapAssert; +import org.assertj.core.error.BasicErrorMessageFactory; +import org.assertj.core.internal.Failures; + +import org.springframework.lang.Nullable; +import org.springframework.test.validation.AbstractBindingResultAssert; +import org.springframework.validation.BindingResult; +import org.springframework.validation.BindingResultUtils; +import org.springframework.validation.Errors; +import org.springframework.web.servlet.ModelAndView; + +/** + * AssertJ {@link org.assertj.core.api.Assert assertions} that can be applied to + * a {@linkplain ModelAndView#getModel() model}. + * + * @author Stephane Nicoll + * @since 6.2 + */ +public class ModelAssert extends AbstractMapAssert, String, Object> { + + private final Failures failures = Failures.instance(); + + public ModelAssert(Map map) { + super(map, ModelAssert.class); + } + + /** + * Return a new {@linkplain AbstractBindingResultAssert assertion} object + * that uses the {@link BindingResult} with the given {@code name} as the + * object to test. + * Examples:

+	 * // Check that the "person" attribute in the model has 2 errors:
+	 * assertThat(...).model().extractingBindingResult("person").hasErrorsCount(2);
+	 * 
+ */ + public AbstractBindingResultAssert extractingBindingResult(String name) { + BindingResult result = BindingResultUtils.getBindingResult(this.actual, name); + if (result == null) { + throw unexpectedModel("to have a binding result for attribute '%s'", name); + } + return new BindingResultAssert(name, result); + } + + /** + * Verify that the actual model has at least one error. + */ + public ModelAssert hasErrors() { + if (getAllErrors() == 0) { + throw unexpectedModel("to have at least one error"); + } + return this.myself; + } + + /** + * Verify that the actual model does not have any errors. + */ + public ModelAssert doesNotHaveErrors() { + int count = getAllErrors(); if (count > 0) { + throw unexpectedModel("to not have an error, but got %s", count); + } + return this.myself; + } + + /** + * Verify that the actual model contain the attributes with the given + * {@code names}, and that these attributes have each at least one error. + * @param names the expected names of attributes with errors + */ + public ModelAssert hasAttributeErrors(String... names) { + return assertAttributes(names, BindingResult::hasErrors, + "to have attribute errors for", "these attributes do not have any error"); + } + + /** + * Verify that the actual model contain the attributes with the given + * {@code names}, and that these attributes do not have any error. + * @param names the expected names of attributes without errors + */ + public ModelAssert doesNotHaveAttributeErrors(String... names) { + return assertAttributes(names, Predicate.not(BindingResult::hasErrors), + "to have attribute without errors for", "these attributes have at least an error"); + } + + private ModelAssert assertAttributes(String[] names, Predicate condition, + String assertionMessage, String failAssertionMessage) { + + Set missing = new LinkedHashSet<>(); + Set failCondition = new LinkedHashSet<>(); + for (String name : names) { + BindingResult bindingResult = getBindingResult(name); + if (bindingResult == null) { + missing.add(name); + } + else if (!condition.test(bindingResult)) { + failCondition.add(name); + } + } + if (!missing.isEmpty() || !failCondition.isEmpty()) { + StringBuilder sb = new StringBuilder(); + sb.append("%n%s:%n %s%n".formatted(assertionMessage, String.join(", ", names))); + if (!missing.isEmpty()) { + sb.append("%nbut could not find these attributes:%n %s%n".formatted(String.join(", ", missing))); + } + if (!failCondition.isEmpty()) { + String prefix = missing.isEmpty() ? "but" : "and"; + sb.append("%n%s %s:%n %s%n".formatted(prefix, failAssertionMessage, String.join(", ", failCondition))); + } + throw unexpectedModel(sb.toString()); + } + return this.myself; + } + + private AssertionError unexpectedModel(String reason, Object... arguments) { + return this.failures.failure(this.info, new UnexpectedModel(reason, arguments)); + } + + private int getAllErrors() { + return this.actual.values().stream().filter(Errors.class::isInstance).map(Errors.class::cast) + .map(Errors::getErrorCount).reduce(0, Integer::sum); + } + + @Nullable + private BindingResult getBindingResult(String name) { + return BindingResultUtils.getBindingResult(this.actual, name); + } + + private final class UnexpectedModel extends BasicErrorMessageFactory { + + private UnexpectedModel(String reason, Object... arguments) { + super("%nExpecting model:%n %s%n%s", ModelAssert.this.actual, reason.formatted(arguments)); + } + } + + private static final class BindingResultAssert extends AbstractBindingResultAssert { + public BindingResultAssert(String name, BindingResult bindingResult) { + super(name, bindingResult, BindingResultAssert.class); + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/validation/AbstractBindingResultAssertTests.java b/spring-test/src/test/java/org/springframework/test/validation/AbstractBindingResultAssertTests.java new file mode 100644 index 000000000000..39ee91e23494 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/validation/AbstractBindingResultAssertTests.java @@ -0,0 +1,136 @@ +/* + * 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.validation; + +import java.util.Map; + +import org.assertj.core.api.AssertProvider; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.MutablePropertyValues; +import org.springframework.beans.testfixture.beans.TestBean; +import org.springframework.validation.BindException; +import org.springframework.validation.BindingResult; +import org.springframework.validation.DataBinder; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +/** + * Tests for {@link AbstractBindingResultAssert}. + * + * @author Stephane Nicoll + */ +class AbstractBindingResultAssertTests { + + @Test + void hasErrorsCountWithNoError() { + assertThat(bindingResult(new TestBean(), Map.of("name", "John", "age", "42"))).hasErrorsCount(0); + } + + @Test + void hasErrorsCountWithInvalidCount() { + AssertProvider actual = bindingResult(new TestBean(), + Map.of("name", "John", "age", "4x", "touchy", "invalid.value")); + assertThatExceptionOfType(AssertionError.class).isThrownBy( + () -> assertThat(actual).hasErrorsCount(1)) + .withMessageContainingAll("check errors for attribute 'test'", "1", "2"); + } + + @Test + void hasFieldErrorsWithMatchingSubset() { + assertThat(bindingResult(new TestBean(), Map.of("name", "John", "age", "4x", "touchy", "x.y"))) + .hasFieldErrors("touchy"); + } + + @Test + void hasFieldErrorsWithAllMatching() { + assertThat(bindingResult(new TestBean(), Map.of("name", "John", "age", "4x", "touchy", "x.y"))) + .hasFieldErrors("touchy", "age"); + } + + @Test + void hasFieldErrorsWithNotAllMatching() { + AssertProvider actual = bindingResult(new TestBean(), Map.of("name", "John", "age", "4x", "touchy", "x.y")); + assertThatExceptionOfType(AssertionError.class).isThrownBy( + () -> assertThat(actual).hasFieldErrors("age", "name")) + .withMessageContainingAll("check field errors", "age", "touchy", "name"); + } + + @Test + void hasOnlyFieldErrorsWithAllMatching() { + assertThat(bindingResult(new TestBean(), Map.of("name", "John", "age", "4x", "touchy", "x.y"))) + .hasOnlyFieldErrors("touchy", "age"); + } + + @Test + void hasOnlyFieldErrorsWithMatchingSubset() { + AssertProvider actual = bindingResult(new TestBean(), Map.of("name", "John", "age", "4x", "touchy", "x.y")); + assertThatExceptionOfType(AssertionError.class).isThrownBy( + () -> assertThat(actual).hasOnlyFieldErrors("age")) + .withMessageContainingAll("check field errors", "age", "touchy"); + } + + @Test + void hasFieldErrorCodeWithMatchingCode() { + assertThat(bindingResult(new TestBean(), Map.of("name", "John", "age", "4x", "touchy", "x.y"))) + .hasFieldErrorCode("age", "typeMismatch"); + } + + @Test + void hasFieldErrorCodeWitNonMatchingCode() { + AssertProvider actual = bindingResult(new TestBean(), Map.of("name", "John", "age", "4x", "touchy", "x.y")); + assertThatExceptionOfType(AssertionError.class).isThrownBy( + () -> assertThat(actual).hasFieldErrorCode("age", "castFailure")) + .withMessageContainingAll("check error code for field 'age'", "castFailure", "typeMismatch"); + } + + @Test + void hasFieldErrorCodeWitNonMatchingField() { + AssertProvider actual = bindingResult(new TestBean(), Map.of("name", "John", "age", "4x", "touchy", "x.y")); + assertThatExceptionOfType(AssertionError.class).isThrownBy( + () -> assertThat(actual).hasFieldErrorCode("unknown", "whatever")) + .withMessageContainingAll("Expecting binding result", "touchy", "age", + "to have at least an error for field 'unknown'"); + } + + + private AssertProvider bindingResult(Object instance, Map propertyValues) { + return () -> new BindingResultAssert("test", createBindingResult(instance, propertyValues)); + } + + private static BindingResult createBindingResult(Object instance, Map propertyValues) { + DataBinder binder = new DataBinder(instance, "test"); + MutablePropertyValues pvs = new MutablePropertyValues(propertyValues); + binder.bind(pvs); + try { + binder.close(); + return binder.getBindingResult(); + } + catch (BindException ex) { + return ex.getBindingResult(); + } + } + + + private static final class BindingResultAssert extends AbstractBindingResultAssert { + public BindingResultAssert(String name, BindingResult bindingResult) { + super(name, bindingResult, BindingResultAssert.class); + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/ModelAssertTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/ModelAssertTests.java new file mode 100644 index 000000000000..7126fdf34952 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/ModelAssertTests.java @@ -0,0 +1,176 @@ +/* + * 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.web.servlet.assertj; + +import java.util.HashMap; +import java.util.Map; + +import org.assertj.core.api.AssertProvider; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.MutablePropertyValues; +import org.springframework.beans.testfixture.beans.TestBean; +import org.springframework.validation.BindException; +import org.springframework.validation.DataBinder; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +/** + * Tests for {@link ModelAssert}. + * + * @author Stephane Nicoll + */ +class ModelAssertTests { + + @Test + void hasErrors() { + assertThat(forModel(new TestBean(), Map.of("name", "John", "age", "4x"))).hasErrors(); + } + + @Test + void hasErrorsWithNoError() { + AssertProvider actual = forModel(new TestBean(), Map.of("name", "John", "age", "42")); + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> assertThat(actual).hasErrors()) + .withMessageContainingAll("John", "to have at least one error"); + } + + @Test + void doesNotHaveErrors() { + assertThat(forModel(new TestBean(), Map.of("name", "John", "age", "42"))).doesNotHaveErrors(); + } + + @Test + void doesNotHaveErrorsWithError() { + AssertProvider actual = forModel(new TestBean(), Map.of("name", "John", "age", "4x")); + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> assertThat(actual).doesNotHaveErrors()) + .withMessageContainingAll("John", "to not have an error, but got 1"); + } + + @Test + void extractBindingResultForAttributeInError() { + Map model = new HashMap<>(); + augmentModel(model, "person", new TestBean(), Map.of("name", "John", "age", "4x", "touchy", "invalid.value")); + assertThat(forModel(model)).extractingBindingResult("person").hasErrorsCount(2); + } + + @Test + void hasErrorCountForUnknownAttribute() { + Map model = new HashMap<>(); + augmentModel(model, "person", new TestBean(), Map.of("name", "John", "age", "42")); + AssertProvider actual = forModel(model); + assertThatExceptionOfType(AssertionError.class).isThrownBy( + () -> assertThat(actual).extractingBindingResult("user")) + .withMessageContainingAll("to have a binding result for attribute 'user'"); + } + + @Test + void hasErrorsWithMatchingAttributes() { + Map model = new HashMap<>(); + augmentModel(model, "wrong1", new TestBean(), Map.of("name", "first", "age", "4x")); + augmentModel(model, "valid", new TestBean(), Map.of("name", "second")); + augmentModel(model, "wrong2", new TestBean(), Map.of("name", "third", "touchy", "invalid.name")); + assertThat(forModel(model)).hasAttributeErrors("wrong1", "wrong2"); + } + + @Test + void hasErrorsWithOneNonMatchingAttribute() { + Map model = new HashMap<>(); + augmentModel(model, "wrong1", new TestBean(), Map.of("name", "first", "age", "4x")); + augmentModel(model, "valid", new TestBean(), Map.of("name", "second")); + augmentModel(model, "wrong2", new TestBean(), Map.of("name", "third", "touchy", "invalid.name")); + AssertProvider actual = forModel(model); + assertThatExceptionOfType(AssertionError.class).isThrownBy( + () -> assertThat(actual).hasAttributeErrors("wrong1", "valid")) + .withMessageContainingAll("to have attribute errors for:", "wrong1, valid", + "but these attributes do not have any error:", "valid"); + } + + @Test + void hasErrorsWithOneNonMatchingAttributeAndOneUnknownAttribute() { + Map model = new HashMap<>(); + augmentModel(model, "wrong1", new TestBean(), Map.of("name", "first", "age", "4x")); + augmentModel(model, "valid", new TestBean(), Map.of("name", "second")); + augmentModel(model, "wrong2", new TestBean(), Map.of("name", "third", "touchy", "invalid.name")); + AssertProvider actual = forModel(model); + assertThatExceptionOfType(AssertionError.class).isThrownBy( + () -> assertThat(actual).hasAttributeErrors("wrong1", "unknown", "valid")) + .withMessageContainingAll("to have attribute errors for:", "wrong1, unknown, valid", + "but could not find these attributes:", "unknown", + "and these attributes do not have any error:", "valid"); + } + + @Test + void doesNotHaveErrorsWithMatchingAttributes() { + Map model = new HashMap<>(); + augmentModel(model, "valid1", new TestBean(), Map.of("name", "first")); + augmentModel(model, "wrong", new TestBean(), Map.of("name", "second", "age", "4x")); + augmentModel(model, "valid2", new TestBean(), Map.of("name", "third")); + assertThat(forModel(model)).doesNotHaveAttributeErrors("valid1", "valid2"); + } + + @Test + void doesNotHaveErrorsWithOneNonMatchingAttribute() { + Map model = new HashMap<>(); + augmentModel(model, "valid1", new TestBean(), Map.of("name", "first")); + augmentModel(model, "wrong", new TestBean(), Map.of("name", "second", "age", "4x")); + augmentModel(model, "valid2", new TestBean(), Map.of("name", "third")); + AssertProvider actual = forModel(model); + assertThatExceptionOfType(AssertionError.class).isThrownBy( + () -> assertThat(actual).doesNotHaveAttributeErrors("valid1", "wrong")) + .withMessageContainingAll("to have attribute without errors for:", "valid1, wrong", + "but these attributes have at least an error:", "wrong"); + } + + @Test + void doesNotHaveErrorsWithOneNonMatchingAttributeAndOneUnknownAttribute() { + Map model = new HashMap<>(); + augmentModel(model, "valid1", new TestBean(), Map.of("name", "first")); + augmentModel(model, "wrong", new TestBean(), Map.of("name", "second", "age", "4x")); + augmentModel(model, "valid2", new TestBean(), Map.of("name", "third")); + AssertProvider actual = forModel(model); + assertThatExceptionOfType(AssertionError.class).isThrownBy( + () -> assertThat(actual).doesNotHaveAttributeErrors("valid1", "unknown", "wrong")) + .withMessageContainingAll("to have attribute without errors for:", "valid1, unknown, wrong", + "but could not find these attributes:", "unknown", + "and these attributes have at least an error:", "wrong"); + } + + private AssertProvider forModel(Map model) { + return () -> new ModelAssert(model); + } + + private AssertProvider forModel(Object instance, Map propertyValues) { + Map model = new HashMap<>(); + augmentModel(model, "test", instance, propertyValues); + return forModel(model); + } + + private static void augmentModel(Map model, String attribute, Object instance, Map propertyValues) { + DataBinder binder = new DataBinder(instance, attribute); + MutablePropertyValues pvs = new MutablePropertyValues(propertyValues); + binder.bind(pvs); + try { + binder.close(); + model.putAll(binder.getBindingResult().getModel()); + } + catch (BindException ex) { + model.putAll(ex.getBindingResult().getModel()); + } + } + +} From 555d4a6004c5c399ed102e68de63c83fd2bd2aba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Nicoll?= Date: Fri, 15 Mar 2024 13:27:23 +0100 Subject: [PATCH 6/7] Add AssertJ support for cookies This commit adds AssertJ compatible assertions for cookies See gh-21178 Co-authored-by: Brian Clozel --- .../web/servlet/assertj/CookieMapAssert.java | 173 +++++++++++++++++ .../servlet/assertj/CookieMapAssertTests.java | 182 ++++++++++++++++++ 2 files changed, 355 insertions(+) create mode 100644 spring-test/src/main/java/org/springframework/test/web/servlet/assertj/CookieMapAssert.java create mode 100644 spring-test/src/test/java/org/springframework/test/web/servlet/assertj/CookieMapAssertTests.java diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/CookieMapAssert.java b/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/CookieMapAssert.java new file mode 100644 index 000000000000..803399b1538b --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/CookieMapAssert.java @@ -0,0 +1,173 @@ +/* + * 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.web.servlet.assertj; + +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.function.Consumer; + +import jakarta.servlet.http.Cookie; +import org.assertj.core.api.AbstractMapAssert; +import org.assertj.core.api.Assertions; + +/** + * AssertJ {@link org.assertj.core.api.Assert assertions} that can be applied to + * {@link Cookie cookies}. + * + * @author Brian Clozel + * @author Stephane Nicoll + * @since 6.2 + */ +public class CookieMapAssert extends AbstractMapAssert, String, Cookie> { + + public CookieMapAssert(Cookie[] actual) { + super(mapCookies(actual), CookieMapAssert.class); + as("Cookies"); + } + + private static Map mapCookies(Cookie[] cookies) { + Map map = new LinkedHashMap<>(); + for (Cookie cookie : cookies) { + map.putIfAbsent(cookie.getName(), cookie); + } + return map; + } + + /** + * Verify that the actual cookies contain a cookie with the given {@code name}. + * @param name the name of an expected cookie + * @see #containsKey + */ + public CookieMapAssert containsCookie(String name) { + return containsKey(name); + } + + /** + * Verify that the actual cookies contain the cookies with the given + * {@code names}. + * @param names the names of expected cookies + * @see #containsKeys + */ + public CookieMapAssert containsCookies(String... names) { + return containsKeys(names); + } + + /** + * Verify that the actual cookies do not contain a cookie with the + * given {@code name}. + * @param name the name of a cookie that should not be present + * @see #doesNotContainKey + */ + public CookieMapAssert doesNotContainCookie(String name) { + return doesNotContainKey(name); + } + + /** + * Verify that the actual cookies do not contain any of the cookies with + * the given {@code names}. + * @param names the names of cookies that should not be present + * @see #doesNotContainKeys + */ + public CookieMapAssert doesNotContainCookies(String... names) { + return doesNotContainKeys(names); + } + + /** + * Verify that the actual cookies contain a cookie with the given + * {@code name} that satisfy given {@code cookieRequirements}. + * the specified names. + * @param name the name of an expected cookie + * @param cookieRequirements the requirements for the cookie + */ + public CookieMapAssert hasCookieSatisfying(String name, Consumer cookieRequirements) { + return hasEntrySatisfying(name, cookieRequirements); + } + + /** + * Verify that the actual cookies contain a cookie with the given + * {@code name} whose {@linkplain Cookie#getValue() value} is equal to the + * given one. + * @param name the name of the cookie + * @param expected the expected value of the cookie + */ + public CookieMapAssert hasValue(String name, String expected) { + return hasCookieSatisfying(name, cookie -> + Assertions.assertThat(cookie.getValue()).isEqualTo(expected)); + } + + /** + * Verify that the actual cookies contain a cookie with the given + * {@code name} whose {@linkplain Cookie#getMaxAge() max age} is equal to + * the given one. + * @param name the name of the cookie + * @param expected the expected max age of the cookie + */ + public CookieMapAssert hasMaxAge(String name, Duration expected) { + return hasCookieSatisfying(name, cookie -> + Assertions.assertThat(Duration.ofSeconds(cookie.getMaxAge())).isEqualTo(expected)); + } + + /** + * Verify that the actual cookies contain a cookie with the given + * {@code name} whose {@linkplain Cookie#getPath() path} is equal to + * the given one. + * @param name the name of the cookie + * @param expected the expected path of the cookie + */ + public CookieMapAssert hasPath(String name, String expected) { + return hasCookieSatisfying(name, cookie -> + Assertions.assertThat(cookie.getPath()).isEqualTo(expected)); + } + + /** + * Verify that the actual cookies contain a cookie with the given + * {@code name} whose {@linkplain Cookie#getDomain() domain} is equal to + * the given one. + * @param name the name of the cookie + * @param expected the expected path of the cookie + */ + public CookieMapAssert hasDomain(String name, String expected) { + return hasCookieSatisfying(name, cookie -> + Assertions.assertThat(cookie.getDomain()).isEqualTo(expected)); + } + + /** + * Verify that the actual cookies contain a cookie with the given + * {@code name} whose {@linkplain Cookie#getSecure() secure flag} is equal + * to the given one. + * @param name the name of the cookie + * @param expected whether the cookie is secure + */ + public CookieMapAssert isSecure(String name, boolean expected) { + return hasCookieSatisfying(name, cookie -> + Assertions.assertThat(cookie.getSecure()).isEqualTo(expected)); + } + + /** + * Verify that the actual cookies contain a cookie with the given + * {@code name} whose {@linkplain Cookie#isHttpOnly() http only flag} is + * equal to the given one. + * @param name the name of the cookie + * @param expected whether the cookie is http only + */ + public CookieMapAssert isHttpOnly(String name, boolean expected) { + return hasCookieSatisfying(name, cookie -> + Assertions.assertThat(cookie.isHttpOnly()).isEqualTo(expected)); + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/CookieMapAssertTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/CookieMapAssertTests.java new file mode 100644 index 000000000000..0bcdabb01019 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/CookieMapAssertTests.java @@ -0,0 +1,182 @@ +/* + * 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.web.servlet.assertj; + + +import java.time.Duration; +import java.util.List; + +import jakarta.servlet.http.Cookie; +import org.assertj.core.api.AssertProvider; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +/** + * Tests for {@link CookieMapAssert}. + * + * @author Brian Clozel + */ +class CookieMapAssertTests { + + static Cookie[] cookies; + + @BeforeAll + static void setup() { + Cookie framework = new Cookie("framework", "spring"); + framework.setSecure(true); + framework.setHttpOnly(true); + Cookie age = new Cookie("age", "value"); + age.setMaxAge(1200); + Cookie domain = new Cookie("domain", "value"); + domain.setDomain("spring.io"); + Cookie path = new Cookie("path", "value"); + path.setPath("/spring"); + cookies = List.of(framework, age, domain, path).toArray(new Cookie[0]); + } + + @Test + void containsCookieWhenCookieExistsShouldPass() { + assertThat(forCookies()).containsCookie("framework"); + } + + @Test + void containsCookieWhenCookieMissingShouldFail() { + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + assertThat(forCookies()).containsCookie("missing")); + } + + @Test + void containsCookiesWhenCookiesExistShouldPass() { + assertThat(forCookies()).containsCookies("framework", "age"); + } + + @Test + void containsCookiesWhenCookieMissingShouldFail() { + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + assertThat(forCookies()).containsCookies("framework", "missing")); + } + + @Test + void doesNotContainCookieWhenCookieMissingShouldPass() { + assertThat(forCookies()).doesNotContainCookie("missing"); + } + + @Test + void doesNotContainCookieWhenCookieExistsShouldFail() { + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + assertThat(forCookies()).doesNotContainCookie("framework")); + } + + @Test + void doesNotContainCookiesWhenCookiesMissingShouldPass() { + assertThat(forCookies()).doesNotContainCookies("missing", "missing2"); + } + + @Test + void doesNotContainCookiesWhenAtLeastOneCookieExistShouldFail() { + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + assertThat(forCookies()).doesNotContainCookies("missing", "framework")); + } + + @Test + void hasValueEqualsWhenCookieValueMatchesShouldPass() { + assertThat(forCookies()).hasValue("framework", "spring"); + } + + @Test + void hasValueEqualsWhenCookieValueDiffersShouldFail() { + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + assertThat(forCookies()).hasValue("framework", "other")); + } + + @Test + void hasCookieSatisfyingWhenCookieValueMatchesShouldPass() { + assertThat(forCookies()).hasCookieSatisfying("framework", cookie -> + assertThat(cookie.getValue()).startsWith("spr")); + } + + @Test + void hasCookieSatisfyingWhenCookieValueDiffersShouldFail() { + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + assertThat(forCookies()).hasCookieSatisfying("framework", cookie -> + assertThat(cookie.getValue()).startsWith("not"))); + } + + @Test + void hasMaxAgeWhenCookieAgeMatchesShouldPass() { + assertThat(forCookies()).hasMaxAge("age", Duration.ofMinutes(20)); + } + + @Test + void hasMaxAgeWhenCookieAgeDiffersShouldFail() { + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + assertThat(forCookies()).hasMaxAge("age", Duration.ofMinutes(30))); + } + + @Test + void pathWhenCookiePathMatchesShouldPass() { + assertThat(forCookies()).hasPath("path", "/spring"); + } + + @Test + void pathWhenCookiePathDiffersShouldFail() { + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + assertThat(forCookies()).hasPath("path", "/other")); + } + + @Test + void hasDomainWhenCookieDomainMatchesShouldPass() { + assertThat(forCookies()).hasDomain("domain", "spring.io"); + } + + @Test + void hasDomainWhenCookieDomainDiffersShouldFail() { + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + assertThat(forCookies()).hasDomain("domain", "example.org")); + } + + @Test + void isSecureWhenCookieSecureMatchesShouldPass() { + assertThat(forCookies()).isSecure("framework", true); + } + + @Test + void isSecureWhenCookieSecureDiffersShouldFail() { + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + assertThat(forCookies()).isSecure("domain", true)); + } + + @Test + void isHttpOnlyWhenCookieHttpOnlyMatchesShouldPass() { + assertThat(forCookies()).isHttpOnly("framework", true); + } + + @Test + void isHttpOnlyWhenCookieHttpOnlyDiffersShouldFail() { + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + assertThat(forCookies()).isHttpOnly("domain", true)); + } + + + private AssertProvider forCookies() { + return () -> new CookieMapAssert(cookies); + } + +} From e7d7cb86417d8115795e6cfa2683f268f970ceb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Nicoll?= Date: Fri, 15 Mar 2024 13:30:16 +0100 Subject: [PATCH 7/7] Add AssertJ support for MockMvc This commit adds a new way to use MockMvc by returning a MvcResult that is AssertJ compatible. Compared to the existing MockMvc infrastructure, this new model has the following advantages: * Can be created from a MockMvc instance * Dedicated builder methods for easier setup * Assertions use familiar AssertJ syntax with discoverable assertions through a DSL * Improved exception message See gh-21178 Co-authored-by: Brian Clozel --- .../servlet/assertj/AssertableMockMvc.java | 227 +++++++ .../servlet/assertj/AssertableMvcResult.java | 50 ++ .../assertj/DefaultAssertableMvcResult.java | 123 ++++ .../web/servlet/assertj/MvcResultAssert.java | 258 ++++++++ .../AssertableMockMvcIntegrationTests.java | 558 ++++++++++++++++++ .../assertj/AssertableMockMvcTests.java | 210 +++++++ .../DefaultAssertableMvcResultTests.java | 107 ++++ .../test/web/servlet/assertj/message.json | 3 + 8 files changed, 1536 insertions(+) create mode 100644 spring-test/src/main/java/org/springframework/test/web/servlet/assertj/AssertableMockMvc.java create mode 100644 spring-test/src/main/java/org/springframework/test/web/servlet/assertj/AssertableMvcResult.java create mode 100644 spring-test/src/main/java/org/springframework/test/web/servlet/assertj/DefaultAssertableMvcResult.java create mode 100644 spring-test/src/main/java/org/springframework/test/web/servlet/assertj/MvcResultAssert.java create mode 100644 spring-test/src/test/java/org/springframework/test/web/servlet/assertj/AssertableMockMvcIntegrationTests.java create mode 100644 spring-test/src/test/java/org/springframework/test/web/servlet/assertj/AssertableMockMvcTests.java create mode 100644 spring-test/src/test/java/org/springframework/test/web/servlet/assertj/DefaultAssertableMvcResultTests.java create mode 100644 spring-test/src/test/resources/org/springframework/test/web/servlet/assertj/message.json diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/AssertableMockMvc.java b/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/AssertableMockMvc.java new file mode 100644 index 000000000000..401aab99896d --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/AssertableMockMvc.java @@ -0,0 +1,227 @@ +/* + * 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.web.servlet.assertj; + +import java.net.URI; +import java.util.Arrays; +import java.util.Collection; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.StreamSupport; + +import org.springframework.http.MediaType; +import org.springframework.http.converter.GenericHttpMessageConverter; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.lang.Nullable; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.RequestBuilder; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.test.web.servlet.setup.StandaloneMockMvcBuilder; +import org.springframework.util.Assert; +import org.springframework.web.context.WebApplicationContext; + +/** + * {@link MockMvc} variant that tests Spring MVC exchanges and provide fluent + * assertions using {@link org.assertj.core.api.Assertions AssertJ}. + * + *

A main difference with {@link MockMvc} is that an unresolved exception + * is not thrown directly. Rather an {@link AssertableMvcResult} is available + * with an {@link AssertableMvcResult#getUnresolvedException() unresolved + * exception}. + * + *

{@link AssertableMockMvc} can be configured with a list of + * {@linkplain HttpMessageConverter HttpMessageConverters} to allow response + * body to be deserialized, rather than asserting on the raw values. + * + * @author Stephane Nicoll + * @author Brian Clozel + * @since 6.2 + */ +public final class AssertableMockMvc { + + private static final MediaType JSON = MediaType.APPLICATION_JSON; + + private final MockMvc mockMvc; + + @Nullable + private final GenericHttpMessageConverter jsonMessageConverter; + + + private AssertableMockMvc(MockMvc mockMvc, @Nullable GenericHttpMessageConverter jsonMessageConverter) { + Assert.notNull(mockMvc, "mockMVC should not be null"); + this.mockMvc = mockMvc; + this.jsonMessageConverter = jsonMessageConverter; + } + + /** + * Create a new {@link AssertableMockMvc} instance that delegates to the + * given {@link MockMvc}. + * @param mockMvc the MockMvc instance to delegate calls to + */ + public static AssertableMockMvc create(MockMvc mockMvc) { + return new AssertableMockMvc(mockMvc, null); + } + + /** + * Create a {@link AssertableMockMvc} instance using the given, fully + * initialized (i.e., refreshed) {@link WebApplicationContext}. The + * given {@code customizations} are applied to the {@link DefaultMockMvcBuilder} + * that ultimately creates the underlying {@link MockMvc} instance. + *

If no further customization of the underlying {@link MockMvc} instance + * is required, use {@link #from(WebApplicationContext)}. + * @param applicationContext the application context to detect the Spring + * MVC infrastructure and application controllers from + * @param customizations the function that creates a {@link MockMvc} + * instance based on a {@link DefaultMockMvcBuilder}. + * @see MockMvcBuilders#webAppContextSetup(WebApplicationContext) + */ + public static AssertableMockMvc from(WebApplicationContext applicationContext, + Function customizations) { + + DefaultMockMvcBuilder builder = MockMvcBuilders.webAppContextSetup(applicationContext); + MockMvc mockMvc = customizations.apply(builder); + return create(mockMvc); + } + + /** + * Shortcut to create a {@link AssertableMockMvc} instance using the given, + * fully initialized (i.e., refreshed) {@link WebApplicationContext}. + *

Consider using {@link #from(WebApplicationContext, Function)} if + * further customizations of the underlying {@link MockMvc} instance is + * required. + * @param applicationContext the application context to detect the Spring + * MVC infrastructure and application controllers from + * @see MockMvcBuilders#webAppContextSetup(WebApplicationContext) + */ + public static AssertableMockMvc from(WebApplicationContext applicationContext) { + return from(applicationContext, DefaultMockMvcBuilder::build); + } + + /** + * Create a {@link AssertableMockMvc} instance by registering one or more + * {@code @Controller} instances and configuring Spring MVC infrastructure + * programmatically. + *

This allows full control over the instantiation and initialization of + * controllers and their dependencies, similar to plain unit tests while + * also making it possible to test one controller at a time. + * @param controllers one or more {@code @Controller} instances to test + * (specified {@code Class} will be turned into instance) + * @param customizations the function that creates a {@link MockMvc} + * instance based on a {@link StandaloneMockMvcBuilder}, typically to + * configure the Spring MVC infrastructure + * @see MockMvcBuilders#standaloneSetup(Object...) + */ + public static AssertableMockMvc of(Collection controllers, + Function customizations) { + + StandaloneMockMvcBuilder builder = MockMvcBuilders.standaloneSetup(controllers.toArray()); + return create(customizations.apply(builder)); + } + + /** + * Shortcut to create a {@link AssertableMockMvc} instance by registering + * one or more {@code @Controller} instances. + *

The minimum infrastructure required by the + * {@link org.springframework.web.servlet.DispatcherServlet DispatcherServlet} + * to serve requests with annotated controllers is created. Consider using + * {@link #of(Collection, Function)} if additional configuration of the MVC + * infrastructure is required. + * @param controllers one or more {@code @Controller} instances to test + * (specified {@code Class} will be turned into instance) + * @see MockMvcBuilders#standaloneSetup(Object...) + */ + public static AssertableMockMvc of(Object... controllers) { + return of(Arrays.asList(controllers), StandaloneMockMvcBuilder::build); + } + + /** + * Return a new {@link AssertableMockMvc} instance using the specified + * {@link HttpMessageConverter}. If none are specified, only basic assertions + * on the response body can be performed. Consider registering a suitable + * JSON converter for asserting data structure. + * @param httpMessageConverters the message converters to use + * @return a new instance using the specified converters + */ + public AssertableMockMvc withHttpMessageConverters(Iterable> httpMessageConverters) { + return new AssertableMockMvc(this.mockMvc, findJsonMessageConverter(httpMessageConverters)); + } + + /** + * Perform a request and return a type that can be used with standard + * {@link org.assertj.core.api.Assertions AssertJ} assertions. + *

Use static methods of {@link MockMvcRequestBuilders} to prepare the + * request, wrapping the invocation in {@code assertThat}. The following + * asserts that a {@linkplain MockMvcRequestBuilders#get(URI) GET} request + * against "/greet" has an HTTP status code 200 (OK), and a simple body: + *

assertThat(mvc.perform(get("/greet")))
+	 *       .hasStatusOk()
+	 *       .body().asString().isEqualTo("Hello");
+	 * 
+ *

Contrary to {@link MockMvc#perform(RequestBuilder)}, this does not + * throw an exception if the request fails with an unresolved exception. + * Rather, the result provides the exception, if any. Assuming that a + * {@linkplain MockMvcRequestBuilders#post(URI) POST} request against + * {@code /boom} throws an {@code IllegalStateException}, the following + * asserts that the invocation has indeed failed with the expected error + * message: + *

assertThat(mvc.perform(post("/boom")))
+	 *       .unresolvedException().isInstanceOf(IllegalStateException.class)
+	 *       .hasMessage("Expected");
+	 * 
+ *

+ * @param requestBuilder used to prepare the request to execute; + * see static factory methods in + * {@link org.springframework.test.web.servlet.request.MockMvcRequestBuilders} + * @return an {@link AssertableMvcResult} to be wrapped in {@code assertThat} + * @see MockMvc#perform(RequestBuilder) + */ + public AssertableMvcResult perform(RequestBuilder requestBuilder) { + Object result = getMvcResultOrFailure(requestBuilder); + if (result instanceof MvcResult mvcResult) { + return new DefaultAssertableMvcResult(mvcResult, null, this.jsonMessageConverter); + } + else { + return new DefaultAssertableMvcResult(null, (Exception) result, this.jsonMessageConverter); + } + } + + private Object getMvcResultOrFailure(RequestBuilder requestBuilder) { + try { + return this.mockMvc.perform(requestBuilder).andReturn(); + } + catch (Exception ex) { + return ex; + } + } + + @SuppressWarnings("unchecked") + @Nullable + private GenericHttpMessageConverter findJsonMessageConverter( + Iterable> messageConverters) { + + return StreamSupport.stream(messageConverters.spliterator(), false) + .filter(GenericHttpMessageConverter.class::isInstance) + .map(GenericHttpMessageConverter.class::cast) + .filter(converter -> converter.canWrite(null, Map.class, JSON)) + .filter(converter -> converter.canRead(Map.class, JSON)) + .findFirst().orElse(null); + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/AssertableMvcResult.java b/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/AssertableMvcResult.java new file mode 100644 index 000000000000..c160da7e819b --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/AssertableMvcResult.java @@ -0,0 +1,50 @@ +/* + * 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.web.servlet.assertj; + +import org.assertj.core.api.AssertProvider; + +import org.springframework.lang.Nullable; +import org.springframework.test.web.servlet.MvcResult; + +/** + * A {@link MvcResult} that additionally supports AssertJ style assertions. + * + *

Can be in two distinct states: + *

    + *
  1. The request processed successfully, and {@link #getUnresolvedException()} + * is therefore {@code null}.
  2. + *
  3. The request failed unexpectedly with {@link #getUnresolvedException()} + * providing more information about the error. Any attempt to access a + * member of the result fails with an exception.
  4. + *
+ * + * @author Stephane Nicoll + * @author Brian Clozel + * @since 6.2 + * @see AssertableMockMvc + */ +public interface AssertableMvcResult extends MvcResult, AssertProvider { + + /** + * Return the exception that was thrown unexpectedly while processing the + * request, if any. + */ + @Nullable + Exception getUnresolvedException(); + +} diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/DefaultAssertableMvcResult.java b/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/DefaultAssertableMvcResult.java new file mode 100644 index 000000000000..3864688c9db7 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/DefaultAssertableMvcResult.java @@ -0,0 +1,123 @@ +/* + * 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.web.servlet.assertj; + +import org.springframework.http.converter.GenericHttpMessageConverter; +import org.springframework.lang.Nullable; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.web.servlet.FlashMap; +import org.springframework.web.servlet.HandlerInterceptor; +import org.springframework.web.servlet.ModelAndView; + +/** + * The default {@link AssertableMvcResult} implementation. + * + * @author Stephane Nicoll + * @since 6.2 + */ +final class DefaultAssertableMvcResult implements AssertableMvcResult { + + @Nullable + private final MvcResult target; + + @Nullable + private final Exception unresolvedException; + + @Nullable + private final GenericHttpMessageConverter jsonMessageConverter; + + DefaultAssertableMvcResult(@Nullable MvcResult target, @Nullable Exception unresolvedException, @Nullable GenericHttpMessageConverter jsonMessageConverter) { + this.target = target; + this.unresolvedException = unresolvedException; + this.jsonMessageConverter = jsonMessageConverter; + } + + /** + * Return the exception that was thrown unexpectedly while processing the + * request, if any. + */ + @Nullable + public Exception getUnresolvedException() { + return this.unresolvedException; + } + + @Override + public MockHttpServletRequest getRequest() { + return getTarget().getRequest(); + } + + @Override + public MockHttpServletResponse getResponse() { + return getTarget().getResponse(); + } + + @Override + public Object getHandler() { + return getTarget().getHandler(); + } + + @Override + public HandlerInterceptor[] getInterceptors() { + return getTarget().getInterceptors(); + } + + @Override + public ModelAndView getModelAndView() { + return getTarget().getModelAndView(); + } + + @Override + public Exception getResolvedException() { + return getTarget().getResolvedException(); + } + + @Override + public FlashMap getFlashMap() { + return getTarget().getFlashMap(); + } + + @Override + public Object getAsyncResult() { + return getTarget().getAsyncResult(); + } + + @Override + public Object getAsyncResult(long timeToWait) { + return getTarget().getAsyncResult(timeToWait); + } + + + private MvcResult getTarget() { + if (this.target == null) { + throw new IllegalStateException( + "Request has failed with unresolved exception " + this.unresolvedException); + } + return this.target; + } + + /** + * Use AssertJ's {@link org.assertj.core.api.Assertions#assertThat assertThat} + * instead. + */ + @Override + public MvcResultAssert assertThat() { + return new MvcResultAssert(this, this.jsonMessageConverter); + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/MvcResultAssert.java b/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/MvcResultAssert.java new file mode 100644 index 000000000000..147ec24792df --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/MvcResultAssert.java @@ -0,0 +1,258 @@ +/* + * 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.web.servlet.assertj; + +import java.io.BufferedReader; +import java.io.PrintWriter; +import java.io.StringReader; +import java.io.StringWriter; + +import jakarta.servlet.http.Cookie; +import org.assertj.core.api.AbstractStringAssert; +import org.assertj.core.api.AbstractThrowableAssert; +import org.assertj.core.api.Assertions; +import org.assertj.core.api.MapAssert; +import org.assertj.core.api.ObjectAssert; +import org.assertj.core.error.BasicErrorMessageFactory; +import org.assertj.core.internal.Failures; + +import org.springframework.http.MediaType; +import org.springframework.http.converter.GenericHttpMessageConverter; +import org.springframework.lang.Nullable; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.test.http.MediaTypeAssert; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.ResultHandler; +import org.springframework.test.web.servlet.ResultMatcher; +import org.springframework.web.servlet.ModelAndView; + +/** + * AssertJ {@link org.assertj.core.api.Assert assertions} that can be applied + * to {@link MvcResult}. + * + * @author Stephane Nicoll + * @author Brian Clozel + * @since 6.2 + */ +public class MvcResultAssert extends AbstractMockHttpServletResponseAssert { + + MvcResultAssert(AssertableMvcResult mvcResult, @Nullable GenericHttpMessageConverter jsonMessageConverter) { + super(jsonMessageConverter, mvcResult, MvcResultAssert.class); + } + + @Override + protected MockHttpServletResponse getResponse() { + checkHasNotFailedUnexpectedly(); + return this.actual.getResponse(); + } + + /** + * Verify that the request has failed with an unresolved exception, and + * return a new {@linkplain AbstractThrowableAssert assertion} object + * that uses the unresolved {@link Exception} as the object to test. + */ + public AbstractThrowableAssert unresolvedException() { + hasUnresolvedException(); + return Assertions.assertThat(this.actual.getUnresolvedException()); + } + + /** + * Return a new {@linkplain AbstractMockHttpServletRequestAssert assertion} + * object that uses the {@link MockHttpServletRequest} as the object to test. + */ + public AbstractMockHttpServletRequestAssert request() { + checkHasNotFailedUnexpectedly(); + return new MockHttpRequestAssert(this.actual.getRequest()); + } + + /** + * Return a new {@linkplain CookieMapAssert assertion} object that uses the + * response's {@linkplain Cookie cookies} as the object to test. + */ + public CookieMapAssert cookies() { + checkHasNotFailedUnexpectedly(); + return new CookieMapAssert(this.actual.getResponse().getCookies()); + } + + /** + * Return a new {@linkplain MediaTypeAssert assertion} object that uses the + * response's {@linkplain MediaType content type} as the object to test. + */ + public MediaTypeAssert contentType() { + checkHasNotFailedUnexpectedly(); + return new MediaTypeAssert(this.actual.getResponse().getContentType()); + } + + /** + * Return a new {@linkplain HandlerResultAssert assertion} object that uses + * the handler as the object to test. For a method invocation on a + * controller, this is relative method handler + * Example:

+	 * // Check that a GET to "/greet" is invoked on a "handleGreet" method name
+	 * assertThat(mvc.perform(get("/greet")).handler().method().hasName("sayGreet");
+	 * 
+ */ + public HandlerResultAssert handler() { + checkHasNotFailedUnexpectedly(); + return new HandlerResultAssert(this.actual.getHandler()); + } + + /** + * Verify that a {@link ModelAndView} is available and return a new + * {@linkplain ModelAssert assertion} object that uses the + * {@linkplain ModelAndView#getModel() model} as the object to test. + */ + public ModelAssert model() { + checkHasNotFailedUnexpectedly(); + return new ModelAssert(getModelAndView().getModel()); + } + + /** + * Verify that a {@link ModelAndView} is available and return a new + * {@linkplain AbstractStringAssert assertion} object that uses the + * {@linkplain ModelAndView#getViewName()} view name} as the object to test. + * @see #hasViewName(String) + */ + public AbstractStringAssert viewName() { + checkHasNotFailedUnexpectedly(); + return Assertions.assertThat(getModelAndView().getViewName()).as("View name"); + } + + /** + * Return a new {@linkplain MapAssert assertion} object that uses the + * "output" flash attributes saved during request processing as the object + * to test. + */ + public MapAssert flash() { + checkHasNotFailedUnexpectedly(); + return new MapAssert<>(this.actual.getFlashMap()); + } + + /** + * Verify that an {@linkplain AbstractHttpServletRequestAssert#hasAsyncStarted(boolean) + * asynchronous processing has started} and return a new + * {@linkplain ObjectAssert assertion} object that uses the asynchronous + * result as the object to test. + */ + public ObjectAssert asyncResult() { + request().hasAsyncStarted(true); + return Assertions.assertThat(this.actual.getAsyncResult()).as("Async result"); + } + + /** + * Verify that the request has failed with an unresolved exception. + * @see #unresolvedException() + */ + public MvcResultAssert hasUnresolvedException() { + Assertions.assertThat(this.actual.getUnresolvedException()) + .withFailMessage("Expecting request to have failed but it has succeeded").isNotNull(); + return this; + } + + /** + * Verify that the request has not failed with an unresolved exception. + */ + public MvcResultAssert doesNotHaveUnresolvedException() { + Assertions.assertThat(this.actual.getUnresolvedException()) + .withFailMessage("Expecting request to have succeeded but it has failed").isNull(); + return this; + } + + /** + * Verify that the actual mvc result matches the given {@link ResultMatcher}. + * @param resultMatcher the result matcher to invoke + */ + public MvcResultAssert matches(ResultMatcher resultMatcher) { + checkHasNotFailedUnexpectedly(); + return super.satisfies(resultMatcher::match); + } + + /** + * Apply the given {@link ResultHandler} to the actual mvc result. + * @param resultHandler the result matcher to invoke + */ + public MvcResultAssert apply(ResultHandler resultHandler) { + checkHasNotFailedUnexpectedly(); + return satisfies(resultHandler::handle); + } + + /** + * Verify that a {@link ModelAndView} is available with a view equals to + * the given one. For more advanced assertions, consider using + * {@link #viewName()} + * @param viewName the expected view name + */ + public MvcResultAssert hasViewName(String viewName) { + viewName().isEqualTo(viewName); + return this.myself; + } + + + private ModelAndView getModelAndView() { + ModelAndView modelAndView = this.actual.getModelAndView(); + Assertions.assertThat(modelAndView).as("ModelAndView").isNotNull(); + return modelAndView; + } + + protected void checkHasNotFailedUnexpectedly() { + Exception unresolvedException = this.actual.getUnresolvedException(); + if (unresolvedException != null) { + throw Failures.instance().failure(this.info, + new RequestFailedUnexpectedly(unresolvedException)); + } + } + + private static final class MockHttpRequestAssert extends AbstractMockHttpServletRequestAssert { + + private MockHttpRequestAssert(MockHttpServletRequest request) { + super(request, MockHttpRequestAssert.class); + } + } + + private static final class RequestFailedUnexpectedly extends BasicErrorMessageFactory { + + private RequestFailedUnexpectedly(Exception ex) { + super("%nRequest has failed unexpectedly:%n%s", unquotedString(getIndentedStackTraceAsString(ex))); + } + + private static String getIndentedStackTraceAsString(Throwable ex) { + String stackTrace = getStackTraceAsString(ex); + return indent(stackTrace); + } + + private static String getStackTraceAsString(Throwable ex) { + StringWriter writer = new StringWriter(); + PrintWriter printer = new PrintWriter(writer); + ex.printStackTrace(printer); + return writer.toString(); + } + + private static String indent(String input) { + BufferedReader reader = new BufferedReader(new StringReader(input)); + StringWriter writer = new StringWriter(); + PrintWriter printer = new PrintWriter(writer); + reader.lines().forEach(line -> { + printer.print(" "); + printer.println(line); + }); + return writer.toString(); + } + + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/AssertableMockMvcIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/AssertableMockMvcIntegrationTests.java new file mode 100644 index 000000000000..201579a993f5 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/AssertableMockMvcIntegrationTests.java @@ -0,0 +1,558 @@ +/* + * 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.web.servlet.assertj; + +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.Callable; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Size; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.core.io.ClassPathResource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Controller; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.test.context.web.WebAppConfiguration; +import org.springframework.test.web.Person; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; +import org.springframework.ui.Model; +import org.springframework.validation.Errors; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.SessionAttributes; +import org.springframework.web.context.WebApplicationContext; +import org.springframework.web.servlet.DispatcherServlet; +import org.springframework.web.servlet.HandlerInterceptor; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; + +import static java.util.Map.entry; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.InstanceOfAssertFactories.map; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Integration tests for {@link AssertableMockMvc}. + * + * @author Brian Clozel + * @author Stephane Nicoll + */ +@SpringJUnitConfig +@WebAppConfiguration +public class AssertableMockMvcIntegrationTests { + + private final AssertableMockMvc mockMvc; + + AssertableMockMvcIntegrationTests(WebApplicationContext wac) { + this.mockMvc = AssertableMockMvc.from(wac); + } + + @Nested + class RequestTests { + + @Test + void hasAsyncStartedTrue() { + assertThat(perform(get("/callable").accept(MediaType.APPLICATION_JSON))) + .request().hasAsyncStarted(true); + } + + @Test + void hasAsyncStartedFalse() { + assertThat(perform(get("/greet"))).request().hasAsyncStarted(false); + } + + @Test + void attributes() { + assertThat(perform(get("/greet"))).request().attributes() + .containsKey(DispatcherServlet.WEB_APPLICATION_CONTEXT_ATTRIBUTE); + } + + @Test + void sessionAttributes() { + assertThat(perform(get("/locale"))).request().sessionAttributes() + .containsOnly(entry("locale", Locale.UK)); + } + } + + @Nested + class CookieTests { + + @Test + void containsCookie() { + Cookie cookie = new Cookie("test", "value"); + assertThat(performWithCookie(cookie, get("/greet"))).cookies().containsCookie("test"); + } + + @Test + void hasValue() { + Cookie cookie = new Cookie("test", "value"); + assertThat(performWithCookie(cookie, get("/greet"))).cookies().hasValue("test", "value"); + } + + private AssertableMvcResult performWithCookie(Cookie cookie, MockHttpServletRequestBuilder request) { + AssertableMockMvc mockMvc = AssertableMockMvc.of(List.of(new TestController()), builder -> builder.addInterceptors( + new HandlerInterceptor() { + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + response.addCookie(cookie); + return true; + } + }).build()); + return mockMvc.perform(request); + } + } + + @Nested + class ContentTypeTests { + + @Test + void contentType() { + assertThat(perform(get("/greet"))).contentType().isCompatibleWith("text/plain"); + } + + } + + @Nested + class StatusTests { + + @Test + void statusOk() { + assertThat(perform(get("/greet"))).hasStatusOk(); + } + + @Test + void statusSeries() { + assertThat(perform(get("/greet"))).hasStatus2xxSuccessful(); + } + + } + + @Nested + class HeadersTests { + + @Test + void shouldAssertHeader() { + assertThat(perform(get("/greet"))).headers() + .hasValue("Content-Type", "text/plain;charset=ISO-8859-1"); + } + + @Test + void shouldAssertHeaderWithCallback() { + assertThat(perform(get("/greet"))).headers().satisfies(textContent("ISO-8859-1")); + } + + private Consumer textContent(String charset) { + return headers -> assertThat(headers).containsEntry( + "Content-Type", List.of("text/plain;charset=%s".formatted(charset))); + } + + } + + @Nested + class ModelAndViewTests { + + @Test + void hasViewName() { + assertThat(perform(get("/persons/{0}", "Andy"))).hasViewName("persons/index"); + } + + @Test + void viewNameWithCustomAssertion() { + assertThat(perform(get("/persons/{0}", "Andy"))).viewName().startsWith("persons"); + } + + @Test + void containsAttributes() { + assertThat(perform(post("/persons").param("name", "Andy"))).model() + .containsOnlyKeys("name").containsEntry("name", "Andy"); + } + + @Test + void hasErrors() { + assertThat(perform(post("/persons"))).model().hasErrors(); + } + + @Test + void hasAttributeErrors() { + assertThat(perform(post("/persons"))).model().hasAttributeErrors("person"); + } + + @Test + void hasAttributeErrorsCount() { + assertThat(perform(post("/persons"))).model().extractingBindingResult("person").hasErrorsCount(1); + } + + } + + @Nested + class FlashTests { + + @Test + void containsAttributes() { + assertThat(perform(post("/persons").param("name", "Andy"))).flash() + .containsOnlyKeys("message").hasEntrySatisfying("message", + value -> assertThat(value).isInstanceOfSatisfying(String.class, + stringValue -> assertThat(stringValue).startsWith("success"))); + } + } + + @Nested + class BodyTests { + + @Test + void asyncResult() { + assertThat(perform(get("/callable").accept(MediaType.APPLICATION_JSON))) + .asyncResult().asInstanceOf(map(String.class, Object.class)) + .containsOnly(entry("key", "value")); + } + + @Test + void stringContent() { + assertThat(perform(get("/greet"))).body().asString().isEqualTo("hello"); + } + + @Test + void jsonPathContent() { + assertThat(perform(get("/message"))).body().jsonPath() + .extractingPath("$.message").asString().isEqualTo("hello"); + } + + @Test + void jsonContentCanLoadResourceFromClasspath() { + assertThat(perform(get("/message"))).body().json().isLenientlyEqualTo( + new ClassPathResource("message.json", AssertableMockMvcIntegrationTests.class)); + } + + @Test + void jsonContentUsingResourceLoaderClass() { + assertThat(perform(get("/message"))).body().json(AssertableMockMvcIntegrationTests.class) + .isLenientlyEqualTo("message.json"); + } + + } + + @Nested + class HandlerTests { + + @Test + void handlerOn404() { + assertThat(perform(get("/unknown-resource"))).handler().isNull(); + } + + @Test + void hasType() { + assertThat(perform(get("/greet"))).handler().hasType(TestController.class); + } + + @Test + void isMethodHandler() { + assertThat(perform(get("/greet"))).handler().isMethodHandler(); + } + + @Test + void isInvokedOn() { + assertThat(perform(get("/callable"))).handler() + .isInvokedOn(AsyncController.class, AsyncController::getCallable); + } + + } + + @Nested + class ExceptionTests { + + @Test + void doesNotHaveUnresolvedException() { + assertThat(perform(get("/greet"))).doesNotHaveUnresolvedException(); + } + + @Test + void hasUnresolvedException() { + assertThat(perform(get("/error/1"))).hasUnresolvedException(); + } + + @Test + void doesNotHaveUnresolvedExceptionWithUnresolvedException() { + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + assertThat(perform(get("/error/1"))).doesNotHaveUnresolvedException()) + .withMessage("Expecting request to have succeeded but it has failed"); + } + + @Test + void hasUnresolvedExceptionWithoutUnresolvedException() { + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + assertThat(perform(get("/greet"))).hasUnresolvedException()) + .withMessage("Expecting request to have failed but it has succeeded"); + } + + @Test + void unresolvedExceptionWithFailedRequest() { + assertThat(perform(get("/error/1"))).unresolvedException() + .isInstanceOf(ServletException.class) + .cause().isInstanceOf(IllegalStateException.class).hasMessage("Expected"); + } + + @Test + void unresolvedExceptionWithSuccessfulRequest() { + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + assertThat(perform(get("/greet"))).unresolvedException()) + .withMessage("Expecting request to have failed but it has succeeded"); + } + + // Check that assertions fail immediately if request has failed with unresolved exception + + @Test + void assertAndApplyWithUnresolvedException() { + testAssertionFailureWithUnresolvableException( + result -> assertThat(result).apply(mvcResult -> {})); + } + + @Test + void assertAsyncResultWithUnresolvedException() { + testAssertionFailureWithUnresolvableException( + result -> assertThat(result).asyncResult()); + } + + @Test + void assertContentTypeWithUnresolvedException() { + testAssertionFailureWithUnresolvableException( + result -> assertThat(result).contentType()); + } + + @Test + void assertCookiesWithUnresolvedException() { + testAssertionFailureWithUnresolvableException( + result -> assertThat(result).cookies()); + } + + @Test + void assertFlashWithUnresolvedException() { + testAssertionFailureWithUnresolvableException( + result -> assertThat(result).flash()); + } + + @Test + void assertStatusWithUnresolvedException() { + testAssertionFailureWithUnresolvableException( + result -> assertThat(result).hasStatus(3)); + } + + @Test + void assertHeaderWithUnresolvedException() { + testAssertionFailureWithUnresolvableException( + result -> assertThat(result).headers()); + } + + @Test + void assertViewNameWithUnresolvedException() { + testAssertionFailureWithUnresolvableException( + result -> assertThat(result).hasViewName("test")); + } + + @Test + void assertForwardedUrlWithUnresolvedException() { + testAssertionFailureWithUnresolvableException( + result -> assertThat(result).hasForwardedUrl("test")); + } + + @Test + void assertRedirectedUrlWithUnresolvedException() { + testAssertionFailureWithUnresolvableException( + result -> assertThat(result).hasRedirectedUrl("test")); + } + + @Test + void assertRequestWithUnresolvedException() { + testAssertionFailureWithUnresolvableException( + result -> assertThat(result).request()); + } + + @Test + void assertModelWithUnresolvedException() { + testAssertionFailureWithUnresolvableException( + result -> assertThat(result).model()); + } + + @Test + void assertBodyWithUnresolvedException() { + testAssertionFailureWithUnresolvableException( + result -> assertThat(result).body()); + } + + + private void testAssertionFailureWithUnresolvableException(Consumer assertions) { + AssertableMvcResult result = perform(get("/error/1")); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertions.accept(result)) + .withMessageContainingAll("Request has failed unexpectedly:", + ServletException.class.getName(), IllegalStateException.class.getName(), + "Expected"); + } + + } + + @Test + void hasForwardUrl() { + assertThat(perform(get("/persons/John"))).hasForwardedUrl("persons/index"); + } + + @Test + void hasRedirectUrl() { + assertThat(perform(post("/persons").param("name", "Andy"))).hasStatus(HttpStatus.FOUND) + .hasRedirectedUrl("/persons/Andy"); + } + + @Test + void satisfiesAllowAdditionalAssertions() { + assertThat(this.mockMvc.perform(get("/greet"))).satisfies(result -> { + assertThat(result).isInstanceOf(MvcResult.class); + assertThat(result).hasStatusOk(); + }); + } + + @Test + void resultMatcherCanBeReused() { + assertThat(this.mockMvc.perform(get("/greet"))).matches(status().isOk()); + } + + @Test + void resultMatcherFailsWithDedicatedException() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(this.mockMvc.perform(get("/greet"))) + .matches(status().isNotFound())) + .withMessageContaining("Status expected:<404> but was:<200>"); + } + + @Test + void shouldApplyResultHandler() { // Spring RESTDocs example + AtomicBoolean applied = new AtomicBoolean(); + assertThat(this.mockMvc.perform(get("/greet"))).apply(result -> applied.set(true)); + assertThat(applied).isTrue(); + } + + + private AssertableMvcResult perform(MockHttpServletRequestBuilder builder) { + return this.mockMvc.perform(builder); + } + + + @Configuration + @EnableWebMvc + @Import({ TestController.class, PersonController.class, AsyncController.class, + SessionController.class, ErrorController.class }) + static class WebConfiguration { + + } + + @RestController + static class TestController { + + @GetMapping(path = "/greet", produces = "text/plain") + String greet() { + return "hello"; + } + + @GetMapping(path = "/message", produces = MediaType.APPLICATION_JSON_VALUE) + String message() { + return "{\"message\": \"hello\"}"; + } + } + + @Controller + @RequestMapping("/persons") + static class PersonController { + + @GetMapping("/{name}") + public String get(@PathVariable String name, Model model) { + model.addAttribute(new Person(name)); + return "persons/index"; + } + + @PostMapping + String create(@Valid Person person, Errors errors, RedirectAttributes redirectAttrs) { + if (errors.hasErrors()) { + return "persons/add"; + } + redirectAttrs.addAttribute("name", person.getName()); + redirectAttrs.addFlashAttribute("message", "success!"); + return "redirect:/persons/{name}"; + } + } + + + @RestController + static class AsyncController { + + @GetMapping("/callable") + public Callable> getCallable() { + return () -> Collections.singletonMap("key", "value"); + } + } + + @Controller + @SessionAttributes("locale") + private static class SessionController { + + @ModelAttribute + void populate(Model model) { + model.addAttribute("locale", Locale.UK); + } + + @RequestMapping("/locale") + String handle() { + return "view"; + } + } + + @Controller + private static class ErrorController { + + @GetMapping("/error/1") + public String one() { + throw new IllegalStateException("Expected"); + } + + @GetMapping("/error/validation/{id}") + public String validation(@PathVariable @Size(max = 4) String id) { + return "Hello " + id; + } + + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/AssertableMockMvcTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/AssertableMockMvcTests.java new file mode 100644 index 000000000000..c8548e7a5d4f --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/AssertableMockMvcTests.java @@ -0,0 +1,210 @@ +/* + * 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.web.servlet.assertj; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.ServletException; +import org.junit.jupiter.api.Test; + +import org.springframework.context.annotation.AnnotationConfigUtils; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.converter.GenericHttpMessageConverter; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.mock.web.MockServletContext; +import org.springframework.test.json.JsonPathAssert; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.context.support.GenericWebApplicationContext; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; + +/** + * Tests for {@link AssertableMockMvc}. + * + * @author Stephane Nicoll + */ +class AssertableMockMvcTests { + + private static final MappingJackson2HttpMessageConverter jsonHttpMessageConverter = + new MappingJackson2HttpMessageConverter(new ObjectMapper()); + + @Test + void createShouldRejectNullMockMvc() { + assertThatThrownBy(() -> AssertableMockMvc.create(null)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void createWithExistingWebApplicationContext() { + try (GenericWebApplicationContext wac = create(WebConfiguration.class)) { + AssertableMockMvc mockMvc = AssertableMockMvc.from(wac); + assertThat(mockMvc.perform(post("/increase"))).body().isEqualTo("counter 41"); + assertThat(mockMvc.perform(post("/increase"))).body().isEqualTo("counter 42"); + } + } + + @Test + void createWithControllerClassShouldInstantiateControllers() { + AssertableMockMvc mockMvc = AssertableMockMvc.of(HelloController.class, CounterController.class); + assertThat(mockMvc.perform(get("/hello"))).body().isEqualTo("Hello World"); + assertThat(mockMvc.perform(post("/increase"))).body().isEqualTo("counter 1"); + assertThat(mockMvc.perform(post("/increase"))).body().isEqualTo("counter 2"); + } + + @Test + void createWithControllersShouldUseThemAsIs() { + AssertableMockMvc mockMvc = AssertableMockMvc.of(new HelloController(), + new CounterController(new AtomicInteger(41))); + assertThat(mockMvc.perform(get("/hello"))).body().isEqualTo("Hello World"); + assertThat(mockMvc.perform(post("/increase"))).body().isEqualTo("counter 42"); + assertThat(mockMvc.perform(post("/increase"))).body().isEqualTo("counter 43"); + } + + @Test + void createWithControllerAndCustomizations() { + AssertableMockMvc mockMvc = AssertableMockMvc.of(List.of(new HelloController()), builder -> + builder.defaultRequest(get("/hello").accept(MediaType.APPLICATION_JSON)).build()); + assertThat(mockMvc.perform(get("/hello"))).hasStatus(HttpStatus.NOT_ACCEPTABLE); + } + + @Test + void createWithControllersHasNoHttpMessageConverter() { + AssertableMockMvc mockMvc = AssertableMockMvc.of(new HelloController()); + JsonPathAssert jsonPathAssert = assertThat(mockMvc.perform(get("/json"))).hasStatusOk().body().jsonPath(); + assertThatIllegalStateException() + .isThrownBy(() -> jsonPathAssert.extractingPath("$").convertTo(Message.class)) + .withMessageContaining("No JSON message converter available"); + } + + @Test + void createWithControllerCanConfigureHttpMessageConverters() { + AssertableMockMvc mockMvc = AssertableMockMvc.of(HelloController.class) + .withHttpMessageConverters(List.of(jsonHttpMessageConverter)); + assertThat(mockMvc.perform(get("/json"))).hasStatusOk().body().jsonPath() + .extractingPath("$").convertTo(Message.class).satisfies(message -> { + assertThat(message.message()).isEqualTo("Hello World"); + assertThat(message.counter()).isEqualTo(42); + }); + } + + @Test + @SuppressWarnings("unchecked") + void withHttpMessageConverterDetectsJsonConverter() { + MappingJackson2HttpMessageConverter converter = spy(jsonHttpMessageConverter); + AssertableMockMvc mockMvc = AssertableMockMvc.of(HelloController.class) + .withHttpMessageConverters(List.of(mock(GenericHttpMessageConverter.class), + mock(GenericHttpMessageConverter.class), converter)); + assertThat(mockMvc.perform(get("/json"))).hasStatusOk().body().jsonPath() + .extractingPath("$").convertTo(Message.class).satisfies(message -> { + assertThat(message.message()).isEqualTo("Hello World"); + assertThat(message.counter()).isEqualTo(42); + }); + verify(converter).canWrite(Map.class, MediaType.APPLICATION_JSON); + } + + @Test + void performWithUnresolvedExceptionSetsException() { + AssertableMockMvc mockMvc = AssertableMockMvc.of(HelloController.class); + AssertableMvcResult result = mockMvc.perform(get("/error")); + assertThat(result.getUnresolvedException()).isNotNull().isInstanceOf(ServletException.class) + .cause().isInstanceOf(IllegalStateException.class).hasMessage("Expected"); + assertThat(result).hasFieldOrPropertyWithValue("target", null); + } + + private GenericWebApplicationContext create(Class... classes) { + GenericWebApplicationContext applicationContext = new GenericWebApplicationContext( + new MockServletContext()); + AnnotationConfigUtils.registerAnnotationConfigProcessors(applicationContext); + for (Class beanClass : classes) { + applicationContext.registerBean(beanClass); + } + applicationContext.refresh(); + return applicationContext; + } + + @Configuration(proxyBeanMethods = false) + @EnableWebMvc + static class WebConfiguration { + + @Bean + CounterController counterController() { + return new CounterController(new AtomicInteger(40)); + } + } + + + @RestController + private static class HelloController { + + @GetMapping(path = "/hello", produces = "text/plain") + public String hello() { + return "Hello World"; + } + + @GetMapping("/error") + public String error() { + throw new IllegalStateException("Expected"); + } + + @GetMapping(path = "/json", produces = "application/json") + public String json() { + return """ + { + "message": "Hello World", + "counter": 42 + }"""; + } + } + + private record Message(String message, int counter) {} + + @RestController + private static class CounterController { + + private final AtomicInteger counter; + + public CounterController(AtomicInteger counter) { + this.counter = counter; + } + + public CounterController() { + this(new AtomicInteger()); + } + + @PostMapping("/increase") + public String increase() { + int value = this.counter.incrementAndGet(); + return "counter " + value; + } + } +} diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/DefaultAssertableMvcResultTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/DefaultAssertableMvcResultTests.java new file mode 100644 index 000000000000..f59a53521d94 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/DefaultAssertableMvcResultTests.java @@ -0,0 +1,107 @@ +/* + * 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.web.servlet.assertj; + +import java.util.function.Consumer; + +import org.junit.jupiter.api.Test; + +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.test.web.servlet.MvcResult; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +/** + * Tests for {@link DefaultAssertableMvcResult}. + * + * @author Stephane Nicoll + */ +class DefaultAssertableMvcResultTests { + + @Test + void createWithMvcResultDelegatesToIt() { + MockHttpServletRequest request = new MockHttpServletRequest(); + MvcResult mvcResult = mock(MvcResult.class); + given(mvcResult.getRequest()).willReturn(request); + DefaultAssertableMvcResult result = new DefaultAssertableMvcResult(mvcResult, null, null); + assertThat(result.getRequest()).isSameAs(request); + verify(mvcResult).getRequest(); + } + + @Test + void createWithExceptionDoesNotAllowAccessToRequest() { + assertRequestHasFailed(DefaultAssertableMvcResult::getRequest); + } + + @Test + void createWithExceptionDoesNotAllowAccessToResponse() { + assertRequestHasFailed(DefaultAssertableMvcResult::getResponse); + } + + @Test + void createWithExceptionDoesNotAllowAccessToHandler() { + assertRequestHasFailed(DefaultAssertableMvcResult::getHandler); + } + + @Test + void createWithExceptionDoesNotAllowAccessToInterceptors() { + assertRequestHasFailed(DefaultAssertableMvcResult::getInterceptors); + } + + @Test + void createWithExceptionDoesNotAllowAccessToModelAndView() { + assertRequestHasFailed(DefaultAssertableMvcResult::getModelAndView); + } + + @Test + void createWithExceptionDoesNotAllowAccessToResolvedException() { + assertRequestHasFailed(DefaultAssertableMvcResult::getResolvedException); + } + + @Test + void createWithExceptionDoesNotAllowAccessToFlashMap() { + assertRequestHasFailed(DefaultAssertableMvcResult::getFlashMap); + } + + @Test + void createWithExceptionDoesNotAllowAccessToAsyncResult() { + assertRequestHasFailed(DefaultAssertableMvcResult::getAsyncResult); + } + + @Test + void createWithExceptionDoesNotAllowAccessToAsyncResultWithTimeToWait() { + assertRequestHasFailed(result -> result.getAsyncResult(1000)); + } + + @Test + void createWithExceptionReturnsException() { + IllegalStateException exception = new IllegalStateException("Expected"); + DefaultAssertableMvcResult result = new DefaultAssertableMvcResult(null, exception, null); + assertThat(result.getUnresolvedException()).isSameAs(exception); + } + + private void assertRequestHasFailed(Consumer action) { + DefaultAssertableMvcResult result = new DefaultAssertableMvcResult(null, new IllegalStateException("Expected"), null); + assertThatIllegalStateException().isThrownBy(() -> action.accept(result)) + .withMessageContaining("Request has failed with unresolved exception"); + } + +} diff --git a/spring-test/src/test/resources/org/springframework/test/web/servlet/assertj/message.json b/spring-test/src/test/resources/org/springframework/test/web/servlet/assertj/message.json new file mode 100644 index 000000000000..ff89222db782 --- /dev/null +++ b/spring-test/src/test/resources/org/springframework/test/web/servlet/assertj/message.json @@ -0,0 +1,3 @@ +{ + "message": "hello" +} \ No newline at end of file