Skip to content

Commit 7e6c0ed

Browse files
committed
Introduce initial support for processing test contexts ahead-of-time
This commit introduces TestContextAotGenerator for processing Spring integration test classes and generating AOT artifacts. Specifically, this class performs the following. - bootstraps the TCF for a given test class - builds the MergedContextConfiguration for each test class and tracks all test classes that share the same MergedContextConfiguration - loads each test ApplicationContext without refreshing it - passes the test ApplicationContext to ApplicationContextAotGenerator to generate the AOT optimized ApplicationContextInitializer - The GenerationContext passed to ApplicationContextAotGenerator uses a feature name of the form "TestContext###_", where "###" is a 3-digit sequence ID left padded with zeros. This commit also includes tests using the TestCompiler to verify that each generated ApplicationContextInitializer can be used to populate a GenericApplicationContext as expected. See spring-projectsgh-28204
1 parent e5f9bb7 commit 7e6c0ed

File tree

12 files changed

+617
-27
lines changed

12 files changed

+617
-27
lines changed

spring-test/spring-test.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ dependencies {
4545
optional("io.projectreactor:reactor-test")
4646
optional("org.jetbrains.kotlinx:kotlinx-coroutines-core")
4747
optional("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
48+
testImplementation(project(":spring-core-test"))
4849
testImplementation(project(":spring-context-support"))
4950
testImplementation(project(":spring-oxm"))
5051
testImplementation(testFixtures(project(":spring-beans")))

spring-test/src/main/java/org/springframework/test/context/aot/TestClassScanner.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import java.lang.annotation.Annotation;
2020
import java.nio.file.Path;
2121
import java.util.Arrays;
22+
import java.util.Comparator;
2223
import java.util.Optional;
2324
import java.util.Set;
2425
import java.util.stream.Stream;
@@ -156,7 +157,8 @@ Stream<Class<?>> scan(String... packageNames) {
156157
.map(this::getJavaClass)
157158
.flatMap(Optional::stream)
158159
.filter(this::isSpringTestClass)
159-
.distinct();
160+
.distinct()
161+
.sorted(Comparator.comparing(Class::getName));
160162
}
161163

162164
private Optional<Class<?>> getJavaClass(ClassSource classSource) {
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
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.test.context.aot;
18+
19+
/**
20+
* Thrown if an error occurs during AOT processing or AOT runtime execution.
21+
*
22+
* @author Sam Brannen
23+
* @since 6.0
24+
*/
25+
@SuppressWarnings("serial")
26+
public class TestContextAotException extends RuntimeException {
27+
28+
/**
29+
* Create a new {@code TestContextAotException}.
30+
* @param message the detail message
31+
*/
32+
public TestContextAotException(String message) {
33+
super(message);
34+
}
35+
36+
/**
37+
* Create a new {@code TestContextAotException}.
38+
* @param message the detail message
39+
* @param cause the root cause
40+
*/
41+
public TestContextAotException(String message, Throwable cause) {
42+
super(message, cause);
43+
}
44+
45+
}
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
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.test.context.aot;
18+
19+
import java.util.concurrent.atomic.AtomicInteger;
20+
import java.util.stream.Stream;
21+
22+
import org.apache.commons.logging.Log;
23+
import org.apache.commons.logging.LogFactory;
24+
25+
import org.springframework.aot.generate.ClassNameGenerator;
26+
import org.springframework.aot.generate.DefaultGenerationContext;
27+
import org.springframework.aot.generate.GeneratedFiles;
28+
import org.springframework.aot.generate.GenerationContext;
29+
import org.springframework.aot.hint.RuntimeHints;
30+
import org.springframework.context.ApplicationContext;
31+
import org.springframework.context.ApplicationContextInitializer;
32+
import org.springframework.context.aot.ApplicationContextAotGenerator;
33+
import org.springframework.context.support.GenericApplicationContext;
34+
import org.springframework.javapoet.ClassName;
35+
import org.springframework.test.context.BootstrapUtils;
36+
import org.springframework.test.context.ContextLoader;
37+
import org.springframework.test.context.MergedContextConfiguration;
38+
import org.springframework.test.context.SmartContextLoader;
39+
import org.springframework.test.context.TestContextBootstrapper;
40+
import org.springframework.util.Assert;
41+
import org.springframework.util.LinkedMultiValueMap;
42+
import org.springframework.util.MultiValueMap;
43+
44+
/**
45+
* {@code TestContextAotGenerator} generates AOT artifacts for integration tests
46+
* that depend on support from the <em>Spring TestContext Framework</em>.
47+
*
48+
* @author Sam Brannen
49+
* @since 6.0
50+
* @see ApplicationContextAotGenerator
51+
*/
52+
class TestContextAotGenerator {
53+
54+
private static final Log logger = LogFactory.getLog(TestClassScanner.class);
55+
56+
private final ApplicationContextAotGenerator aotGenerator = new ApplicationContextAotGenerator();
57+
58+
private final AtomicInteger sequence = new AtomicInteger();
59+
60+
private final GeneratedFiles generatedFiles;
61+
62+
private final RuntimeHints runtimeHints;
63+
64+
65+
/**
66+
* Create a new {@link TestContextAotGenerator} that uses the supplied
67+
* {@link GeneratedFiles}.
68+
* @param generatedFiles the {@code GeneratedFiles} to use
69+
*/
70+
public TestContextAotGenerator(GeneratedFiles generatedFiles) {
71+
this(generatedFiles, new RuntimeHints());
72+
}
73+
74+
/**
75+
* Create a new {@link TestContextAotGenerator} that uses the supplied
76+
* {@link GeneratedFiles} and {@link RuntimeHints}.
77+
* @param generatedFiles the {@code GeneratedFiles} to use
78+
* @param runtimeHints the {@code RuntimeHints} to use
79+
*/
80+
public TestContextAotGenerator(GeneratedFiles generatedFiles, RuntimeHints runtimeHints) {
81+
this.generatedFiles = generatedFiles;
82+
this.runtimeHints = runtimeHints;
83+
}
84+
85+
86+
/**
87+
* Get the {@link RuntimeHints} gathered during {@linkplain #processAheadOfTime(Stream)
88+
* AOT processing}.
89+
*/
90+
public final RuntimeHints getRuntimeHints() {
91+
return this.runtimeHints;
92+
}
93+
94+
/**
95+
* Process each of the supplied Spring integration test classes and generate
96+
* AOT artifacts.
97+
* @throws TestContextAotException if an error occurs during AOT processing
98+
*/
99+
public void processAheadOfTime(Stream<Class<?>> testClasses) throws TestContextAotException {
100+
MultiValueMap<MergedContextConfiguration, Class<?>> map = new LinkedMultiValueMap<>();
101+
testClasses.forEach(testClass -> map.add(buildMergedContextConfiguration(testClass), testClass));
102+
103+
map.forEach((mergedConfig, classes) -> {
104+
// System.err.println(mergedConfig + " -> " + classes);
105+
if (logger.isDebugEnabled()) {
106+
logger.debug("Generating AOT artifacts for test classes [%s]"
107+
.formatted(classes.stream().map(Class::getCanonicalName).toList()));
108+
}
109+
try {
110+
// Use first test class discovered for a given unique MergedContextConfiguration.
111+
Class<?> testClass = classes.get(0);
112+
DefaultGenerationContext generationContext = createGenerationContext(testClass);
113+
ClassName className = processAheadOfTime(mergedConfig, generationContext);
114+
// TODO Store ClassName in a map analogous to TestContextAotProcessor in Spring Native.
115+
generationContext.writeGeneratedContent();
116+
}
117+
catch (Exception ex) {
118+
if (logger.isWarnEnabled()) {
119+
logger.warn("Failed to generate AOT artifacts for test classes [%s]"
120+
.formatted(classes.stream().map(Class::getCanonicalName).toList()), ex);
121+
}
122+
}
123+
});
124+
}
125+
126+
/**
127+
* Process the specified {@link MergedContextConfiguration} ahead-of-time
128+
* using the specified {@link GenerationContext}.
129+
* <p>Return the {@link ClassName} of the {@link ApplicationContextInitializer}
130+
* to use to restore an optimized state of the test application context for
131+
* the given {@code MergedContextConfiguration}.
132+
* @param mergedConfig the {@code MergedContextConfiguration} to process
133+
* @param generationContext the generation context to use
134+
* @return the {@link ClassName} for the generated {@code ApplicationContextInitializer}
135+
* @throws TestContextAotException if an error occurs during AOT processing
136+
*/
137+
ClassName processAheadOfTime(MergedContextConfiguration mergedConfig,
138+
GenerationContext generationContext) throws TestContextAotException {
139+
140+
GenericApplicationContext gac = loadContextForAotProcessing(mergedConfig);
141+
try {
142+
return this.aotGenerator.processAheadOfTime(gac, generationContext);
143+
}
144+
catch (Throwable ex) {
145+
throw new TestContextAotException("Failed to process test class [%s] for AOT"
146+
.formatted(mergedConfig.getTestClass().getCanonicalName()), ex);
147+
}
148+
}
149+
150+
/**
151+
* Load the {@code GenericApplicationContext} for the supplied merged context
152+
* configuration for AOT processing.
153+
* <p>Only supports {@link SmartContextLoader SmartContextLoaders} that
154+
* create {@link GenericApplicationContext GenericApplicationContexts}.
155+
* @throws TestContextAotException if an error occurs while loading the application
156+
* context or if one of the prerequisites is not met
157+
* @see SmartContextLoader#loadContextForAotProcessing(MergedContextConfiguration)
158+
*/
159+
private GenericApplicationContext loadContextForAotProcessing(
160+
MergedContextConfiguration mergedConfig) throws TestContextAotException {
161+
162+
Class<?> testClass = mergedConfig.getTestClass();
163+
ContextLoader contextLoader = mergedConfig.getContextLoader();
164+
Assert.notNull(contextLoader, """
165+
Cannot load an ApplicationContext with a NULL 'contextLoader'. \
166+
Consider annotating test class [%s] with @ContextConfiguration or \
167+
@ContextHierarchy.""".formatted(testClass.getCanonicalName()));
168+
169+
if (contextLoader instanceof SmartContextLoader smartContextLoader) {
170+
try {
171+
ApplicationContext context = smartContextLoader.loadContextForAotProcessing(mergedConfig);
172+
if (context instanceof GenericApplicationContext gac) {
173+
return gac;
174+
}
175+
}
176+
catch (Exception ex) {
177+
throw new TestContextAotException(
178+
"Failed to load ApplicationContext for AOT processing for test class [%s]"
179+
.formatted(testClass.getCanonicalName()), ex);
180+
}
181+
}
182+
throw new TestContextAotException("""
183+
Cannot generate AOT artifacts for test class [%s]. The configured \
184+
ContextLoader [%s] must be a SmartContextLoader and must create a \
185+
GenericApplicationContext.""".formatted(testClass.getCanonicalName(),
186+
contextLoader.getClass().getName()));
187+
}
188+
189+
MergedContextConfiguration buildMergedContextConfiguration(Class<?> testClass) {
190+
TestContextBootstrapper testContextBootstrapper =
191+
BootstrapUtils.resolveTestContextBootstrapper(testClass);
192+
return testContextBootstrapper.buildMergedContextConfiguration();
193+
}
194+
195+
DefaultGenerationContext createGenerationContext(Class<?> testClass) {
196+
ClassNameGenerator classNameGenerator = new ClassNameGenerator(testClass);
197+
DefaultGenerationContext generationContext =
198+
new DefaultGenerationContext(classNameGenerator, this.generatedFiles, this.runtimeHints);
199+
return generationContext.withName(nextTestContextId());
200+
}
201+
202+
private String nextTestContextId() {
203+
return "TestContext%03d_".formatted(this.sequence.incrementAndGet());
204+
}
205+
206+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
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.test.context.aot;
18+
19+
import java.nio.file.Path;
20+
import java.nio.file.Paths;
21+
import java.util.Set;
22+
import java.util.stream.Stream;
23+
24+
/**
25+
* @author Sam Brannen
26+
* @since 6.0
27+
*/
28+
abstract class AbstractAotTests {
29+
30+
static final String[] expectedSourceFilesForBasicSpringTests = {
31+
// BasicSpringJupiterSharedConfigTests
32+
"org/springframework/context/event/DefaultEventListenerFactory__TestContext001_BeanDefinitions.java",
33+
"org/springframework/context/event/EventListenerMethodProcessor__TestContext001_BeanDefinitions.java",
34+
"org/springframework/test/context/aot/samples/basic/BasicSpringJupiterSharedConfigTests__TestContext001_ApplicationContextInitializer.java",
35+
"org/springframework/test/context/aot/samples/basic/BasicSpringJupiterSharedConfigTests__TestContext001_BeanFactoryRegistrations.java",
36+
"org/springframework/test/context/aot/samples/basic/BasicTestConfiguration__TestContext001_BeanDefinitions.java",
37+
// BasicSpringJupiterTests -- not generated b/c already generated for BasicSpringJupiterSharedConfigTests.
38+
// "org/springframework/context/event/DefaultEventListenerFactory__TestContext00?_BeanDefinitions.java",
39+
// "org/springframework/context/event/EventListenerMethodProcessor__TestContext00?_BeanDefinitions.java",
40+
// "org/springframework/test/context/aot/samples/basic/BasicSpringJupiterTests__TestContext00?_ApplicationContextInitializer.java",
41+
// "org/springframework/test/context/aot/samples/basic/BasicSpringJupiterTests__TestContext00?_BeanFactoryRegistrations.java",
42+
// "org/springframework/test/context/aot/samples/basic/BasicTestConfiguration__TestContext00?_BeanDefinitions.java",
43+
// BasicSpringJupiterTests.NestedTests
44+
"org/springframework/context/event/DefaultEventListenerFactory__TestContext002_BeanDefinitions.java",
45+
"org/springframework/context/event/EventListenerMethodProcessor__TestContext002_BeanDefinitions.java",
46+
"org/springframework/test/context/aot/samples/basic/BasicSpringJupiterTests_NestedTests__TestContext002_ApplicationContextInitializer.java",
47+
"org/springframework/test/context/aot/samples/basic/BasicSpringJupiterTests_NestedTests__TestContext002_BeanFactoryRegistrations.java",
48+
"org/springframework/test/context/aot/samples/basic/BasicTestConfiguration__TestContext002_BeanDefinitions.java",
49+
// BasicSpringTestNGTests
50+
"org/springframework/context/event/DefaultEventListenerFactory__TestContext003_BeanDefinitions.java",
51+
"org/springframework/context/event/EventListenerMethodProcessor__TestContext003_BeanDefinitions.java",
52+
"org/springframework/test/context/aot/samples/basic/BasicSpringTestNGTests__TestContext003_ApplicationContextInitializer.java",
53+
"org/springframework/test/context/aot/samples/basic/BasicSpringTestNGTests__TestContext003_BeanFactoryRegistrations.java",
54+
"org/springframework/test/context/aot/samples/basic/BasicTestConfiguration__TestContext003_BeanDefinitions.java",
55+
// BasicSpringVintageTests
56+
"org/springframework/context/event/DefaultEventListenerFactory__TestContext004_BeanDefinitions.java",
57+
"org/springframework/context/event/EventListenerMethodProcessor__TestContext004_BeanDefinitions.java",
58+
"org/springframework/test/context/aot/samples/basic/BasicSpringVintageTests__TestContext004_ApplicationContextInitializer.java",
59+
"org/springframework/test/context/aot/samples/basic/BasicSpringVintageTests__TestContext004_BeanFactoryRegistrations.java",
60+
"org/springframework/test/context/aot/samples/basic/BasicTestConfiguration__TestContext004_BeanDefinitions.java"
61+
};
62+
63+
Stream<Class<?>> scan() {
64+
return new TestClassScanner(classpathRoots()).scan();
65+
}
66+
67+
Stream<Class<?>> scan(String... packageNames) {
68+
return new TestClassScanner(classpathRoots()).scan(packageNames);
69+
}
70+
71+
Set<Path> classpathRoots() {
72+
try {
73+
return Set.of(Paths.get(getClass().getProtectionDomain().getCodeSource().getLocation().toURI()));
74+
}
75+
catch (Exception ex) {
76+
throw new RuntimeException(ex);
77+
}
78+
}
79+
80+
}

0 commit comments

Comments
 (0)