diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/testing.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/testing.adoc index ae6539d2db..ceedf86dc4 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/testing.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/testing.adoc @@ -137,6 +137,44 @@ Therefore, as long as your tests share the same configuration (no matter how it +[[features.testing.spring-boot-applications.using-main]] +==== Using the Test Configuration Main Method +Typically the test configuration discovered by `@SpringBootTest` will be your main `@SpringBootApplication`. +In most well structured applications, this configuration class will also include the `main` method used to launch the application. + +For example, the following is a very common code pattern for a typical Spring Boot application: + +include::code:typical/MyApplication[] + +In the example above, the `main` method doesn't do anything other than delegate to `SpringApplication.run`. +It is, however, possible to have a more complex `main` method that applies customizations before calling `SpringApplication.run`. + +For example, here is an application that changes the banner mode and sets additional profiles: + +include::code:custom/MyApplication[] + +Since customizations in the `main` method can affect the resulting `ApplicationContext`, Spring Boot will also attempt to use the `main` method for tests. +By default, `@SpringBootTest` will detect any `main` method on your `@SpringBootConfiguration` and run it up to the point that the `SpringApplication.run` method is called. +If your `@SpringBootConfiguration` class doesn't have a main method, the class itself is used directly to create the `ApplicationContext`. + +In some situations, you may find that you can't or don't want to run the `main` method in your tests. +If that's the case, you can change the `useMainMethod` attribute of `@SpringBootTest` to `UseMainMethod.NEVER`. + +For example, you might have the following application class: + +include::code:never/MyApplication[] + +If a test wants to use the `MyApplication` configuration without calling the main method, it can be written as follows: + +include::code:never/MyApplicationTests[] + +The test above will still use `MyApplication` to create the `ApplicationContext`, however, it won't call `MyCode.expensiveOperation()` since the `main` method is not invoked. + +If you want to do the opposite, and ensure that the `main` method is always invoked, you can set the `useMainMethod` attribute of `@SpringBootTest` to `UseMainMethod.ALWAYS`. +If this property is set, and no `main` method can be found the test will fail. + + + [[features.testing.spring-boot-applications.excluding-configuration]] ==== Excluding Test Configuration If your application uses component scanning (for example, if you use `@SpringBootApplication` or `@ComponentScan`), you may find top-level configuration classes that you created only for specific tests accidentally get picked up everywhere. diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/springbootapplications/usingmain/custom/MyApplication.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/springbootapplications/usingmain/custom/MyApplication.java new file mode 100644 index 0000000000..26a9687fb8 --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/springbootapplications/usingmain/custom/MyApplication.java @@ -0,0 +1,33 @@ +/* + * Copyright 2012-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.boot.docs.features.testing.springbootapplications.usingmain.custom; + +import org.springframework.boot.Banner; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class MyApplication { + + public static void main(String[] args) { + SpringApplication application = new SpringApplication(MyApplication.class); + application.setBannerMode(Banner.Mode.OFF); + application.setAdditionalProfiles("myprofile"); + application.run(args); + } + +} diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/springbootapplications/usingmain/never/MyApplication.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/springbootapplications/usingmain/never/MyApplication.java new file mode 100644 index 0000000000..330e363921 --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/springbootapplications/usingmain/never/MyApplication.java @@ -0,0 +1,30 @@ +/* + * Copyright 2012-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.boot.docs.features.testing.springbootapplications.usingmain.never; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class MyApplication { + + public static void main(String[] args) { + MyCode.expensiveOperation(); + SpringApplication.run(MyApplication.class, args); + } + +} diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/springbootapplications/usingmain/never/MyApplicationTests.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/springbootapplications/usingmain/never/MyApplicationTests.java new file mode 100644 index 0000000000..e16c5546c0 --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/springbootapplications/usingmain/never/MyApplicationTests.java @@ -0,0 +1,32 @@ +/* + * Copyright 2012-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.boot.docs.features.testing.springbootapplications.usingmain.never; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.UseMainMethod; + +@SpringBootTest(useMainMethod = UseMainMethod.NEVER) +public class MyApplicationTests { + + @Test + void exampleTest() { + // ... + } + +} diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/springbootapplications/usingmain/never/MyCode.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/springbootapplications/usingmain/never/MyCode.java new file mode 100644 index 0000000000..87dbeb4dd7 --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/springbootapplications/usingmain/never/MyCode.java @@ -0,0 +1,27 @@ +/* + * Copyright 2012-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.boot.docs.features.testing.springbootapplications.usingmain.never; + +public final class MyCode { + + private MyCode() { + } + + static void expensiveOperation() { + } + +} diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/springbootapplications/usingmain/typical/MyApplication.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/springbootapplications/usingmain/typical/MyApplication.java new file mode 100644 index 0000000000..1c22341019 --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/springbootapplications/usingmain/typical/MyApplication.java @@ -0,0 +1,29 @@ +/* + * Copyright 2012-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.boot.docs.features.testing.springbootapplications.usingmain.typical; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class MyApplication { + + public static void main(String[] args) { + SpringApplication.run(MyApplication.class, args); + } + +} diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testing/springbootapplications/usingmain/custom/MyApplication.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testing/springbootapplications/usingmain/custom/MyApplication.kt new file mode 100644 index 0000000000..53f8a371c7 --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testing/springbootapplications/usingmain/custom/MyApplication.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2012-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.boot.docs.features.testing.springbootapplications.usingmain.custom + +import org.springframework.boot.Banner +import org.springframework.boot.runApplication +import org.springframework.boot.autoconfigure.SpringBootApplication + +@SpringBootApplication +class MyApplication + +fun main(args: Array) { + runApplication(*args) { + setBannerMode(Banner.Mode.OFF) + setAdditionalProfiles("myprofile"); + } +} diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testing/springbootapplications/usingmain/never/MyApplication.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testing/springbootapplications/usingmain/never/MyApplication.kt new file mode 100644 index 0000000000..346371f62e --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testing/springbootapplications/usingmain/never/MyApplication.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2012-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.boot.docs.features.testing.springbootapplications.usingmain.custom.never + +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.docs.using.structuringyourcode.locatingthemainclass.MyApplication +import org.springframework.boot.runApplication + +@SpringBootApplication +class MyApplication + +fun main(args: Array) { + MyCode.expensiveOperation() + runApplication(*args) +} diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testing/springbootapplications/usingmain/never/MyApplicationTests.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testing/springbootapplications/usingmain/never/MyApplicationTests.kt new file mode 100644 index 0000000000..d6d27947f2 --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testing/springbootapplications/usingmain/never/MyApplicationTests.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2012-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.boot.docs.features.testing.springbootapplications.usingmain.custom.never + +import org.junit.jupiter.api.Test +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.context.SpringBootTest.UseMainMethod +import org.springframework.context.annotation.Import + +@SpringBootTest(useMainMethod = UseMainMethod.NEVER) +class MyApplicationTests { + + @Test + fun exampleTest() { + // ... + } + +} diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testing/springbootapplications/usingmain/never/MyCode.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testing/springbootapplications/usingmain/never/MyCode.kt new file mode 100644 index 0000000000..33b1de3af1 --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testing/springbootapplications/usingmain/never/MyCode.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2012-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.boot.docs.features.testing.springbootapplications.usingmain.custom.never + +class MyCode { + companion object { + fun expensiveOperation() { + } + } +} diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testing/springbootapplications/usingmain/typical/MyApplication.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testing/springbootapplications/usingmain/typical/MyApplication.kt new file mode 100644 index 0000000000..38758f47bd --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testing/springbootapplications/usingmain/typical/MyApplication.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2012-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.boot.docs.features.testing.springbootapplications.usingmain.typical + +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.docs.using.structuringyourcode.locatingthemainclass.MyApplication +import org.springframework.boot.runApplication + +@SpringBootApplication +class MyApplication + +fun main(args: Array) { + runApplication(*args) +} diff --git a/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/context/SpringBootContextLoader.java b/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/context/SpringBootContextLoader.java index 98dd2ce673..f5d6da2c38 100644 --- a/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/context/SpringBootContextLoader.java +++ b/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/context/SpringBootContextLoader.java @@ -16,16 +16,23 @@ package org.springframework.boot.test.context; +import java.lang.reflect.Method; +import java.time.Duration; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import org.springframework.beans.BeanUtils; import org.springframework.boot.ApplicationContextFactory; +import org.springframework.boot.ConfigurableBootstrapContext; import org.springframework.boot.SpringApplication; +import org.springframework.boot.SpringApplication.AbandonedRunException; +import org.springframework.boot.SpringApplicationHook; +import org.springframework.boot.SpringApplicationRunListener; +import org.springframework.boot.SpringBootConfiguration; import org.springframework.boot.WebApplicationType; import org.springframework.boot.context.event.ApplicationEnvironmentPreparedEvent; -import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.context.SpringBootTest.UseMainMethod; import org.springframework.boot.test.mock.web.SpringBootMockServletContext; import org.springframework.boot.test.util.TestPropertyValues; import org.springframework.boot.test.util.TestPropertyValues.Type; @@ -54,7 +61,9 @@ import org.springframework.test.context.support.TestPropertySourceUtils; import org.springframework.test.context.web.WebMergedContextConfiguration; import org.springframework.util.Assert; import org.springframework.util.ObjectUtils; +import org.springframework.util.ReflectionUtils; import org.springframework.util.StringUtils; +import org.springframework.util.function.ThrowingSupplier; import org.springframework.web.context.ConfigurableWebApplicationContext; import org.springframework.web.context.support.GenericWebApplicationContext; @@ -86,7 +95,47 @@ public class SpringBootContextLoader extends AbstractContextLoader { @Override public ApplicationContext loadContext(MergedContextConfiguration mergedConfig) throws Exception { assertHasClassesOrLocations(mergedConfig); + SpringBootTestAnnotation annotation = SpringBootTestAnnotation.get(mergedConfig); + String[] args = annotation.getArgs(); + UseMainMethod useMainMethod = annotation.getUseMainMethod(); + ContextLoaderHook hook = new ContextLoaderHook(mergedConfig); + if (useMainMethod != UseMainMethod.NEVER) { + Method mainMethod = getMainMethod(mergedConfig, useMainMethod); + if (mainMethod != null) { + return hook.run(() -> ReflectionUtils.invokeMethod(mainMethod, null, new Object[] { args })); + } + } SpringApplication application = getSpringApplication(); + return hook.run(() -> application.run(args)); + } + + private void assertHasClassesOrLocations(MergedContextConfiguration mergedConfig) { + boolean hasClasses = !ObjectUtils.isEmpty(mergedConfig.getClasses()); + boolean hasLocations = !ObjectUtils.isEmpty(mergedConfig.getLocations()); + Assert.state(hasClasses || hasLocations, + () -> "No configuration classes or locations found in @SpringApplicationConfiguration. " + + "For default configuration detection to work you need Spring 4.0.3 or better (found " + + SpringVersion.getVersion() + ")."); + } + + private Method getMainMethod(MergedContextConfiguration mergedConfig, UseMainMethod useMainMethod) { + Class springBootConfiguration = Arrays.stream(mergedConfig.getClasses()) + .filter(this::isSpringBootConfiguration).findFirst().orElse(null); + Assert.state(springBootConfiguration != null || useMainMethod == UseMainMethod.WHEN_AVAILABLE, + "Cannot use main method as no @SpringBootConfiguration-annotated class is available"); + Method mainMethod = (springBootConfiguration != null) + ? ReflectionUtils.findMethod(springBootConfiguration, "main", String[].class) : null; + Assert.state(mainMethod != null || useMainMethod == UseMainMethod.WHEN_AVAILABLE, + () -> "Main method not found on '%s'".formatted(springBootConfiguration.getName())); + return mainMethod; + } + + private boolean isSpringBootConfiguration(Class candidate) { + return MergedAnnotations.from(candidate, SearchStrategy.TYPE_HIERARCHY) + .isPresent(SpringBootConfiguration.class); + } + + private void configure(MergedContextConfiguration mergedConfig, SpringApplication application) { application.setMainApplicationClass(mergedConfig.getTestClass()); application.addPrimarySources(Arrays.asList(mergedConfig.getClasses())); application.getSources().addAll(Arrays.asList(mergedConfig.getLocations())); @@ -103,7 +152,8 @@ public class SpringBootContextLoader extends AbstractContextLoader { else { application.setWebApplicationType(WebApplicationType.NONE); } - application.setApplicationContextFactory((type) -> getApplicationContextFactory(mergedConfig, type)); + application.setApplicationContextFactory( + (webApplicationType) -> getApplicationContextFactory(mergedConfig, webApplicationType)); application.setInitializers(initializers); ConfigurableEnvironment environment = getEnvironment(); if (environment != null) { @@ -113,30 +163,19 @@ public class SpringBootContextLoader extends AbstractContextLoader { else { application.addListeners(new PrepareEnvironmentListener(mergedConfig)); } - String[] args = SpringBootTestArgs.get(mergedConfig.getContextCustomizers()); - return application.run(args); - } - - private void assertHasClassesOrLocations(MergedContextConfiguration mergedConfig) { - boolean hasClasses = !ObjectUtils.isEmpty(mergedConfig.getClasses()); - boolean hasLocations = !ObjectUtils.isEmpty(mergedConfig.getLocations()); - Assert.state(hasClasses || hasLocations, - () -> "No configuration classes or locations found in @SpringApplicationConfiguration. " - + "For default configuration detection to work you need Spring 4.0.3 or better (found " - + SpringVersion.getVersion() + ")."); } private ConfigurableApplicationContext getApplicationContextFactory(MergedContextConfiguration mergedConfig, - WebApplicationType type) { - if (type != WebApplicationType.NONE && !isEmbeddedWebEnvironment(mergedConfig)) { - if (type == WebApplicationType.REACTIVE) { + WebApplicationType webApplicationType) { + if (webApplicationType != WebApplicationType.NONE && !isEmbeddedWebEnvironment(mergedConfig)) { + if (webApplicationType == WebApplicationType.REACTIVE) { return new GenericReactiveWebApplicationContext(); } - if (type == WebApplicationType.SERVLET) { + if (webApplicationType == WebApplicationType.SERVLET) { return new GenericWebApplicationContext(); } } - return ApplicationContextFactory.DEFAULT.create(type); + return ApplicationContextFactory.DEFAULT.create(webApplicationType); } private void prepareEnvironment(MergedContextConfiguration mergedConfig, SpringApplication application, @@ -165,9 +204,10 @@ public class SpringBootContextLoader extends AbstractContextLoader { } /** - * Builds new {@link org.springframework.boot.SpringApplication} instance. You can - * override this method to add custom behavior - * @return {@link org.springframework.boot.SpringApplication} instance + * Builds new {@link org.springframework.boot.SpringApplication} instance. This method + * is only called when a {@code main} method isn't being used to create the + * {@link SpringApplication}. + * @return a {@link SpringApplication} instance */ protected SpringApplication getSpringApplication() { return new SpringApplication(); @@ -215,16 +255,14 @@ public class SpringBootContextLoader extends AbstractContextLoader { initializers.add(BeanUtils.instantiateClass(initializerClass)); } if (mergedConfig.getParent() != null) { - initializers - .add(new ParentContextApplicationContextInitializer(mergedConfig.getParentApplicationContext())); + ApplicationContext parentApplicationContext = mergedConfig.getParentApplicationContext(); + initializers.add(new ParentContextApplicationContextInitializer(parentApplicationContext)); } return initializers; } private boolean isEmbeddedWebEnvironment(MergedContextConfiguration mergedConfig) { - return MergedAnnotations.from(mergedConfig.getTestClass(), SearchStrategy.TYPE_HIERARCHY) - .get(SpringBootTest.class).getValue("webEnvironment", WebEnvironment.class).orElse(WebEnvironment.NONE) - .isEmbedded(); + return SpringBootTestAnnotation.get(mergedConfig).getWebEnvironment().isEmbedded(); } @Override @@ -371,4 +409,45 @@ public class SpringBootContextLoader extends AbstractContextLoader { } + /** + * {@link SpringApplicationHook} used to capture the {@link ApplicationContext} and to + * trigger early exit for the {@link Mode#AOT_PROCESSING} mode. + */ + private class ContextLoaderHook implements SpringApplicationHook { + + private final MergedContextConfiguration mergedConfig; + + ContextLoaderHook(MergedContextConfiguration mergedConfig) { + this.mergedConfig = mergedConfig; + } + + @Override + public SpringApplicationRunListener getRunListener(SpringApplication application) { + return new SpringApplicationRunListener() { + + @Override + public void starting(ConfigurableBootstrapContext bootstrapContext) { + SpringBootContextLoader.this.configure(ContextLoaderHook.this.mergedConfig, application); + } + + @Override + public void ready(ConfigurableApplicationContext context, Duration timeTaken) { + throw new AbandonedRunException(context); + } + + }; + } + + private ApplicationContext run(ThrowingSupplier action) { + try { + SpringApplication.withHook(this, action); + throw new IllegalStateException("ApplicationContext not loaded"); + } + catch (AbandonedRunException ex) { + return ex.getApplicationContext(); + } + } + + } + } diff --git a/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/context/SpringBootTest.java b/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/context/SpringBootTest.java index 6fed9ffb26..70bb75535f 100644 --- a/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/context/SpringBootTest.java +++ b/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/context/SpringBootTest.java @@ -125,6 +125,14 @@ public @interface SpringBootTest { */ WebEnvironment webEnvironment() default WebEnvironment.MOCK; + /** + * The type of main method usage to employ when creating the {@link SpringApplication} + * under test. + * @return the type of main method usage + * @since 3.0.0 + */ + UseMainMethod useMainMethod() default UseMainMethod.WHEN_AVAILABLE; + /** * An enumeration web environment modes. */ @@ -175,4 +183,34 @@ public @interface SpringBootTest { } + /** + * Enumeration of how the main method of the + * {@link SpringBootConfiguration @SpringBootConfiguration}-annotated class is used + * when creating and running the {@link SpringApplication} under test. + */ + enum UseMainMethod { + + /** + * Always use the {@code main} method. A failure will occur if there is no + * {@link SpringBootConfiguration @SpringBootConfiguration}-annotated class or + * that class does not have a main method. + */ + ALWAYS, + + /** + * Never use the {@code main} method, creating a test-specific + * {@link SpringApplication} instead. + */ + NEVER, + + /** + * Use the {@code main} method when it is available. If there is no + * {@link SpringBootConfiguration @SpringBootConfiguration}-annotated class or + * that class does not have a main method, a test-specific + * {@link SpringApplication} will be used. + */ + WHEN_AVAILABLE; + + } + } diff --git a/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/context/SpringBootTestAnnotation.java b/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/context/SpringBootTestAnnotation.java new file mode 100644 index 0000000000..26a97984e8 --- /dev/null +++ b/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/context/SpringBootTestAnnotation.java @@ -0,0 +1,115 @@ +/* + * Copyright 2012-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.boot.test.context; + +import java.util.Arrays; +import java.util.Objects; + +import org.springframework.boot.test.context.SpringBootTest.UseMainMethod; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.test.context.ContextCustomizer; +import org.springframework.test.context.MergedContextConfiguration; +import org.springframework.test.context.TestContextAnnotationUtils; + +/** + * {@link ContextCustomizer} to track attributes of + * {@link SpringBootTest @SptringBootTest} that are taken into account when evaluating a + * {@link MergedContextConfiguration} to determine if a context can be shared between + * tests. + * + * @author Phillip Webb + * @author Madhura Bhave + * @author Andy Wilkinson + */ +class SpringBootTestAnnotation implements ContextCustomizer { + + private static final String[] NO_ARGS = new String[0]; + + private static final SpringBootTestAnnotation DEFAULT = new SpringBootTestAnnotation((SpringBootTest) null); + + private final String[] args; + + private final WebEnvironment webEnvironment; + + private final UseMainMethod useMainMethod; + + SpringBootTestAnnotation(Class testClass) { + this(TestContextAnnotationUtils.findMergedAnnotation(testClass, SpringBootTest.class)); + } + + private SpringBootTestAnnotation(SpringBootTest annotation) { + this.args = (annotation != null) ? annotation.args() : NO_ARGS; + this.webEnvironment = (annotation != null) ? annotation.webEnvironment() : WebEnvironment.NONE; + this.useMainMethod = (annotation != null) ? annotation.useMainMethod() : UseMainMethod.WHEN_AVAILABLE; + } + + @Override + public void customizeContext(ConfigurableApplicationContext context, MergedContextConfiguration mergedConfig) { + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + SpringBootTestAnnotation other = (SpringBootTestAnnotation) obj; + boolean result = Arrays.equals(this.args, other.args); + result = result && this.useMainMethod == other.useMainMethod; + result = result && this.webEnvironment == other.webEnvironment; + return result; + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + Arrays.hashCode(this.args); + result = prime * result + Objects.hash(this.useMainMethod, this.webEnvironment); + return result; + } + + String[] getArgs() { + return this.args; + } + + WebEnvironment getWebEnvironment() { + return this.webEnvironment; + } + + UseMainMethod getUseMainMethod() { + return this.useMainMethod; + } + + /** + * Return the application arguments from the given {@link MergedContextConfiguration}. + * @param mergedConfig the merged config to check + * @return a {@link SpringBootTestAnnotation} instance + */ + static SpringBootTestAnnotation get(MergedContextConfiguration mergedConfig) { + for (ContextCustomizer customizer : mergedConfig.getContextCustomizers()) { + if (customizer instanceof SpringBootTestAnnotation annotation) { + return annotation; + } + } + return DEFAULT; + } + +} diff --git a/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/context/SpringBootTestArgs.java b/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/context/SpringBootTestArgs.java deleted file mode 100644 index 64e91d38fc..0000000000 --- a/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/context/SpringBootTestArgs.java +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Copyright 2012-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.boot.test.context; - -import java.util.Arrays; -import java.util.Set; - -import org.springframework.context.ConfigurableApplicationContext; -import org.springframework.core.annotation.MergedAnnotations; -import org.springframework.test.context.ContextCustomizer; -import org.springframework.test.context.MergedContextConfiguration; - -/** - * {@link ContextCustomizer} to track application arguments that are used in a - * {@link SpringBootTest}. The application arguments are taken into account when - * evaluating a {@link MergedContextConfiguration} to determine if a context can be shared - * between tests. - * - * @author Madhura Bhave - */ -class SpringBootTestArgs implements ContextCustomizer { - - private static final String[] NO_ARGS = new String[0]; - - private final String[] args; - - SpringBootTestArgs(Class testClass) { - this.args = MergedAnnotations.from(testClass, MergedAnnotations.SearchStrategy.TYPE_HIERARCHY) - .get(SpringBootTest.class).getValue("args", String[].class).orElse(NO_ARGS); - } - - @Override - public void customizeContext(ConfigurableApplicationContext context, MergedContextConfiguration mergedConfig) { - } - - String[] getArgs() { - return this.args; - } - - @Override - public boolean equals(Object obj) { - return (obj != null) && (getClass() == obj.getClass()) - && Arrays.equals(this.args, ((SpringBootTestArgs) obj).args); - } - - @Override - public int hashCode() { - return Arrays.hashCode(this.args); - } - - /** - * Return the application arguments from the given customizers. - * @param customizers the customizers to check - * @return the application args or an empty array - */ - static String[] get(Set customizers) { - for (ContextCustomizer customizer : customizers) { - if (customizer instanceof SpringBootTestArgs testArgs) { - return testArgs.args; - } - } - return NO_ARGS; - } - -} diff --git a/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/context/SpringBootTestContextBootstrapper.java b/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/context/SpringBootTestContextBootstrapper.java index a74742d124..c9ae36ba3b 100644 --- a/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/context/SpringBootTestContextBootstrapper.java +++ b/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/context/SpringBootTestContextBootstrapper.java @@ -362,8 +362,7 @@ public class SpringBootTestContextBootstrapper extends DefaultTestContextBootstr protected final MergedContextConfiguration createModifiedConfig(MergedContextConfiguration mergedConfig, Class[] classes, String[] propertySourceProperties) { Set contextCustomizers = new LinkedHashSet<>(mergedConfig.getContextCustomizers()); - contextCustomizers.add(new SpringBootTestArgs(mergedConfig.getTestClass())); - contextCustomizers.add(new SpringBootTestWebEnvironment(mergedConfig.getTestClass())); + contextCustomizers.add(new SpringBootTestAnnotation(mergedConfig.getTestClass())); return new MergedContextConfiguration(mergedConfig.getTestClass(), mergedConfig.getLocations(), classes, mergedConfig.getContextInitializerClasses(), mergedConfig.getActiveProfiles(), mergedConfig.getPropertySourceLocations(), propertySourceProperties, contextCustomizers, diff --git a/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/context/SpringBootTestWebEnvironment.java b/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/context/SpringBootTestWebEnvironment.java deleted file mode 100644 index 39d91fdebf..0000000000 --- a/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/context/SpringBootTestWebEnvironment.java +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Copyright 2012-2020 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.boot.test.context; - -import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; -import org.springframework.context.ConfigurableApplicationContext; -import org.springframework.test.context.ContextCustomizer; -import org.springframework.test.context.MergedContextConfiguration; -import org.springframework.test.context.TestContextAnnotationUtils; - -/** - * {@link ContextCustomizer} to track the web environment that is used in a - * {@link SpringBootTest}. The web environment is taken into account when evaluating a - * {@link MergedContextConfiguration} to determine if a context can be shared between - * tests. - * - * @author Andy Wilkinson - */ -class SpringBootTestWebEnvironment implements ContextCustomizer { - - private final WebEnvironment webEnvironment; - - SpringBootTestWebEnvironment(Class testClass) { - SpringBootTest sprintBootTest = TestContextAnnotationUtils.findMergedAnnotation(testClass, - SpringBootTest.class); - this.webEnvironment = (sprintBootTest != null) ? sprintBootTest.webEnvironment() : null; - } - - @Override - public void customizeContext(ConfigurableApplicationContext context, MergedContextConfiguration mergedConfig) { - } - - @Override - public boolean equals(Object obj) { - return (obj != null) && (getClass() == obj.getClass()) - && this.webEnvironment == ((SpringBootTestWebEnvironment) obj).webEnvironment; - } - - @Override - public int hashCode() { - return (this.webEnvironment != null) ? this.webEnvironment.hashCode() : 0; - } - -} diff --git a/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/context/SpringBootContextLoaderTests.java b/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/context/SpringBootContextLoaderTests.java index 0ca328598c..53dd092ae4 100644 --- a/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/context/SpringBootContextLoaderTests.java +++ b/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/context/SpringBootContextLoaderTests.java @@ -23,6 +23,9 @@ import java.util.stream.Collectors; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.test.context.SpringBootTest.UseMainMethod; import org.springframework.boot.test.util.TestPropertyValues; import org.springframework.boot.web.reactive.context.GenericReactiveWebApplicationContext; import org.springframework.context.ApplicationContext; @@ -40,6 +43,7 @@ import org.springframework.test.util.ReflectionTestUtils; import org.springframework.web.context.WebApplicationContext; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; /** * Tests for {@link SpringBootContextLoader} @@ -114,8 +118,7 @@ class SpringBootContextLoaderTests { assertThat(getActiveProfiles(ActiveProfileWithComma.class)).containsExactly("profile1,2"); } - @Test - // gh-28776 + @Test // gh-28776 void testPropertyValuesShouldTakePrecedenceWhenInlinedPropertiesPresent() { TestContext context = new ExposedTestContextManager(SimpleConfig.class).getExposedTestContext(); StandardEnvironment environment = (StandardEnvironment) context.getApplicationContext().getEnvironment(); @@ -162,6 +165,37 @@ class SpringBootContextLoaderTests { assertThat(context.getApplicationContext()).isInstanceOf(GenericReactiveWebApplicationContext.class); } + @Test + void whenUseMainMethodAlwaysAndMainMethodThrowsException() { + TestContext testContext = new ExposedTestContextManager(UseMainMethodAlwaysAndMainMethodThrowsException.class) + .getExposedTestContext(); + assertThatIllegalStateException().isThrownBy(testContext::getApplicationContext).havingCause() + .withMessageContaining("ThrownFromMain"); + } + + @Test + void whenUseMainMethodWhenAvailableAndNoMainMethod() { + TestContext testContext = new ExposedTestContextManager(UseMainMethodWhenAvailableAndNoMainMethod.class) + .getExposedTestContext(); + ApplicationContext applicationContext = testContext.getApplicationContext(); + assertThat(applicationContext.getEnvironment().getActiveProfiles()).isEmpty(); + } + + @Test + void whenUseMainMethodWhenAvailableAndMainMethod() { + TestContext testContext = new ExposedTestContextManager(UseMainMethodWhenAvailableAndMainMethod.class) + .getExposedTestContext(); + ApplicationContext applicationContext = testContext.getApplicationContext(); + assertThat(applicationContext.getEnvironment().getActiveProfiles()).contains("frommain"); + } + + @Test + void whenUseMainMethodNever() { + TestContext testContext = new ExposedTestContextManager(UseMainMethodNever.class).getExposedTestContext(); + ApplicationContext applicationContext = testContext.getApplicationContext(); + assertThat(applicationContext.getEnvironment().getActiveProfiles()).isEmpty(); + } + private String[] getActiveProfiles(Class testClass) { TestContext testContext = new ExposedTestContextManager(testClass).getExposedTestContext(); ApplicationContext applicationContext = testContext.getApplicationContext(); @@ -239,18 +273,64 @@ class SpringBootContextLoaderTests { } + @SpringBootTest(classes = Config.class, args = "--spring.main.web-application-type=none") + static class ChangingWebApplicationTypeToNone { + + } + + @SpringBootTest(classes = Config.class, args = "--spring.main.web-application-type=reactive") + static class ChangingWebApplicationTypeToReactive { + + } + + @SpringBootTest(classes = ConfigWithThrowingMain.class, useMainMethod = UseMainMethod.ALWAYS) + static class UseMainMethodAlwaysAndMainMethodThrowsException { + + } + + @SpringBootTest(classes = ConfigWithNoMain.class, useMainMethod = UseMainMethod.WHEN_AVAILABLE) + static class UseMainMethodWhenAvailableAndNoMainMethod { + + } + + @SpringBootTest(classes = ConfigWithMain.class, useMainMethod = UseMainMethod.WHEN_AVAILABLE) + static class UseMainMethodWhenAvailableAndMainMethod { + + } + + @SpringBootTest(classes = ConfigWithMain.class, useMainMethod = UseMainMethod.NEVER) + static class UseMainMethodNever { + + } + @Configuration(proxyBeanMethods = false) static class Config { } - @SpringBootTest(classes = Config.class, args = "--spring.main.web-application-type=none") - static class ChangingWebApplicationTypeToNone { + @Configuration(proxyBeanMethods = false) + @SpringBootConfiguration + public static class ConfigWithMain { + + public static void main(String[] args) { + new SpringApplication(ConfigWithMain.class).run("--spring.profiles.active=frommain"); + } } - @SpringBootTest(classes = Config.class, args = "--spring.main.web-application-type=reactive") - static class ChangingWebApplicationTypeToReactive { + @Configuration(proxyBeanMethods = false) + @SpringBootConfiguration + static class ConfigWithNoMain { + + } + + @Configuration(proxyBeanMethods = false) + @SpringBootConfiguration + public static class ConfigWithThrowingMain { + + public static void main(String[] args) { + throw new RuntimeException("ThrownFromMain"); + } } diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-profile/src/test/java/smoketest/profile/ActiveProfilesTests.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-profile/src/test/java/smoketest/profile/ActiveProfilesTests.java index e122c493e8..b60fb5de67 100644 --- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-profile/src/test/java/smoketest/profile/ActiveProfilesTests.java +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-profile/src/test/java/smoketest/profile/ActiveProfilesTests.java @@ -21,6 +21,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.env.EnvironmentPostProcessor; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.UseMainMethod; import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; import org.springframework.core.env.Environment; import org.springframework.test.context.ActiveProfiles; @@ -33,7 +34,8 @@ import static org.assertj.core.api.Assertions.assertThat; * * @author Madhura Bhave */ -@SpringBootTest(webEnvironment = WebEnvironment.NONE, properties = { "enableEnvironmentPostProcessor=true" }) // gh-28530 +@SpringBootTest(useMainMethod = UseMainMethod.NEVER, webEnvironment = WebEnvironment.NONE, + properties = { "enableEnvironmentPostProcessor=true" }) // gh-28530 @ActiveProfiles("hello") class ActiveProfilesTests { diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-profile/src/test/java/smoketest/profile/AttributeInjectionTests.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-profile/src/test/java/smoketest/profile/AttributeInjectionTests.java index 42a26eefee..aafb628ec3 100644 --- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-profile/src/test/java/smoketest/profile/AttributeInjectionTests.java +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-profile/src/test/java/smoketest/profile/AttributeInjectionTests.java @@ -20,11 +20,12 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.UseMainMethod; import static org.assertj.core.api.Assertions.assertThat; // gh-29169 -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@SpringBootTest(useMainMethod = UseMainMethod.NEVER, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) class AttributeInjectionTests { @Autowired(required = false)