From b46e5289229abace0fb892f4cbb501ea241ebe50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Nicoll?= Date: Fri, 15 Mar 2024 13:25:03 +0100 Subject: [PATCH] Add AssertJ support for the HTTP handler This commit adds AssertJ compatible assertions for the component that produces the result from the request. See gh-21178 --- .../test/util/MethodAssert.java | 62 ++++++++ .../servlet/assertj/HandlerResultAssert.java | 120 +++++++++++++++ .../test/util/MethodAssertTests.java | 92 ++++++++++++ .../assertj/HandlerResultAssertTests.java | 142 ++++++++++++++++++ 4 files changed, 416 insertions(+) create mode 100644 spring-test/src/main/java/org/springframework/test/util/MethodAssert.java create mode 100644 spring-test/src/main/java/org/springframework/test/web/servlet/assertj/HandlerResultAssert.java create mode 100644 spring-test/src/test/java/org/springframework/test/util/MethodAssertTests.java create mode 100644 spring-test/src/test/java/org/springframework/test/web/servlet/assertj/HandlerResultAssertTests.java diff --git a/spring-test/src/main/java/org/springframework/test/util/MethodAssert.java b/spring-test/src/main/java/org/springframework/test/util/MethodAssert.java new file mode 100644 index 000000000000..a346aa6a548f --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/util/MethodAssert.java @@ -0,0 +1,62 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.test.util; + +import java.lang.reflect.Method; + +import org.assertj.core.api.AbstractObjectAssert; +import org.assertj.core.api.Assertions; + +import org.springframework.lang.Nullable; + +/** + * AssertJ {@link org.assertj.core.api.Assert assertions} that can be applied + * to a {@link Method}. + * + * @author Stephane Nicoll + * @since 6.2 + */ +public class MethodAssert extends AbstractObjectAssert { + + public MethodAssert(@Nullable Method actual) { + super(actual, MethodAssert.class); + as("Method %s", actual); + } + + /** + * Verify that the actual method has the given {@linkplain Method#getName() + * name}. + * @param name the expected method name + */ + public MethodAssert hasName(String name) { + isNotNull(); + Assertions.assertThat(this.actual.getName()).as("Method name").isEqualTo(name); + return this.myself; + } + + /** + * Verify that the actual method is declared in the given {@code type}. + * @param type the expected declaring class + */ + public MethodAssert hasDeclaringClass(Class type) { + isNotNull(); + Assertions.assertThat(this.actual.getDeclaringClass()) + .as("Method declaring class").isEqualTo(type); + return this.myself; + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/HandlerResultAssert.java b/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/HandlerResultAssert.java new file mode 100644 index 000000000000..2be4797fe3e6 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/HandlerResultAssert.java @@ -0,0 +1,120 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.test.web.servlet.assertj; + +import java.lang.reflect.Method; + +import org.assertj.core.api.AbstractObjectAssert; +import org.assertj.core.api.Assertions; + +import org.springframework.cglib.core.internal.Function; +import org.springframework.lang.Nullable; +import org.springframework.test.util.MethodAssert; +import org.springframework.util.ClassUtils; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder; +import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder.MethodInvocationInfo; + +/** + * AssertJ {@link org.assertj.core.api.Assert assertions} that can be applied to + * a handler or handler method. + + * @author Stephane Nicoll + * @since 6.2 + */ +public class HandlerResultAssert extends AbstractObjectAssert { + + public HandlerResultAssert(@Nullable Object actual) { + super(actual, HandlerResultAssert.class); + as("Handler result"); + } + + /** + * Return a new {@linkplain MethodAssert assertion} object that uses + * the {@link Method} that handles the request as the object to test. + * Verify first that the handler is a {@linkplain #isMethodHandler() method + * handler}. + * Example:

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

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

+	 * // Check that a GET to "/greet" is managed by GreetController
+	 * assertThat(mvc.perform(get("/greet")).handler().hasType(GreetController.class);
+	 * 
+ * @param type the expected type of the handler + */ + public HandlerResultAssert hasType(Class type) { + isNotNull(); + Class actualType = this.actual.getClass(); + if (this.actual instanceof HandlerMethod handlerMethod) { + actualType = handlerMethod.getBeanType(); + } + Assertions.assertThat(ClassUtils.getUserClass(actualType)).as("Handler result type").isEqualTo(type); + return this; + } + + private Method getHandlerMethod() { + isMethodHandler(); // validate type + return ((HandlerMethod) this.actual).getMethod(); + } + + +} diff --git a/spring-test/src/test/java/org/springframework/test/util/MethodAssertTests.java b/spring-test/src/test/java/org/springframework/test/util/MethodAssertTests.java new file mode 100644 index 000000000000..17f294d4edaf --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/util/MethodAssertTests.java @@ -0,0 +1,92 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.test.util; + +import java.lang.reflect.Method; + +import org.junit.jupiter.api.Test; + +import org.springframework.lang.Nullable; +import org.springframework.util.ReflectionUtils; + +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +/** + * Tests for {@link MethodAssert}. + * + * @author Stephane Nicoll + */ +class MethodAssertTests { + + @Test + void isEqualTo() { + Method method = ReflectionUtils.findMethod(TestData.class, "counter"); + assertThat(method).isEqualTo(method); + } + + @Test + void hasName() { + assertThat(ReflectionUtils.findMethod(TestData.class, "counter")).hasName("counter"); + } + + @Test + void hasNameWithWrongName() { + Method method = ReflectionUtils.findMethod(TestData.class, "counter"); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(method).hasName("invalid")) + .withMessageContainingAll("Method name", "counter", "invalid"); + } + + @Test + void hasNameWithNullMethod() { + Method method = ReflectionUtils.findMethod(TestData.class, "notAMethod"); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(method).hasName("name")) + .withMessageContaining("Expecting actual not to be null"); + } + + @Test + void hasDeclaringClass() { + assertThat(ReflectionUtils.findMethod(TestData.class, "counter")).hasDeclaringClass(TestData.class); + } + + @Test + void haDeclaringClassWithWrongClass() { + Method method = ReflectionUtils.findMethod(TestData.class, "counter"); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(method).hasDeclaringClass(Method.class)) + .withMessageContainingAll("Method declaring class", + TestData.class.getCanonicalName(), Method.class.getCanonicalName()); + } + + @Test + void hasDeclaringClassWithNullMethod() { + Method method = ReflectionUtils.findMethod(TestData.class, "notAMethod"); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(method).hasDeclaringClass(TestData.class)) + .withMessageContaining("Expecting actual not to be null"); + } + + + private MethodAssert assertThat(@Nullable Method method) { + return new MethodAssert(method); + } + + + record TestData(String name, int counter) {} + +} diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/HandlerResultAssertTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/HandlerResultAssertTests.java new file mode 100644 index 000000000000..882ad0a2c3e0 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/HandlerResultAssertTests.java @@ -0,0 +1,142 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.test.web.servlet.assertj; + +import java.lang.reflect.Method; + +import org.assertj.core.api.AssertProvider; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; + +import org.springframework.http.ResponseEntity; +import org.springframework.util.ReflectionUtils; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.resource.DefaultServletHttpRequestHandler; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +/** + * Tests for {@link HandlerResultAssert}. + * + * @author Stephane Nicoll + */ +class HandlerResultAssertTests { + + @Test + void hasTypeUseController() { + assertThat(handlerMethod(new TestController(), "greet")).hasType(TestController.class); + } + + @Test + void isMethodHandlerWithMethodHandler() { + assertThat(handlerMethod(new TestController(), "greet")).isMethodHandler(); + } + + @Test + void isMethodHandlerWithServletHandler() { + AssertProvider actual = handler(new DefaultServletHttpRequestHandler()); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(actual).isMethodHandler()) + .withMessageContainingAll(DefaultServletHttpRequestHandler.class.getName(), + HandlerMethod.class.getName()); + } + + @Test + void methodName() { + assertThat(handlerMethod(new TestController(), "greet")).method().hasName("greet"); + } + + @Test + void declaringClass() { + assertThat(handlerMethod(new TestController(), "greet")).method().hasDeclaringClass(TestController.class); + } + + @Test + void method() { + assertThat(handlerMethod(new TestController(), "greet")).method().isEqualTo( + ReflectionUtils.findMethod(TestController.class, "greet")); + } + + @Test + void methodWithServletHandler() { + AssertProvider actual = handler(new DefaultServletHttpRequestHandler()); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(actual).method()) + .withMessageContainingAll(DefaultServletHttpRequestHandler.class.getName(), + HandlerMethod.class.getName()); + } + + @Test + void isInvokedOn() { + assertThat(handlerMethod(new TestController(), "greet")) + .isInvokedOn(TestController.class, TestController::greet); + } + + @Test + void isInvokedOnWithVoidMethod() { + assertThat(handlerMethod(new TestController(), "update")) + .isInvokedOn(TestController.class, controller -> { + controller.update(); + return controller; + }); + } + + @Test + void isInvokedOnWithWrongMethod() { + AssertProvider actual = handlerMethod(new TestController(), "update"); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(actual).isInvokedOn(TestController.class, TestController::greet)) + .withMessageContainingAll( + method(TestController.class, "greet").toGenericString(), + method(TestController.class, "update").toGenericString()); + } + + + private static AssertProvider handler(Object instance) { + return () -> new HandlerResultAssert(instance); + } + + private static AssertProvider handlerMethod(Object instance, String name, Class... parameterTypes) { + HandlerMethod handlerMethod = new HandlerMethod(instance, method(instance.getClass(), name, parameterTypes)); + return () -> new HandlerResultAssert(handlerMethod); + } + + private static Method method(Class target, String name, Class... parameterTypes) { + Method method = ReflectionUtils.findMethod(target, name, parameterTypes); + Assertions.assertThat(method).isNotNull(); + return method; + } + + @RestController + public static class TestController { + + @GetMapping("/greet") + public ResponseEntity greet() { + return ResponseEntity.ok().body("Hello"); + } + + @PostMapping("/update") + public void update() { + } + + } + +}