Skip to content

Commit 39a282e

Browse files
committed
Introduce @⁠DisabledInAotMode in the TestContext framework
This commit introduces @⁠DisabledInAotMode in the TestContext framework to support the following use cases. - Disabling AOT build-time processing of a test ApplicationContext -- applicable to any testing framework (JUnit 4, JUnit Jupiter, etc.). - Disabling an entire test class or a single test method at run time when the test suite is run with AOT optimizations enabled -- only applicable to JUnit Jupiter based tests. Closes gh-30834
1 parent 8e5f39b commit 39a282e

File tree

10 files changed

+384
-40
lines changed

10 files changed

+384
-40
lines changed
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/*
2+
* Copyright 2002-2023 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.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.condition.DisabledIf;
26+
27+
/**
28+
* {@code @DisabledInAotMode} signals that an annotated test class is <em>disabled</em>
29+
* in Spring AOT (ahead-of-time) mode, which means that the {@code ApplicationContext}
30+
* for the test class will not be processed for AOT optimizations at build time.
31+
*
32+
* <p>If a test class is annotated with {@code @DisabledInAotMode}, all other test
33+
* classes which specify configuration to load the same {@code ApplicationContext}
34+
* must also be annotated with {@code @DisabledInAotMode}. Failure to annotate
35+
* all such test classes will result in a exception, either at build time or
36+
* run time.
37+
*
38+
* <p>When used with JUnit Jupiter based tests, {@code @DisabledInAotMode} also
39+
* signals that the annotated test class or test method is <em>disabled</em> when
40+
* running the test suite in Spring AOT mode. When applied at the class level,
41+
* all test methods within that class will be disabled. In this sense,
42+
* {@code @DisabledInAotMode} has semantics similar to those of JUnit Jupiter's
43+
* {@link org.junit.jupiter.api.condition.DisabledInNativeImage @DisabledInNativeImage}
44+
* annotation.
45+
*
46+
* <p>This annotation may be used as a meta-annotation in order to create a
47+
* custom <em>composed annotation</em> that inherits the semantics of this
48+
* annotation.
49+
*
50+
* @author Sam Brannen
51+
* @since 6.1
52+
* @see org.springframework.aot.AotDetector#useGeneratedArtifacts() AotDetector.useGeneratedArtifacts()
53+
* @see org.junit.jupiter.api.condition.EnabledInNativeImage @EnabledInNativeImage
54+
* @see org.junit.jupiter.api.condition.DisabledInNativeImage @DisabledInNativeImage
55+
*/
56+
@Target({ElementType.TYPE, ElementType.METHOD})
57+
@Retention(RetentionPolicy.RUNTIME)
58+
@Documented
59+
@DisabledIf(value = "org.springframework.aot.AotDetector#useGeneratedArtifacts",
60+
disabledReason = "Disabled in Spring AOT mode")
61+
public @interface DisabledInAotMode {
62+
}

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

Lines changed: 54 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,11 @@
1818

1919
import java.util.Arrays;
2020
import java.util.LinkedHashSet;
21+
import java.util.List;
2122
import java.util.Map;
2223
import java.util.Set;
2324
import java.util.concurrent.atomic.AtomicInteger;
25+
import java.util.function.Predicate;
2426
import java.util.stream.Stream;
2527

2628
import org.apache.commons.logging.Log;
@@ -91,6 +93,9 @@ public class TestContextAotGenerator {
9193

9294
private static final Log logger = LogFactory.getLog(TestContextAotGenerator.class);
9395

96+
private static final Predicate<? super Class<?>> isDisabledInAotMode =
97+
testClass -> MergedAnnotations.from(testClass).isPresent(DisabledInAotMode.class);
98+
9499

95100
private final ApplicationContextAotGenerator aotGenerator = new ApplicationContextAotGenerator();
96101

@@ -235,35 +240,56 @@ private MultiValueMap<ClassName, Class<?>> processAheadOfTime(
235240
ClassLoader classLoader = getClass().getClassLoader();
236241
MultiValueMap<ClassName, Class<?>> initializerClassMappings = new LinkedMultiValueMap<>();
237242
mergedConfigMappings.forEach((mergedConfig, testClasses) -> {
238-
if (logger.isDebugEnabled()) {
239-
logger.debug("Generating AOT artifacts for test classes " +
240-
testClasses.stream().map(Class::getName).toList());
241-
}
242-
this.mergedConfigRuntimeHints.registerHints(this.runtimeHints, mergedConfig, classLoader);
243-
try {
244-
// Use first test class discovered for a given unique MergedContextConfiguration.
245-
Class<?> testClass = testClasses.get(0);
246-
DefaultGenerationContext generationContext = createGenerationContext(testClass);
247-
ClassName initializer = processAheadOfTime(mergedConfig, generationContext);
248-
Assert.state(!initializerClassMappings.containsKey(initializer),
249-
() -> "ClassName [%s] already encountered".formatted(initializer.reflectionName()));
250-
initializerClassMappings.addAll(initializer, testClasses);
251-
generationContext.writeGeneratedContent();
252-
}
253-
catch (Exception ex) {
254-
if (this.failOnError) {
255-
throw new TestContextAotException("Failed to generate AOT artifacts for test classes " +
256-
testClasses.stream().map(Class::getName).toList(), ex);
243+
long numDisabled = testClasses.stream().filter(isDisabledInAotMode).count();
244+
// At least one test class is disabled?
245+
if (numDisabled > 0) {
246+
// Then all related test classes should be disabled.
247+
if (numDisabled != testClasses.size()) {
248+
if (this.failOnError) {
249+
throw new TestContextAotException("""
250+
All test classes that share an ApplicationContext must be annotated
251+
with @DisabledInAotMode if one of them is: """ + classNames(testClasses));
252+
}
253+
else if (logger.isWarnEnabled()) {
254+
logger.warn("""
255+
All test classes that share an ApplicationContext must be annotated
256+
with @DisabledInAotMode if one of them is: """ + classNames(testClasses));
257+
}
258+
}
259+
if (logger.isInfoEnabled()) {
260+
logger.info("Skipping AOT processing due to the presence of @DisabledInAotMode for test classes " +
261+
classNames(testClasses));
257262
}
263+
}
264+
else {
258265
if (logger.isDebugEnabled()) {
259-
logger.debug("Failed to generate AOT artifacts for test classes " +
260-
testClasses.stream().map(Class::getName).toList(), ex);
266+
logger.debug("Generating AOT artifacts for test classes " + classNames(testClasses));
267+
}
268+
this.mergedConfigRuntimeHints.registerHints(this.runtimeHints, mergedConfig, classLoader);
269+
try {
270+
// Use first test class discovered for a given unique MergedContextConfiguration.
271+
Class<?> testClass = testClasses.get(0);
272+
DefaultGenerationContext generationContext = createGenerationContext(testClass);
273+
ClassName initializer = processAheadOfTime(mergedConfig, generationContext);
274+
Assert.state(!initializerClassMappings.containsKey(initializer),
275+
() -> "ClassName [%s] already encountered".formatted(initializer.reflectionName()));
276+
initializerClassMappings.addAll(initializer, testClasses);
277+
generationContext.writeGeneratedContent();
261278
}
262-
else if (logger.isWarnEnabled()) {
263-
logger.warn("""
279+
catch (Exception ex) {
280+
if (this.failOnError) {
281+
throw new TestContextAotException("Failed to generate AOT artifacts for test classes " +
282+
classNames(testClasses), ex);
283+
}
284+
if (logger.isDebugEnabled()) {
285+
logger.debug("Failed to generate AOT artifacts for test classes " + classNames(testClasses), ex);
286+
}
287+
else if (logger.isWarnEnabled()) {
288+
logger.warn("""
264289
Failed to generate AOT artifacts for test classes %s. \
265290
Enable DEBUG logging to view the stack trace. %s"""
266-
.formatted(testClasses.stream().map(Class::getName).toList(), ex));
291+
.formatted(classNames(testClasses), ex));
292+
}
267293
}
268294
}
269295
});
@@ -401,4 +427,8 @@ private static boolean getFailOnErrorFlag() {
401427
return true;
402428
}
403429

430+
private static List<String> classNames(List<Class<?>> classes) {
431+
return classes.stream().map(Class::getName).toList();
432+
}
433+
404434
}

spring-test/src/test/java/org/springframework/test/context/aot/AbstractAotTests.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,13 @@ abstract class AbstractAotTests {
7272
"org/springframework/context/event/EventListenerMethodProcessor__TestContext005_BeanDefinitions.java",
7373
"org/springframework/test/context/aot/samples/basic/BasicSpringVintageTests__TestContext005_ApplicationContextInitializer.java",
7474
"org/springframework/test/context/aot/samples/basic/BasicSpringVintageTests__TestContext005_BeanFactoryRegistrations.java",
75-
"org/springframework/test/context/aot/samples/basic/BasicTestConfiguration__TestContext005_BeanDefinitions.java"
75+
"org/springframework/test/context/aot/samples/basic/BasicTestConfiguration__TestContext005_BeanDefinitions.java",
76+
// DisabledInAotRuntimeMethodLevelTests
77+
"org/springframework/context/event/DefaultEventListenerFactory__TestContext006_BeanDefinitions.java",
78+
"org/springframework/context/event/EventListenerMethodProcessor__TestContext006_BeanDefinitions.java",
79+
"org/springframework/test/context/aot/samples/basic/DisabledInAotRuntimeMethodLevelTests__TestContext006_ApplicationContextInitializer.java",
80+
"org/springframework/test/context/aot/samples/basic/DisabledInAotRuntimeMethodLevelTests__TestContext006_BeanDefinitions.java",
81+
"org/springframework/test/context/aot/samples/basic/DisabledInAotRuntimeMethodLevelTests__TestContext006_BeanFactoryRegistrations.java"
7682
};
7783

7884
Stream<Class<?>> scan() {

spring-test/src/test/java/org/springframework/test/context/aot/AotIntegrationTests.java

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@
4545
import org.springframework.test.context.aot.samples.basic.BasicSpringJupiterTests;
4646
import org.springframework.test.context.aot.samples.basic.BasicSpringTestNGTests;
4747
import org.springframework.test.context.aot.samples.basic.BasicSpringVintageTests;
48+
import org.springframework.test.context.aot.samples.basic.DisabledInAotProcessingTests;
49+
import org.springframework.test.context.aot.samples.basic.DisabledInAotRuntimeClassLevelTests;
50+
import org.springframework.test.context.aot.samples.basic.DisabledInAotRuntimeMethodLevelTests;
4851

4952
import static org.assertj.core.api.Assertions.assertThat;
5053
import static org.junit.platform.engine.discovery.DiscoverySelectors.selectClass;
@@ -98,15 +101,20 @@ void endToEndTests() {
98101
// .printFiles(System.out)
99102
.compile(compiled ->
100103
// AOT RUN-TIME: EXECUTION
101-
runTestsInAotMode(6, List.of(
102-
BasicSpringJupiterSharedConfigTests.class,
103-
BasicSpringJupiterTests.class, // NestedTests get executed automatically
104+
runTestsInAotMode(7, List.of(
105+
// The #s represent how many tests should run from each test class, which
106+
// must add up to the expectedNumTests above.
107+
/* 1 */ BasicSpringJupiterSharedConfigTests.class,
108+
/* 2 */ BasicSpringJupiterTests.class, // NestedTests get executed automatically
104109
// Run @Import tests AFTER the tests with otherwise identical config
105110
// in order to ensure that the other test classes are not accidentally
106111
// using the config for the @Import tests.
107-
BasicSpringJupiterImportedConfigTests.class,
108-
BasicSpringTestNGTests.class,
109-
BasicSpringVintageTests.class)));
112+
/* 1 */ BasicSpringJupiterImportedConfigTests.class,
113+
/* 1 */ BasicSpringTestNGTests.class,
114+
/* 1 */ BasicSpringVintageTests.class,
115+
/* 0 */ DisabledInAotProcessingTests.class,
116+
/* 0 */ DisabledInAotRuntimeClassLevelTests.class,
117+
/* 1 */ DisabledInAotRuntimeMethodLevelTests.class)));
110118
}
111119

112120
@Disabled("Uncomment to run all Spring integration tests in `spring-test`")

spring-test/src/test/java/org/springframework/test/context/aot/TestAotProcessorTests.java

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2022 the original author or authors.
2+
* Copyright 2002-2023 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -34,6 +34,9 @@
3434
import org.springframework.test.context.aot.samples.basic.BasicSpringJupiterTests;
3535
import org.springframework.test.context.aot.samples.basic.BasicSpringTestNGTests;
3636
import org.springframework.test.context.aot.samples.basic.BasicSpringVintageTests;
37+
import org.springframework.test.context.aot.samples.basic.DisabledInAotProcessingTests;
38+
import org.springframework.test.context.aot.samples.basic.DisabledInAotRuntimeClassLevelTests;
39+
import org.springframework.test.context.aot.samples.basic.DisabledInAotRuntimeMethodLevelTests;
3740
import org.springframework.util.ClassUtils;
3841

3942
import static org.assertj.core.api.Assertions.assertThat;
@@ -56,7 +59,10 @@ void process(@TempDir(cleanup = CleanupMode.ON_SUCCESS) Path tempDir) throws Exc
5659
BasicSpringJupiterTests.class,
5760
BasicSpringJupiterTests.NestedTests.class,
5861
BasicSpringTestNGTests.class,
59-
BasicSpringVintageTests.class
62+
BasicSpringVintageTests.class,
63+
DisabledInAotProcessingTests.class,
64+
DisabledInAotRuntimeClassLevelTests.class,
65+
DisabledInAotRuntimeMethodLevelTests.class
6066
).forEach(testClass -> copy(testClass, classpathRoot));
6167

6268
Set<Path> classpathRoots = Set.of(classpathRoot);

spring-test/src/test/java/org/springframework/test/context/aot/TestClassScannerTests.java

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2022 the original author or authors.
2+
* Copyright 2002-2023 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -23,6 +23,9 @@
2323
import org.springframework.test.context.aot.samples.basic.BasicSpringJupiterTests;
2424
import org.springframework.test.context.aot.samples.basic.BasicSpringTestNGTests;
2525
import org.springframework.test.context.aot.samples.basic.BasicSpringVintageTests;
26+
import org.springframework.test.context.aot.samples.basic.DisabledInAotProcessingTests;
27+
import org.springframework.test.context.aot.samples.basic.DisabledInAotRuntimeClassLevelTests;
28+
import org.springframework.test.context.aot.samples.basic.DisabledInAotRuntimeMethodLevelTests;
2629

2730
import static org.assertj.core.api.Assertions.assertThat;
2831

@@ -42,17 +45,26 @@ void scanBasicTestClasses() {
4245
BasicSpringJupiterSharedConfigTests.class,
4346
BasicSpringJupiterTests.class,
4447
BasicSpringJupiterTests.NestedTests.class,
48+
BasicSpringTestNGTests.class,
4549
BasicSpringVintageTests.class,
46-
BasicSpringTestNGTests.class
50+
DisabledInAotProcessingTests.class,
51+
DisabledInAotRuntimeClassLevelTests.class,
52+
DisabledInAotRuntimeMethodLevelTests.class
4753
);
4854
}
4955

5056
@Test
5157
void scanTestSuitesForJupiter() {
5258
assertThat(scan("org.springframework.test.context.aot.samples.suites.jupiter"))
53-
.containsExactlyInAnyOrder(BasicSpringJupiterImportedConfigTests.class,
54-
BasicSpringJupiterSharedConfigTests.class, BasicSpringJupiterTests.class,
55-
BasicSpringJupiterTests.NestedTests.class);
59+
.containsExactlyInAnyOrder(
60+
BasicSpringJupiterImportedConfigTests.class,
61+
BasicSpringJupiterSharedConfigTests.class,
62+
BasicSpringJupiterTests.class,
63+
BasicSpringJupiterTests.NestedTests.class,
64+
DisabledInAotProcessingTests.class,
65+
DisabledInAotRuntimeClassLevelTests.class,
66+
DisabledInAotRuntimeMethodLevelTests.class
67+
);
5668
}
5769

5870
@Test
@@ -76,7 +88,10 @@ void scanTestSuitesForAllTestEngines() {
7688
BasicSpringJupiterTests.class,
7789
BasicSpringJupiterTests.NestedTests.class,
7890
BasicSpringVintageTests.class,
79-
BasicSpringTestNGTests.class
91+
BasicSpringTestNGTests.class,
92+
DisabledInAotProcessingTests.class,
93+
DisabledInAotRuntimeClassLevelTests.class,
94+
DisabledInAotRuntimeMethodLevelTests.class
8095
);
8196
}
8297

@@ -88,7 +103,10 @@ void scanTestSuitesWithNestedSuites() {
88103
BasicSpringJupiterSharedConfigTests.class,
89104
BasicSpringJupiterTests.class,
90105
BasicSpringJupiterTests.NestedTests.class,
91-
BasicSpringVintageTests.class
106+
BasicSpringVintageTests.class,
107+
DisabledInAotProcessingTests.class,
108+
DisabledInAotRuntimeClassLevelTests.class,
109+
DisabledInAotRuntimeMethodLevelTests.class
92110
);
93111
}
94112

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/*
2+
* Copyright 2002-2023 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.samples.basic;
18+
19+
import org.junit.jupiter.api.Test;
20+
21+
import org.springframework.aot.AotDetector;
22+
import org.springframework.beans.factory.annotation.Autowired;
23+
import org.springframework.beans.factory.config.BeanFactoryPostProcessor;
24+
import org.springframework.context.annotation.Bean;
25+
import org.springframework.context.annotation.Configuration;
26+
import org.springframework.test.context.aot.DisabledInAotMode;
27+
import org.springframework.test.context.aot.TestContextAotGenerator;
28+
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
29+
import org.springframework.util.Assert;
30+
31+
import static org.assertj.core.api.Assertions.assertThat;
32+
33+
/**
34+
* {@code @DisabledInAotMode} test class which verifies that the application context
35+
* for the test class is skipped during AOT processing.
36+
*
37+
* @author Sam Brannen
38+
* @since 6.1
39+
*/
40+
@SpringJUnitConfig
41+
@DisabledInAotMode
42+
public class DisabledInAotProcessingTests {
43+
44+
@Test
45+
void disabledInAotMode(@Autowired String enigma) {
46+
assertThat(AotDetector.useGeneratedArtifacts()).as("Should be disabled in AOT mode").isFalse();
47+
assertThat(enigma).isEqualTo("puzzle");
48+
}
49+
50+
@Configuration
51+
static class Config {
52+
53+
@Bean
54+
String enigma() {
55+
return "puzzle";
56+
}
57+
58+
@Bean
59+
static BeanFactoryPostProcessor bfppBrokenDuringAotProcessing() {
60+
boolean runningDuringAotProcessing = StackWalker.getInstance().walk(stream ->
61+
stream.anyMatch(stackFrame -> stackFrame.getClassName().equals(TestContextAotGenerator.class.getName())));
62+
63+
return beanFactory -> Assert.state(!runningDuringAotProcessing, "Should not be used during AOT processing");
64+
}
65+
}
66+
67+
}

0 commit comments

Comments
 (0)