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/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/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: + * + * 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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); + } + +} 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/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/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/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/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); + } + +} 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/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() { + } + + } + +} 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()); + } + } + +} 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); + } + +} 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": { + } +} 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