Browse Source

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
pull/28739/head
Brian Clozel 4 years ago
parent
commit
444a9bd011
  1. 63
      spring-core-test/src/main/java/org/springframework/aot/test/agent/EnabledIfRuntimeHintsAgent.java
  2. 46
      spring-core-test/src/main/java/org/springframework/aot/test/agent/RuntimeHintsAgentCondition.java
  3. 50
      spring-core-test/src/main/java/org/springframework/aot/test/agent/RuntimeHintsInvocations.java
  4. 119
      spring-core-test/src/main/java/org/springframework/aot/test/agent/RuntimeHintsInvocationsAssert.java
  5. 76
      spring-core-test/src/main/java/org/springframework/aot/test/agent/RuntimeHintsRecorder.java
  6. 9
      spring-core-test/src/main/java/org/springframework/aot/test/agent/package-info.java
  7. 1
      src/checkstyle/checkstyle-suppressions.xml

63
spring-core-test/src/main/java/org/springframework/aot/test/agent/EnabledIfRuntimeHintsAgent.java

@ -0,0 +1,63 @@ @@ -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.
*
* <pre class="code">
* &#064;EnabledIfRuntimeHintsAgent
* class MyTestCases {
*
* &#064;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);
* }
*
* }
* </pre>
*
* @author Brian Clozel
* @since 6.0
*/
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@ExtendWith(RuntimeHintsAgentCondition.class)
@Tag("RuntimeHintsTests")
public @interface EnabledIfRuntimeHintsAgent {
}

46
spring-core-test/src/main/java/org/springframework/aot/test/agent/RuntimeHintsAgentCondition.java

@ -0,0 +1,46 @@ @@ -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");
}
}

50
spring-core-test/src/main/java/org/springframework/aot/test/agent/RuntimeHintsInvocations.java

@ -0,0 +1,50 @@ @@ -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<RuntimeHintsInvocationsAssert> {
private final List<RecordedInvocation> invocations;
RuntimeHintsInvocations(List<RecordedInvocation> invocations) {
this.invocations = invocations;
}
@Override
public RuntimeHintsInvocationsAssert assertThat() {
return new RuntimeHintsInvocationsAssert(this);
}
Stream<RecordedInvocation> recordedInvocations() {
return this.invocations.stream();
}
}

119
spring-core-test/src/main/java/org/springframework/aot/test/agent/RuntimeHintsInvocationsAssert.java

@ -0,0 +1,119 @@ @@ -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<RuntimeHintsInvocationsAssert, RuntimeHintsInvocations> {
List<Consumer<RuntimeHints>> 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<RuntimeHintsRegistrar> 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}.
* <p>
* Example: <pre class="code">
* RuntimeHints hints = new RuntimeHints();
* hints.reflection().registerType(MyType.class);
* assertThat(invocations).allMatch(hints); </pre>
* @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<RecordedInvocation> 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<StackWalker.StackFrame> 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.
* <p>
* Example: <pre class="code">
* assertThat(invocations).hasCount(42); </pre>
* @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;
}
}

76
spring-core-test/src/main/java/org/springframework/aot/test/agent/RuntimeHintsRecorder.java

@ -0,0 +1,76 @@ @@ -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<RecordedInvocation> recordedInvocations = new ArrayDeque<>();
@Override
public void onInvocation(RecordedInvocation invocation) {
this.recordedInvocations.addLast(invocation);
}
}
}

9
spring-core-test/src/main/java/org/springframework/aot/test/agent/package-info.java

@ -0,0 +1,9 @@ @@ -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;

1
src/checkstyle/checkstyle-suppressions.xml

@ -40,6 +40,7 @@ @@ -40,6 +40,7 @@
<!-- spring-core-test -->
<suppress files="CompileWithTargetClassAccess" checks="IllegalImport" id="bannedJUnitJupiterImports" />
<suppress files="org[\\/]springframework[\\/]aot[\\/]test[\\/]agent[\\/].+" checks="IllegalImport" id="bannedJUnitJupiterImports" />
<!-- spring-expression -->
<suppress files="ExpressionException" checks="MutableException"/>

Loading…
Cancel
Save