From 444a9bd0117cb5bec82b689e3f4e4212774ad07d Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Thu, 30 Jun 2022 18:20:22 +0200 Subject: [PATCH] Add testing infrastructure for RuntimeHintsAgent This commit adds the supporting testing infrastructure using the `RuntimeHintsAgent`. Given that the agent is loaded by the JVM running the test suite, we can then use it to record method invocations at runtime and check whether the prepared `RuntimeHints` match the expected behavior. This commit contributes the `RuntimeHintsRecorder`. With this, we can record relevant method invocations for a given lambda, focusing on a specific part of the code behavior. This returns a `RuntimeHintsInvocations` instance, which is an AssertJ assert provider. From there, we can perform assertions on the recorded invocations and check that a given collection of hints cover the reflection, resources and proxies needs at runtime. This also ships the `@EnabledIfRuntimeHintsAgent` opinionated annotation: this applies the `RuntimeHintsAgentCondition` JUnit extension that detects whether the `RuntimeHintsAgent` is loaded by the current JVM. Tests annotated with this will be skipped if the agent is not present. This annotation is also tagged with a JUnit `@Tag` to gather such tests in a specific `"RuntimeHintsTests"` test suite. In the Spring Framework build, we have chosen to isolate such tests and not load the agent for the main test suite ("RuntimeHintsTests" tests are excluded from the main suite). While the agent's intent is to be as transparent as possible, there are security and access considerations that could interefere with other tests. With this approach, we can then create a separate test suite and run agent tests in a dedicated JVM. Note that projects using this infrastructure can choose to use the condition by itself in a custom annotation. Here is an example of this testing infrastructure: ``` @EnabledIfRuntimeHintsAgent class MyTestCases { @Test void hintsForMethodsReflectionShouldMatch() { RuntimeHints hints = new RuntimeHints(); hints.reflection().registerType(String.class, hint -> hint.withMembers(MemberCategory.INTROSPECT_PUBLIC_METHODS)); RuntimeHintsInvocations invocations = RuntimeHintsRecorder.record(() -> { Method[] methods = String.class.getMethods(); }); assertThat(invocations).match(hints); } } ``` See gh-27981 --- .../agent/EnabledIfRuntimeHintsAgent.java | 63 ++++++++++ .../agent/RuntimeHintsAgentCondition.java | 46 +++++++ .../test/agent/RuntimeHintsInvocations.java | 50 ++++++++ .../agent/RuntimeHintsInvocationsAssert.java | 119 ++++++++++++++++++ .../aot/test/agent/RuntimeHintsRecorder.java | 76 +++++++++++ .../aot/test/agent/package-info.java | 9 ++ src/checkstyle/checkstyle-suppressions.xml | 1 + 7 files changed, 364 insertions(+) create mode 100644 spring-core-test/src/main/java/org/springframework/aot/test/agent/EnabledIfRuntimeHintsAgent.java create mode 100644 spring-core-test/src/main/java/org/springframework/aot/test/agent/RuntimeHintsAgentCondition.java create mode 100644 spring-core-test/src/main/java/org/springframework/aot/test/agent/RuntimeHintsInvocations.java create mode 100644 spring-core-test/src/main/java/org/springframework/aot/test/agent/RuntimeHintsInvocationsAssert.java create mode 100644 spring-core-test/src/main/java/org/springframework/aot/test/agent/RuntimeHintsRecorder.java create mode 100644 spring-core-test/src/main/java/org/springframework/aot/test/agent/package-info.java diff --git a/spring-core-test/src/main/java/org/springframework/aot/test/agent/EnabledIfRuntimeHintsAgent.java b/spring-core-test/src/main/java/org/springframework/aot/test/agent/EnabledIfRuntimeHintsAgent.java new file mode 100644 index 00000000000..36ef2803b43 --- /dev/null +++ b/spring-core-test/src/main/java/org/springframework/aot/test/agent/EnabledIfRuntimeHintsAgent.java @@ -0,0 +1,63 @@ +/* + * Copyright 2002-2022 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.aot.test.agent; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.aot.agent.RuntimeHintsAgent; + +/** + * {@code @EneabledIfRuntimeHintsAgent} signals that the annotated test class or test method + * is only enabled if the {@link RuntimeHintsAgent} is loaded on the current JVM. + * + *
+ * @EnabledIfRuntimeHintsAgent
+ * class MyTestCases {
+ *
+ *     @Test
+ *     void hintsForMethodsReflectionShouldMatch() {
+ *         RuntimeHints hints = new RuntimeHints();
+ *         hints.reflection().registerType(String.class,
+ *             hint -> hint.withMembers(MemberCategory.INTROSPECT_PUBLIC_METHODS));
+ *
+ *         RuntimeHintsInvocations invocations = RuntimeHintsRecorder.record(() -> {
+ *             Method[] methods = String.class.getMethods();
+ *         });
+ *         assertThat(invocations).match(hints);
+ *     }
+ *
+ * }
+ * 
+ * + * @author Brian Clozel + * @since 6.0 + */ +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@ExtendWith(RuntimeHintsAgentCondition.class) +@Tag("RuntimeHintsTests") +public @interface EnabledIfRuntimeHintsAgent { + +} diff --git a/spring-core-test/src/main/java/org/springframework/aot/test/agent/RuntimeHintsAgentCondition.java b/spring-core-test/src/main/java/org/springframework/aot/test/agent/RuntimeHintsAgentCondition.java new file mode 100644 index 00000000000..9dfcddb7d73 --- /dev/null +++ b/spring-core-test/src/main/java/org/springframework/aot/test/agent/RuntimeHintsAgentCondition.java @@ -0,0 +1,46 @@ +/* + * Copyright 2002-2022 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.aot.test.agent; + +import org.junit.jupiter.api.extension.ConditionEvaluationResult; +import org.junit.jupiter.api.extension.ExecutionCondition; +import org.junit.jupiter.api.extension.ExtensionContext; + +import org.springframework.aot.agent.RuntimeHintsAgent; + +import static org.junit.platform.commons.util.AnnotationUtils.findAnnotation; + +/** + * {@link ExecutionCondition} for {@link EnabledIfRuntimeHintsAgent @EnabledIfRuntimeHintsAgent}. + * + * @author Brian Clozel + */ +public class RuntimeHintsAgentCondition implements ExecutionCondition { + + + @Override + public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext context) { + return findAnnotation(context.getElement(), EnabledIfRuntimeHintsAgent.class) + .map(annotation -> checkRuntimeHintsAgentPresence()) + .orElse(ConditionEvaluationResult.enabled("@RuntimeHintsTest is not present")); + } + + static ConditionEvaluationResult checkRuntimeHintsAgentPresence() { + return RuntimeHintsAgent.isLoaded() ? ConditionEvaluationResult.enabled("RuntimeHintsAgent is loaded") + : ConditionEvaluationResult.disabled("RuntimeHintsAgent is not loaded on the current JVM"); + } +} diff --git a/spring-core-test/src/main/java/org/springframework/aot/test/agent/RuntimeHintsInvocations.java b/spring-core-test/src/main/java/org/springframework/aot/test/agent/RuntimeHintsInvocations.java new file mode 100644 index 00000000000..753d0186dcb --- /dev/null +++ b/spring-core-test/src/main/java/org/springframework/aot/test/agent/RuntimeHintsInvocations.java @@ -0,0 +1,50 @@ +/* + * Copyright 2002-2022 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.aot.test.agent; + +import java.util.List; +import java.util.stream.Stream; + +import org.assertj.core.api.AssertProvider; + +import org.springframework.aot.agent.RecordedInvocation; + +/** + * A wrapper for {@link RecordedInvocation} that is the starting point for {@code RuntimeHints} AssertJ assertions. + * + * @author Brian Clozel + * @since 6.0 + * @see RuntimeHintsInvocationsAssert + */ +public class RuntimeHintsInvocations implements AssertProvider { + + private final List invocations; + + RuntimeHintsInvocations(List invocations) { + this.invocations = invocations; + } + + @Override + public RuntimeHintsInvocationsAssert assertThat() { + return new RuntimeHintsInvocationsAssert(this); + } + + Stream recordedInvocations() { + return this.invocations.stream(); + } + +} diff --git a/spring-core-test/src/main/java/org/springframework/aot/test/agent/RuntimeHintsInvocationsAssert.java b/spring-core-test/src/main/java/org/springframework/aot/test/agent/RuntimeHintsInvocationsAssert.java new file mode 100644 index 00000000000..c29e6aa0508 --- /dev/null +++ b/spring-core-test/src/main/java/org/springframework/aot/test/agent/RuntimeHintsInvocationsAssert.java @@ -0,0 +1,119 @@ +/* + * Copyright 2002-2022 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.aot.test.agent; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.assertj.core.api.AbstractAssert; +import org.assertj.core.error.BasicErrorMessageFactory; +import org.assertj.core.error.ErrorMessageFactory; + +import org.springframework.aot.agent.RecordedInvocation; +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.aot.hint.RuntimeHintsRegistrar; +import org.springframework.core.io.support.SpringFactoriesLoader; +import org.springframework.util.Assert; + +/** + * AssertJ {@link org.assertj.core.api.Assert assertions} that can be applied to an {@link RuntimeHintsInvocations}. + * + * @author Brian Clozel + * @since 6.0 + */ +public class RuntimeHintsInvocationsAssert extends AbstractAssert { + + + List> configurers = new ArrayList<>(); + + RuntimeHintsInvocationsAssert(RuntimeHintsInvocations invocations) { + super(invocations, RuntimeHintsInvocationsAssert.class); + } + + public RuntimeHintsInvocationsAssert withRegistrar(RuntimeHintsRegistrar registrar) { + this.configurers.add(hints -> registrar.registerHints(hints, getClass().getClassLoader())); + return this; + } + + public RuntimeHintsInvocationsAssert withSpringFactoriesRegistrars(String location) { + List registrars = SpringFactoriesLoader.forResourceLocation(location).load(RuntimeHintsRegistrar.class); + this.configurers.add(hints -> registrars.forEach(registrar -> registrar.registerHints(hints, getClass().getClassLoader()))); + return this; + } + + private void configureRuntimeHints(RuntimeHints hints) { + this.configurers.forEach(configurer -> configurer.accept(hints)); + } + + /** + * Verifies that each recorded invocation match at least once hint in the provided {@link RuntimeHints}. + *

+ * Example:

+	 * RuntimeHints hints = new RuntimeHints();
+	 * hints.reflection().registerType(MyType.class);
+	 * assertThat(invocations).allMatch(hints); 
+ * @param runtimeHints the runtime hints configuration to test against + * @throws AssertionError if any of the recorded invocations has no match in the provided hints + */ + public void match(RuntimeHints runtimeHints) { + Assert.notNull(runtimeHints, "RuntimeHints should not be null"); + configureRuntimeHints(runtimeHints); + List noMatchInvocations = + this.actual.recordedInvocations().filter(invocation -> !invocation.matches(runtimeHints)).toList(); + if (!noMatchInvocations.isEmpty()) { + throwAssertionError(errorMessageForInvocation(noMatchInvocations.get(0))); + } + } + + + private ErrorMessageFactory errorMessageForInvocation(RecordedInvocation invocation) { + return new BasicErrorMessageFactory("%nMissing <%s> for invocation <%s> on type <%s> %nwith arguments %s.%nStacktrace:%n<%s>", + invocation.getHintType().hintClassName(), invocation.getMethodReference(), + invocation.getInstanceTypeReference(), invocation.getArguments(), + formatStackTrace(invocation.getStackFrames())); + } + + private String formatStackTrace(Stream stackTraceElements) { + return stackTraceElements + .map(f -> f.getClassName() + "#" + f.getMethodName() + + ", Line " + f.getLineNumber()).collect(Collectors.joining(System.lineSeparator())); + } + + /** + * Verifies that the count of recorded invocations match the expected one. + *

+ * Example:

+	 * assertThat(invocations).hasCount(42); 
+ * @param count the expected invocations count + * @return {@code this} assertion object. + * @throws AssertionError if the number of recorded invocations doesn't match the expected one + */ + public RuntimeHintsInvocationsAssert hasCount(long count) { + isNotNull(); + long invocationsCount = this.actual.recordedInvocations().count(); + if(invocationsCount != count) { + throwAssertionError(new BasicErrorMessageFactory("%nNumber of recorded invocations does not match, expected <%n> but got <%n>.", + invocationsCount, count)); + } + return this; + } + + +} diff --git a/spring-core-test/src/main/java/org/springframework/aot/test/agent/RuntimeHintsRecorder.java b/spring-core-test/src/main/java/org/springframework/aot/test/agent/RuntimeHintsRecorder.java new file mode 100644 index 00000000000..abad3a85528 --- /dev/null +++ b/spring-core-test/src/main/java/org/springframework/aot/test/agent/RuntimeHintsRecorder.java @@ -0,0 +1,76 @@ +/* + * Copyright 2002-2022 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.aot.test.agent; + +import java.util.ArrayDeque; +import java.util.Deque; + +import org.springframework.aot.agent.RecordedInvocation; +import org.springframework.aot.agent.RecordedInvocationsListener; +import org.springframework.aot.agent.RecordedInvocationsPublisher; +import org.springframework.aot.agent.RuntimeHintsAgent; +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.util.Assert; + +/** + * Invocations relevant to {@link RuntimeHints} recorded during the execution of a block + * of code instrumented by the {@link RuntimeHintsAgent}. + * + * @author Brian Clozel + * @since 6.0 + */ +public final class RuntimeHintsRecorder { + + private final RuntimeHintsInvocationsListener listener; + + private RuntimeHintsRecorder() { + this.listener = new RuntimeHintsInvocationsListener(); + } + + /** + * Record all method invocations relevant to {@link RuntimeHints} that happened + * during the execution of the given action. + * @param action the block of code we want to record invocations from + * @return the recorded invocations + */ + public synchronized static RuntimeHintsInvocations record(Runnable action) { + Assert.notNull(action, "Runnable action should not be null"); + Assert.isTrue(RuntimeHintsAgent.isLoaded(), "RuntimeHintsAgent should be loaded in the current JVM"); + RuntimeHintsRecorder recorder = new RuntimeHintsRecorder(); + RecordedInvocationsPublisher.addListener(recorder.listener); + try { + action.run(); + } + finally { + RecordedInvocationsPublisher.removeListener(recorder.listener); + } + return new RuntimeHintsInvocations(recorder.listener.recordedInvocations.stream().toList()); + } + + + private static final class RuntimeHintsInvocationsListener implements RecordedInvocationsListener { + + private final Deque recordedInvocations = new ArrayDeque<>(); + + @Override + public void onInvocation(RecordedInvocation invocation) { + this.recordedInvocations.addLast(invocation); + } + + } + +} diff --git a/spring-core-test/src/main/java/org/springframework/aot/test/agent/package-info.java b/spring-core-test/src/main/java/org/springframework/aot/test/agent/package-info.java new file mode 100644 index 00000000000..dc7ecdddfd6 --- /dev/null +++ b/spring-core-test/src/main/java/org/springframework/aot/test/agent/package-info.java @@ -0,0 +1,9 @@ +/** + * Testing support for the {@link org.springframework.aot.agent.RuntimeHintsAgent}. + */ +@NonNullApi +@NonNullFields +package org.springframework.aot.test.agent; + +import org.springframework.lang.NonNullApi; +import org.springframework.lang.NonNullFields; diff --git a/src/checkstyle/checkstyle-suppressions.xml b/src/checkstyle/checkstyle-suppressions.xml index 41d0560396d..24bbed66a8e 100644 --- a/src/checkstyle/checkstyle-suppressions.xml +++ b/src/checkstyle/checkstyle-suppressions.xml @@ -40,6 +40,7 @@ +