diff --git a/framework-docs/modules/ROOT/pages/integration/aot-cache.adoc b/framework-docs/modules/ROOT/pages/integration/aot-cache.adoc index 57d0aed17c16..32acc9d9fdcb 100644 --- a/framework-docs/modules/ROOT/pages/integration/aot-cache.adoc +++ b/framework-docs/modules/ROOT/pages/integration/aot-cache.adoc @@ -118,3 +118,54 @@ the cache, make sure that the following conditions are fulfilled when creating a - The timestamps of the JARs must be preserved. - When using the cache, the classpath must be the same as the one used to create it, in the same order. Additional JARs or directories can be specified *at the end* (but will not be cached). + +[[aot-cache-integration-tests]] +== Generating the cache from integration tests + +In addition to creating the AOT cache via a dedicated training run, you can also +generate the cache as a side effect of running integration tests. This is useful +when your test suite exercises a significant portion of the application's startup +and request-handling workflow, producing a more effective cache than a simple +`-Dspring.context.exit=onRefresh` run. The cache also stores method +profiling information, so exercising realistic code paths improves warmup behavior as well. + +Spring Framework provides the +`org.springframework.test.context.aot.AotCacheTestExecutionListener` for this purpose. +The listener is registered by default and activates automatically when the test JVM is +started with AOT cache recording enabled. When active, this listener: + +. Validates that the JDK supports AOT cache recording (JDK 25+). +. Eagerly loads the `ApplicationContext` so that context creation is part of the training workload. +. Warns when the `-Dspring.context.exit=onRefresh` flag is present, since it would + terminate the test JVM mid-run. +. Verifies the cache file was produced on JVM exit. + +To enable AOT cache recording from integration tests, the build tool (Maven or Gradle) +must pass the JVM flag that specifies the cache output path. By convention, the cache is +written to `aot-cache/application.aot` relative to the test working directory, which +matches the location detected by the Paketo Spring Boot buildpack and bundled into the +image by the Spring Boot build plugins: + +[source,bash,subs="verbatim,quotes"] +---- +-XX:AOTCacheOutput=aot-cache/application.aot +---- + +The listener does not set JVM flags itself; it only detects them. No additional Spring +configuration is required, since the JVM flags are the single source of truth for whether +recording is enabled and where the cache file is written. + +NOTE: The `-Dspring.context.exit=onRefresh` flag is *not* needed when generating the +cache from integration tests, because the test JVM exits normally at the end of the run. +If it is present, the listener logs a warning. + +For the AOT cache to work correctly, the application must: + +* Use the standard JDK class loader (not a custom class loader). +* Be deployed as an extracted JAR or with a standard classpath layout. + +On JVM exit, the listener verifies that the cache file was produced at the path specified +by the `-XX:AOTCacheOutput` flag and logs a warning if it is missing. + +TIP: The Maven/Gradle flag wiring for `-XX:AOTCacheOutput` is provided by the Spring Boot +Maven and Gradle plugins. Consult the Spring Boot documentation for build tool integration details. diff --git a/framework-docs/modules/ROOT/pages/testing/testcontext-framework/tel-config.adoc b/framework-docs/modules/ROOT/pages/testing/testcontext-framework/tel-config.adoc index 0b89b0672419..bf679690d47c 100644 --- a/framework-docs/modules/ROOT/pages/testing/testcontext-framework/tel-config.adoc +++ b/framework-docs/modules/ROOT/pages/testing/testcontext-framework/tel-config.adoc @@ -20,6 +20,9 @@ by default, exactly in the following order: "after" modes. * `CommonCachesTestExecutionListener`: Clears resource caches in the test's `ApplicationContext` if necessary. +* `AotCacheTestExecutionListener`: Coordinates JVM AOT cache recording during integration + tests. It activates automatically when the test JVM is started with AOT cache recording + flags (see xref:integration/aot-cache.adoc#aot-cache-integration-tests[Generating the cache from integration tests]). * `TransactionalTestExecutionListener`: Provides transactional test execution with default rollback semantics. * `SqlScriptsTestExecutionListener`: Runs SQL scripts configured by using the `@Sql` diff --git a/spring-test/src/main/java/org/springframework/test/context/TestExecutionListener.java b/spring-test/src/main/java/org/springframework/test/context/TestExecutionListener.java index 81327908d9f9..3c30e017d377 100644 --- a/spring-test/src/main/java/org/springframework/test/context/TestExecutionListener.java +++ b/spring-test/src/main/java/org/springframework/test/context/TestExecutionListener.java @@ -106,6 +106,8 @@ * DirtiesContextTestExecutionListener} *
  • {@link org.springframework.test.context.support.CommonCachesTestExecutionListener * CommonCachesTestExecutionListener}
  • + *
  • {@link org.springframework.test.context.aot.AotCacheTestExecutionListener + * AotCacheTestExecutionListener}
  • *
  • {@link org.springframework.test.context.transaction.TransactionalTestExecutionListener * TransactionalTestExecutionListener}
  • *
  • {@link org.springframework.test.context.jdbc.SqlScriptsTestExecutionListener diff --git a/spring-test/src/main/java/org/springframework/test/context/aot/AotCacheTestExecutionListener.java b/spring-test/src/main/java/org/springframework/test/context/aot/AotCacheTestExecutionListener.java new file mode 100644 index 000000000000..d9a378320282 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/context/aot/AotCacheTestExecutionListener.java @@ -0,0 +1,256 @@ +/* + * Copyright 2002-present 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.test.context.aot; + +import java.io.File; +import java.lang.management.ManagementFactory; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.jspecify.annotations.Nullable; + +import org.springframework.context.ApplicationContext; +import org.springframework.context.support.DefaultLifecycleProcessor; +import org.springframework.core.SpringProperties; +import org.springframework.test.context.TestContext; +import org.springframework.test.context.support.AbstractTestExecutionListener; + +/** + * {@code TestExecutionListener} that coordinates JVM AOT cache recording + * during integration tests, as proposed in + * issue #36774. + * + *

    This listener is registered by default and activates automatically when the test JVM + * is started with AOT cache recording enabled, that is with the JDK 25+ single-step + * {@code -XX:AOTCacheOutput=} flag. + * The flag injection itself is the responsibility of the build tooling used for the test + * run (for example, the Spring Boot Maven or Gradle plugins), since JVM flags can only be + * configured at startup. + * + *

    When active, this listener: + *

    + * + *

    Requires JDK 25 or later (JEP 514). + * + * @author Vasily Pelikh + * @since 7.1 + * @see JEP 483: Ahead-of-Time Class Loading & Linking + * @see JEP 514: Ahead-of-Time Command-Line Ergonomics + */ +public class AotCacheTestExecutionListener extends AbstractTestExecutionListener { + + /** + * The {@link #getOrder() order} value for this listener: {@value}. + * Ordered after {@link org.springframework.test.context.support.CommonCachesTestExecutionListener} + * and before {@link org.springframework.test.context.transaction.TransactionalTestExecutionListener}. + * @since 7.1 + */ + public static final int ORDER = 3006; + + private static final String AOT_CACHE_OUTPUT_FLAG_PREFIX = "-XX:AOTCacheOutput="; + + private static final Log logger = LogFactory.getLog(AotCacheTestExecutionListener.class); + + // Static state: at most one shutdown hook can be registered per JVM. In normal usage, the + // build tool passes a single -XX:AOTCacheOutput path for the entire test run, so one hook + // that verifies that path suffices. + private static final AtomicBoolean shutdownHookRegistered = new AtomicBoolean(); + + /** + * Returns {@value #ORDER}. + */ + @Override + public final int getOrder() { + return ORDER; + } + + @Override + public void beforeTestClass(TestContext testContext) throws Exception { + List jvmArguments = getInputArguments(); + if (!isAotRecordingEnabled(jvmArguments)) { + return; + } + logger.info("AOT cache recording is enabled. Preparing the training workload for test class [" + + testContext.getTestClass().getName() + "]."); + + validateJdkVersion(); + + // Access the ApplicationContext eagerly so that the training workload from context + // creation (bean instantiation, @PostConstruct callbacks, etc.) is captured by the + // JVM's AOT cache mechanism. + ApplicationContext context = testContext.getApplicationContext(); + validateClassLoader(context); + + warnIfExitOnRefresh(); + + String aotCacheOutput = findAotCacheOutput(jvmArguments); + if (aotCacheOutput != null) { + logger.info("Expected AOT cache output path: " + aotCacheOutput); + registerCacheOutputVerification(aotCacheOutput); + } + } + + /** + * Validate that the JDK version supports AOT cache recording. + * @throws IllegalStateException if the JDK version is unsupported + */ + protected void validateJdkVersion() { + int currentVersion = Runtime.version().feature(); + int requiredVersion = getRequiredJavaFeatureVersion(); + if (currentVersion < requiredVersion) { + throw new IllegalStateException(String.format( + "AOT cache recording requires JDK %d or later (JEP 514). " + + "Current JDK version: %d", requiredVersion, currentVersion)); + } + } + + /** + * Validate that the given application context uses a standard JDK class loader. + * @param context the application context + */ + protected void validateClassLoader(ApplicationContext context) { + ClassLoader classLoader = context.getClassLoader(); + if (classLoader != null && !isStandardClassLoader(classLoader)) { + logger.warn(String.format(""" + The ApplicationContext class loader [%s] is not a standard JDK class loader. + AOT cache only caches classes loaded by JDK built-in class loaders (JEP 483). + Use an extracted JAR layout with the standard class loader (for example, \ + Spring Boot's executable JAR unpacking) for the cache to be effective.""", + classLoader.getClass().getName())); + } + } + + /** + * Determine whether the {@code -Dspring.context.exit=onRefresh} flag is configured. + * @return {@code true} if the flag is set to {@code onRefresh} + */ + protected boolean isExitOnRefreshConfigured() { + return "onRefresh".equalsIgnoreCase(SpringProperties.getProperty(DefaultLifecycleProcessor.EXIT_PROPERTY_NAME)); + } + + private void warnIfExitOnRefresh() { + if (isExitOnRefreshConfigured()) { + logger.warn("The '" + DefaultLifecycleProcessor.EXIT_PROPERTY_NAME + "=onRefresh' property is set. " + + "This terminates the JVM when the ApplicationContext refreshes and is not compatible " + + "with generating an AOT cache from integration tests. Remove it from the test JVM arguments."); + } + } + + /** + * Return the minimum JDK feature version required for AOT cache recording. + *

    JDK 25 introduced the single-step {@code -XX:AOTCacheOutput} workflow (JEP 514). + * Override in tests to simulate unsupported JDK versions. + * @return the required JDK feature version (default: 25) + */ + protected int getRequiredJavaFeatureVersion() { + return 25; + } + + /** + * Determine whether the given class loader is a standard JDK class loader + * suitable for AOT cache recording. + *

    Leyden only caches classes loaded by JDK built-in class loaders + * (such as {@code jdk.internal.loader.BuiltinClassLoader} and its + * {@code AppClassLoader} / {@code PlatformClassLoader} subclasses). Custom + * class loaders (for example, Spring Boot's {@code LaunchedURLClassLoader}) + * prevent classes from being cached. + * @param classLoader the class loader to check (never {@code null}) + * @return {@code true} if the class loader is a standard JDK class loader + */ + protected boolean isStandardClassLoader(ClassLoader classLoader) { + String className = classLoader.getClass().getName(); + return className.startsWith("jdk.internal.loader."); + } + + /** + * Determine whether the given JVM arguments enable AOT cache recording via the JDK 25+ + * single-step {@code -XX:AOTCacheOutput} flag. + * @param jvmArguments the JVM command-line arguments + * @return {@code true} if AOT cache recording is enabled + */ + static boolean isAotRecordingEnabled(List jvmArguments) { + for (String argument : jvmArguments) { + if (argument.startsWith(AOT_CACHE_OUTPUT_FLAG_PREFIX)) { + return true; + } + } + return false; + } + + /** + * Return the value of the {@code -XX:AOTCacheOutput} JVM flag, or {@code null} if + * the flag is not present in the given JVM arguments. + * @param jvmArguments the JVM command-line arguments + * @return the AOT cache output path, or {@code null} + */ + static @Nullable String findAotCacheOutput(List jvmArguments) { + for (String argument : jvmArguments) { + if (argument.startsWith(AOT_CACHE_OUTPUT_FLAG_PREFIX)) { + return argument.substring(AOT_CACHE_OUTPUT_FLAG_PREFIX.length()); + } + } + return null; + } + + /** + * Return the JVM command-line arguments, excluding the arguments passed to the + * {@code main} method. + *

    Exposed for testing purposes. + * @return the JVM command-line arguments + */ + protected List getInputArguments() { + return ManagementFactory.getRuntimeMXBean().getInputArguments(); + } + + private void registerCacheOutputVerification(String outputPath) { + if (shutdownHookRegistered.compareAndSet(false, true)) { + Runtime.getRuntime().addShutdownHook(new Thread(() -> verifyCacheOutput(outputPath))); + } + } + + /** + * Verify that the AOT cache file was created at the given output path. + * @param outputPath the expected AOT cache output path + * @return {@code true} if the cache file exists + */ + boolean verifyCacheOutput(String outputPath) { + File cacheFile = new File(outputPath); + if (cacheFile.exists()) { + logger.info("AOT cache file created successfully: " + cacheFile.getAbsolutePath() + + " (" + cacheFile.length() + " bytes)"); + return true; + } + else { + logger.warn("AOT cache file was NOT created at: " + cacheFile.getAbsolutePath() + + ". Verify that the JVM was started with '-XX:AOTCacheOutput=' (JDK 25+) " + + "and that the application uses the standard JDK class loader with an extracted JAR layout."); + return false; + } + } + +} diff --git a/spring-test/src/main/resources/META-INF/spring.factories b/spring-test/src/main/resources/META-INF/spring.factories index 344b3ecb2074..b9b89435651e 100644 --- a/spring-test/src/main/resources/META-INF/spring.factories +++ b/spring-test/src/main/resources/META-INF/spring.factories @@ -12,7 +12,8 @@ org.springframework.test.context.TestExecutionListener = \ org.springframework.test.context.transaction.TransactionalTestExecutionListener,\ org.springframework.test.context.jdbc.SqlScriptsTestExecutionListener,\ org.springframework.test.context.event.EventPublishingTestExecutionListener,\ - org.springframework.test.context.bean.override.mockito.MockitoResetTestExecutionListener + org.springframework.test.context.bean.override.mockito.MockitoResetTestExecutionListener,\ + org.springframework.test.context.aot.AotCacheTestExecutionListener # Default ContextCustomizerFactory implementations for the Spring TestContext Framework # diff --git a/spring-test/src/test/java/org/springframework/test/context/TestExecutionListenersTests.java b/spring-test/src/test/java/org/springframework/test/context/TestExecutionListenersTests.java index 36f31894c5e6..f6f96ee5cf00 100644 --- a/spring-test/src/test/java/org/springframework/test/context/TestExecutionListenersTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/TestExecutionListenersTests.java @@ -25,6 +25,7 @@ import org.springframework.core.Ordered; import org.springframework.core.annotation.AliasFor; import org.springframework.core.annotation.AnnotationConfigurationException; +import org.springframework.test.context.aot.AotCacheTestExecutionListener; import org.springframework.test.context.bean.override.BeanOverrideTestExecutionListener; import org.springframework.test.context.bean.override.mockito.MockitoResetTestExecutionListener; import org.springframework.test.context.event.ApplicationEventsTestExecutionListener; @@ -73,6 +74,7 @@ void defaultListeners() { MICROMETER_LISTENER_CLASS,// DirtiesContextTestExecutionListener.class,// CommonCachesTestExecutionListener.class, // + AotCacheTestExecutionListener.class,// TransactionalTestExecutionListener.class,// SqlScriptsTestExecutionListener.class,// EventPublishingTestExecutionListener.class,// @@ -96,6 +98,7 @@ void defaultListenersMergedWithCustomListenerPrepended() { MICROMETER_LISTENER_CLASS,// DirtiesContextTestExecutionListener.class,// CommonCachesTestExecutionListener.class, // + AotCacheTestExecutionListener.class,// TransactionalTestExecutionListener.class,// SqlScriptsTestExecutionListener.class,// EventPublishingTestExecutionListener.class,// @@ -118,6 +121,7 @@ void defaultListenersMergedWithCustomListenerAppended() { MICROMETER_LISTENER_CLASS,// DirtiesContextTestExecutionListener.class,// CommonCachesTestExecutionListener.class, // + AotCacheTestExecutionListener.class, TransactionalTestExecutionListener.class, SqlScriptsTestExecutionListener.class,// EventPublishingTestExecutionListener.class,// @@ -142,6 +146,7 @@ void defaultListenersMergedWithCustomListenerInserted() { MICROMETER_LISTENER_CLASS,// DirtiesContextTestExecutionListener.class,// CommonCachesTestExecutionListener.class, // + AotCacheTestExecutionListener.class,// TransactionalTestExecutionListener.class,// SqlScriptsTestExecutionListener.class,// EventPublishingTestExecutionListener.class,// diff --git a/spring-test/src/test/java/org/springframework/test/context/aot/AotCacheTestExecutionListenerTests.java b/spring-test/src/test/java/org/springframework/test/context/aot/AotCacheTestExecutionListenerTests.java new file mode 100644 index 000000000000..25fc1d9892e3 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/aot/AotCacheTestExecutionListenerTests.java @@ -0,0 +1,195 @@ +/* + * Copyright 2002-present 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.test.context.aot; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import org.springframework.context.ApplicationContext; +import org.springframework.context.support.DefaultLifecycleProcessor; +import org.springframework.core.SpringProperties; +import org.springframework.test.context.TestContext; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +/** + * Tests for {@link AotCacheTestExecutionListener}. + * + * @author Vasily Pelikh + * @since 7.1 + */ +class AotCacheTestExecutionListenerTests { + + private final AotCacheTestExecutionListener listener = new AotCacheTestExecutionListener(); + + @AfterEach + void clearProperties() { + SpringProperties.setProperty(DefaultLifecycleProcessor.EXIT_PROPERTY_NAME, null); + } + + @Test + void orderValue() { + assertThat(listener.getOrder()).isEqualTo(3006); + } + + @Test + void beforeTestClassWhenRecordingIsNotEnabled() throws Exception { + TestContext testContext = mock(); + listener.beforeTestClass(testContext); + verify(testContext, never()).getApplicationContext(); + } + + @Test + void beforeTestClassWhenAotCacheOutputFlagIsPresent() throws Exception { + ApplicationContext applicationContext = mock(); + TestContext testContext = mock(); + given((Class) testContext.getTestClass()).willReturn((Class) AotCacheTestExecutionListenerTests.class); + given(testContext.getApplicationContext()).willReturn(applicationContext); + + AotCacheTestExecutionListener recordingListener = listenerWithArgs("-XX:AOTCacheOutput=build/app.aot"); + recordingListener.beforeTestClass(testContext); + + verify(testContext).getApplicationContext(); + } + + @Test + void beforeTestClassWhenJdkVersionIsUnsupported() { + AotCacheTestExecutionListener unsupportedListener = new AotCacheTestExecutionListener() { + @Override + protected List getInputArguments() { + return List.of("-XX:AOTCacheOutput=build/app.aot"); + } + + @Override + protected int getRequiredJavaFeatureVersion() { + return 9999; + } + }; + TestContext testContext = mock(); + given((Class) testContext.getTestClass()).willReturn((Class) AotCacheTestExecutionListenerTests.class); + + assertThatIllegalStateException() + .isThrownBy(() -> unsupportedListener.beforeTestClass(testContext)) + .withMessageContaining("JDK"); + verify(testContext, never()).getApplicationContext(); + } + + @Test + void beforeTestClassWhenClassLoaderIsNotStandard() throws Exception { + AotCacheTestExecutionListener warnListener = new AotCacheTestExecutionListener() { + @Override + protected List getInputArguments() { + return List.of("-XX:AOTCacheOutput=build/app.aot"); + } + + @Override + protected boolean isStandardClassLoader(ClassLoader classLoader) { + return false; + } + }; + ApplicationContext applicationContext = mock(); + given(applicationContext.getClassLoader()).willReturn(AotCacheTestExecutionListenerTests.class.getClassLoader()); + TestContext testContext = mock(); + given((Class) testContext.getTestClass()).willReturn((Class) AotCacheTestExecutionListenerTests.class); + given(testContext.getApplicationContext()).willReturn(applicationContext); + + warnListener.beforeTestClass(testContext); + + verify(testContext).getApplicationContext(); + } + + @Test + void isExitOnRefreshConfiguredWhenPropertyIsSet() { + SpringProperties.setProperty(DefaultLifecycleProcessor.EXIT_PROPERTY_NAME, "onRefresh"); + assertThat(listener.isExitOnRefreshConfigured()).isTrue(); + } + + @Test + void isExitOnRefreshConfiguredWhenPropertyIsNotSet() { + assertThat(listener.isExitOnRefreshConfigured()).isFalse(); + } + + @Test + void isAotRecordingEnabledWhenAotCacheOutputFlagIsPresent() { + assertThat(AotCacheTestExecutionListener.isAotRecordingEnabled( + List.of("-Xmx512m", "-XX:AOTCacheOutput=build/app.aot", "-jar", "app.jar"))).isTrue(); + } + + @Test + void isAotRecordingEnabledWhenAotModeRecordFlagIsPresentIsNotSupported() { + // JDK 24 two-step record mode is not supported; only the JDK 25+ single-step flag + assertThat(AotCacheTestExecutionListener.isAotRecordingEnabled( + List.of("-XX:AOTMode=record", "-XX:AOTConfiguration=build/app.aotconf"))).isFalse(); + } + + @Test + void isAotRecordingEnabledWhenNoRecordingFlagIsPresent() { + assertThat(AotCacheTestExecutionListener.isAotRecordingEnabled( + List.of("-Xmx512m", "-jar", "app.jar"))).isFalse(); + } + + @Test + void isAotRecordingEnabledWhenAotModeCreateFlagIsPresent() { + // -XX:AOTMode=create alone does not record a training run + assertThat(AotCacheTestExecutionListener.isAotRecordingEnabled( + List.of("-XX:AOTMode=create", "-XX:AOTCache=build/app.aot"))).isFalse(); + } + + @Test + void findAotCacheOutputWhenFlagIsPresent() { + assertThat(AotCacheTestExecutionListener.findAotCacheOutput( + List.of("-Xmx512m", "-XX:AOTCacheOutput=build/app.aot"))).isEqualTo("build/app.aot"); + } + + @Test + void findAotCacheOutputWhenFlagIsAbsent() { + assertThat(AotCacheTestExecutionListener.findAotCacheOutput(List.of("-Xmx512m"))).isNull(); + } + + @Test + void verifyCacheOutputWhenFileExists(@TempDir Path tempDir) throws Exception { + Path cacheFile = tempDir.resolve("app.aot"); + Files.writeString(cacheFile, "test"); + assertThat(listener.verifyCacheOutput(cacheFile.toString())).isTrue(); + } + + @Test + void verifyCacheOutputWhenFileDoesNotExist() { + assertThat(listener.verifyCacheOutput("does-not-exist.aot")).isFalse(); + } + + private AotCacheTestExecutionListener listenerWithArgs(String... args) { + List inputArgs = List.of(args); + return new AotCacheTestExecutionListener() { + @Override + protected List getInputArguments() { + return inputArgs; + } + }; + } + +}