Skip to content

Commit 444a9bd

Browse files
committed
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
1 parent c86e678 commit 444a9bd

File tree

7 files changed

+364
-0
lines changed

7 files changed

+364
-0
lines changed
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/*
2+
* Copyright 2002-2022 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.aot.test.agent;
18+
19+
import java.lang.annotation.Documented;
20+
import java.lang.annotation.ElementType;
21+
import java.lang.annotation.Retention;
22+
import java.lang.annotation.RetentionPolicy;
23+
import java.lang.annotation.Target;
24+
25+
import org.junit.jupiter.api.Tag;
26+
import org.junit.jupiter.api.extension.ExtendWith;
27+
28+
import org.springframework.aot.agent.RuntimeHintsAgent;
29+
30+
/**
31+
* {@code @EneabledIfRuntimeHintsAgent} signals that the annotated test class or test method
32+
* is only enabled if the {@link RuntimeHintsAgent} is loaded on the current JVM.
33+
*
34+
* <pre class="code">
35+
* &#064;EnabledIfRuntimeHintsAgent
36+
* class MyTestCases {
37+
*
38+
* &#064;Test
39+
* void hintsForMethodsReflectionShouldMatch() {
40+
* RuntimeHints hints = new RuntimeHints();
41+
* hints.reflection().registerType(String.class,
42+
* hint -> hint.withMembers(MemberCategory.INTROSPECT_PUBLIC_METHODS));
43+
*
44+
* RuntimeHintsInvocations invocations = RuntimeHintsRecorder.record(() -> {
45+
* Method[] methods = String.class.getMethods();
46+
* });
47+
* assertThat(invocations).match(hints);
48+
* }
49+
*
50+
* }
51+
* </pre>
52+
*
53+
* @author Brian Clozel
54+
* @since 6.0
55+
*/
56+
@Target({ElementType.TYPE, ElementType.METHOD})
57+
@Retention(RetentionPolicy.RUNTIME)
58+
@Documented
59+
@ExtendWith(RuntimeHintsAgentCondition.class)
60+
@Tag("RuntimeHintsTests")
61+
public @interface EnabledIfRuntimeHintsAgent {
62+
63+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/*
2+
* Copyright 2002-2022 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.aot.test.agent;
18+
19+
import org.junit.jupiter.api.extension.ConditionEvaluationResult;
20+
import org.junit.jupiter.api.extension.ExecutionCondition;
21+
import org.junit.jupiter.api.extension.ExtensionContext;
22+
23+
import org.springframework.aot.agent.RuntimeHintsAgent;
24+
25+
import static org.junit.platform.commons.util.AnnotationUtils.findAnnotation;
26+
27+
/**
28+
* {@link ExecutionCondition} for {@link EnabledIfRuntimeHintsAgent @EnabledIfRuntimeHintsAgent}.
29+
*
30+
* @author Brian Clozel
31+
*/
32+
public class RuntimeHintsAgentCondition implements ExecutionCondition {
33+
34+
35+
@Override
36+
public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext context) {
37+
return findAnnotation(context.getElement(), EnabledIfRuntimeHintsAgent.class)
38+
.map(annotation -> checkRuntimeHintsAgentPresence())
39+
.orElse(ConditionEvaluationResult.enabled("@RuntimeHintsTest is not present"));
40+
}
41+
42+
static ConditionEvaluationResult checkRuntimeHintsAgentPresence() {
43+
return RuntimeHintsAgent.isLoaded() ? ConditionEvaluationResult.enabled("RuntimeHintsAgent is loaded")
44+
: ConditionEvaluationResult.disabled("RuntimeHintsAgent is not loaded on the current JVM");
45+
}
46+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/*
2+
* Copyright 2002-2022 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.aot.test.agent;
18+
19+
import java.util.List;
20+
import java.util.stream.Stream;
21+
22+
import org.assertj.core.api.AssertProvider;
23+
24+
import org.springframework.aot.agent.RecordedInvocation;
25+
26+
/**
27+
* A wrapper for {@link RecordedInvocation} that is the starting point for {@code RuntimeHints} AssertJ assertions.
28+
*
29+
* @author Brian Clozel
30+
* @since 6.0
31+
* @see RuntimeHintsInvocationsAssert
32+
*/
33+
public class RuntimeHintsInvocations implements AssertProvider<RuntimeHintsInvocationsAssert> {
34+
35+
private final List<RecordedInvocation> invocations;
36+
37+
RuntimeHintsInvocations(List<RecordedInvocation> invocations) {
38+
this.invocations = invocations;
39+
}
40+
41+
@Override
42+
public RuntimeHintsInvocationsAssert assertThat() {
43+
return new RuntimeHintsInvocationsAssert(this);
44+
}
45+
46+
Stream<RecordedInvocation> recordedInvocations() {
47+
return this.invocations.stream();
48+
}
49+
50+
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/*
2+
* Copyright 2002-2022 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.aot.test.agent;
18+
19+
import java.util.ArrayList;
20+
import java.util.List;
21+
import java.util.function.Consumer;
22+
import java.util.stream.Collectors;
23+
import java.util.stream.Stream;
24+
25+
import org.assertj.core.api.AbstractAssert;
26+
import org.assertj.core.error.BasicErrorMessageFactory;
27+
import org.assertj.core.error.ErrorMessageFactory;
28+
29+
import org.springframework.aot.agent.RecordedInvocation;
30+
import org.springframework.aot.hint.RuntimeHints;
31+
import org.springframework.aot.hint.RuntimeHintsRegistrar;
32+
import org.springframework.core.io.support.SpringFactoriesLoader;
33+
import org.springframework.util.Assert;
34+
35+
/**
36+
* AssertJ {@link org.assertj.core.api.Assert assertions} that can be applied to an {@link RuntimeHintsInvocations}.
37+
*
38+
* @author Brian Clozel
39+
* @since 6.0
40+
*/
41+
public class RuntimeHintsInvocationsAssert extends AbstractAssert<RuntimeHintsInvocationsAssert, RuntimeHintsInvocations> {
42+
43+
44+
List<Consumer<RuntimeHints>> configurers = new ArrayList<>();
45+
46+
RuntimeHintsInvocationsAssert(RuntimeHintsInvocations invocations) {
47+
super(invocations, RuntimeHintsInvocationsAssert.class);
48+
}
49+
50+
public RuntimeHintsInvocationsAssert withRegistrar(RuntimeHintsRegistrar registrar) {
51+
this.configurers.add(hints -> registrar.registerHints(hints, getClass().getClassLoader()));
52+
return this;
53+
}
54+
55+
public RuntimeHintsInvocationsAssert withSpringFactoriesRegistrars(String location) {
56+
List<RuntimeHintsRegistrar> registrars = SpringFactoriesLoader.forResourceLocation(location).load(RuntimeHintsRegistrar.class);
57+
this.configurers.add(hints -> registrars.forEach(registrar -> registrar.registerHints(hints, getClass().getClassLoader())));
58+
return this;
59+
}
60+
61+
private void configureRuntimeHints(RuntimeHints hints) {
62+
this.configurers.forEach(configurer -> configurer.accept(hints));
63+
}
64+
65+
/**
66+
* Verifies that each recorded invocation match at least once hint in the provided {@link RuntimeHints}.
67+
* <p>
68+
* Example: <pre class="code">
69+
* RuntimeHints hints = new RuntimeHints();
70+
* hints.reflection().registerType(MyType.class);
71+
* assertThat(invocations).allMatch(hints); </pre>
72+
* @param runtimeHints the runtime hints configuration to test against
73+
* @throws AssertionError if any of the recorded invocations has no match in the provided hints
74+
*/
75+
public void match(RuntimeHints runtimeHints) {
76+
Assert.notNull(runtimeHints, "RuntimeHints should not be null");
77+
configureRuntimeHints(runtimeHints);
78+
List<RecordedInvocation> noMatchInvocations =
79+
this.actual.recordedInvocations().filter(invocation -> !invocation.matches(runtimeHints)).toList();
80+
if (!noMatchInvocations.isEmpty()) {
81+
throwAssertionError(errorMessageForInvocation(noMatchInvocations.get(0)));
82+
}
83+
}
84+
85+
86+
private ErrorMessageFactory errorMessageForInvocation(RecordedInvocation invocation) {
87+
return new BasicErrorMessageFactory("%nMissing <%s> for invocation <%s> on type <%s> %nwith arguments %s.%nStacktrace:%n<%s>",
88+
invocation.getHintType().hintClassName(), invocation.getMethodReference(),
89+
invocation.getInstanceTypeReference(), invocation.getArguments(),
90+
formatStackTrace(invocation.getStackFrames()));
91+
}
92+
93+
private String formatStackTrace(Stream<StackWalker.StackFrame> stackTraceElements) {
94+
return stackTraceElements
95+
.map(f -> f.getClassName() + "#" + f.getMethodName()
96+
+ ", Line " + f.getLineNumber()).collect(Collectors.joining(System.lineSeparator()));
97+
}
98+
99+
/**
100+
* Verifies that the count of recorded invocations match the expected one.
101+
* <p>
102+
* Example: <pre class="code">
103+
* assertThat(invocations).hasCount(42); </pre>
104+
* @param count the expected invocations count
105+
* @return {@code this} assertion object.
106+
* @throws AssertionError if the number of recorded invocations doesn't match the expected one
107+
*/
108+
public RuntimeHintsInvocationsAssert hasCount(long count) {
109+
isNotNull();
110+
long invocationsCount = this.actual.recordedInvocations().count();
111+
if(invocationsCount != count) {
112+
throwAssertionError(new BasicErrorMessageFactory("%nNumber of recorded invocations does not match, expected <%n> but got <%n>.",
113+
invocationsCount, count));
114+
}
115+
return this;
116+
}
117+
118+
119+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/*
2+
* Copyright 2002-2022 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.aot.test.agent;
18+
19+
import java.util.ArrayDeque;
20+
import java.util.Deque;
21+
22+
import org.springframework.aot.agent.RecordedInvocation;
23+
import org.springframework.aot.agent.RecordedInvocationsListener;
24+
import org.springframework.aot.agent.RecordedInvocationsPublisher;
25+
import org.springframework.aot.agent.RuntimeHintsAgent;
26+
import org.springframework.aot.hint.RuntimeHints;
27+
import org.springframework.util.Assert;
28+
29+
/**
30+
* Invocations relevant to {@link RuntimeHints} recorded during the execution of a block
31+
* of code instrumented by the {@link RuntimeHintsAgent}.
32+
*
33+
* @author Brian Clozel
34+
* @since 6.0
35+
*/
36+
public final class RuntimeHintsRecorder {
37+
38+
private final RuntimeHintsInvocationsListener listener;
39+
40+
private RuntimeHintsRecorder() {
41+
this.listener = new RuntimeHintsInvocationsListener();
42+
}
43+
44+
/**
45+
* Record all method invocations relevant to {@link RuntimeHints} that happened
46+
* during the execution of the given action.
47+
* @param action the block of code we want to record invocations from
48+
* @return the recorded invocations
49+
*/
50+
public synchronized static RuntimeHintsInvocations record(Runnable action) {
51+
Assert.notNull(action, "Runnable action should not be null");
52+
Assert.isTrue(RuntimeHintsAgent.isLoaded(), "RuntimeHintsAgent should be loaded in the current JVM");
53+
RuntimeHintsRecorder recorder = new RuntimeHintsRecorder();
54+
RecordedInvocationsPublisher.addListener(recorder.listener);
55+
try {
56+
action.run();
57+
}
58+
finally {
59+
RecordedInvocationsPublisher.removeListener(recorder.listener);
60+
}
61+
return new RuntimeHintsInvocations(recorder.listener.recordedInvocations.stream().toList());
62+
}
63+
64+
65+
private static final class RuntimeHintsInvocationsListener implements RecordedInvocationsListener {
66+
67+
private final Deque<RecordedInvocation> recordedInvocations = new ArrayDeque<>();
68+
69+
@Override
70+
public void onInvocation(RecordedInvocation invocation) {
71+
this.recordedInvocations.addLast(invocation);
72+
}
73+
74+
}
75+
76+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/**
2+
* Testing support for the {@link org.springframework.aot.agent.RuntimeHintsAgent}.
3+
*/
4+
@NonNullApi
5+
@NonNullFields
6+
package org.springframework.aot.test.agent;
7+
8+
import org.springframework.lang.NonNullApi;
9+
import org.springframework.lang.NonNullFields;

src/checkstyle/checkstyle-suppressions.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040

4141
<!-- spring-core-test -->
4242
<suppress files="CompileWithTargetClassAccess" checks="IllegalImport" id="bannedJUnitJupiterImports" />
43+
<suppress files="org[\\/]springframework[\\/]aot[\\/]test[\\/]agent[\\/].+" checks="IllegalImport" id="bannedJUnitJupiterImports" />
4344

4445
<!-- spring-expression -->
4546
<suppress files="ExpressionException" checks="MutableException"/>

0 commit comments

Comments
 (0)