Add bootTestRun to run app using test source set output and classpath

Closes gh-35248
pull/35286/head
Andy Wilkinson 2 years ago
parent 9cadc6ffbb
commit 19d7973776

@ -14,12 +14,13 @@ When Gradle's {java-plugin}[`java` plugin] is applied to a project, the Spring B
2. Configures the `assemble` task to depend on the `bootJar` task.
3. Configures the `jar` task to use `plain` as the convention for its archive classifier.
4. Creates a {boot-build-image-javadoc}[`BootBuildImage`] task named `bootBuildImage` that will create a OCI image using a https://buildpacks.io[buildpack].
5. Creates a {boot-run-javadoc}[`BootRun`] task named `bootRun` that can be used to run your application.
6. Creates a configuration named `bootArchives` that contains the artifact produced by the `bootJar` task.
7. Creates a configuration named `developmentOnly` for dependencies that are only required at development time, such as Spring Boot's Devtools, and should not be packaged in executable jars and wars.
8. Creates a configuration named `productionRuntimeClasspath`. It is equivalent to `runtimeClasspath` minus any dependencies that only appear in the `developmentOnly` configuration.
9. Configures any `JavaCompile` tasks with no configured encoding to use `UTF-8`.
10. Configures any `JavaCompile` tasks to use the `-parameters` compiler argument.
5. Creates a {boot-run-javadoc}[`BootRun`] task named `bootRun` that can be used to run your application using the `main` source set to find its main method and provide its runtime classpath.
6. Creates a {boot-run-javadoc}['BootRun`] task named `bootTestRun` that can be used to run your application using the `test` source set to find its main method and provide its runtime classpath.
7. Creates a configuration named `bootArchives` that contains the artifact produced by the `bootJar` task.
8. Creates a configuration named `developmentOnly` for dependencies that are only required at development time, such as Spring Boot's Devtools, and should not be packaged in executable jars and wars.
9. Creates a configuration named `productionRuntimeClasspath`. It is equivalent to `runtimeClasspath` minus any dependencies that only appear in the `developmentOnly` configuration.
10. Configures any `JavaCompile` tasks with no configured encoding to use `UTF-8`.
11. Configures any `JavaCompile` tasks to use the `-parameters` compiler argument.
@ -59,7 +60,7 @@ When Gradle's {application-plugin}[`application` plugin] is applied to a project
The task is configured to use the `applicationDefaultJvmArgs` property as a convention for its `defaultJvmOpts` property.
2. Creates a new distribution named `boot` and configures it to contain the artifact in the `bootArchives` configuration in its `lib` directory and the start scripts in its `bin` directory.
3. Configures the `bootRun` task to use the `mainClassName` property as a convention for its `main` property.
4. Configures the `bootRun` task to use the `applicationDefaultJvmArgs` property as a convention for its `jvmArgs` property.
4. Configures the `bootRun` and `bootTestRun` tasks to use the `applicationDefaultJvmArgs` property as a convention for their `jvmArgs` property.
5. Configures the `bootJar` task to use the `mainClassName` property as a convention for the `Start-Class` entry in its manifest.
6. Configures the `bootWar` task to use the `mainClassName` property as a convention for the `Start-Class` entry in its manifest.

@ -141,3 +141,12 @@ include::../gradle/running/boot-run-source-resources.gradle.kts[tags=source-reso
----
This makes them reloadable in the live application which can be helpful at development time.
[[running-your-application.using-a-test-main-class]]
== Using a Test Main Class
In addition to `bootRun` a `bootTestRun` task is also registered.
Like `bootRun`, `bootTestRun` is an instance of `BootRun` but it's configured to use a main class found in the output of the test source set rather than the main source set.
It also uses the test source set's runtime classpath rather than the main source set's runtime classpath.
As `bootTestRun` is an instance of `BootRun`, all of the configuration options described above for `bootRun` can also be used with `bootTestRun`.

@ -85,6 +85,8 @@ final class JavaPluginAction implements PluginApplicationAction {
configureBootBuildImageTask(project, bootJar);
configureArtifactPublication(bootJar);
configureBootRunTask(project, resolveMainClassName);
TaskProvider<ResolveMainClassName> resolveMainTestClassName = configureResolveMainTestClassNameTask(project);
configureBootTestRunTask(project, resolveMainTestClassName);
project.afterEvaluate(this::configureUtf8Encoding);
configureParametersCompilerArg(project);
configureAdditionalMetadataLocations(project);
@ -128,6 +130,23 @@ final class JavaPluginAction implements PluginApplicationAction {
});
}
private TaskProvider<ResolveMainClassName> configureResolveMainTestClassNameTask(Project project) {
return project.getTasks()
.register(SpringBootPlugin.RESOLVE_TEST_MAIN_CLASS_NAME_TASK_NAME, ResolveMainClassName.class,
(resolveMainClassName) -> {
resolveMainClassName.setDescription("Resolves the name of the application's test main class.");
resolveMainClassName.setGroup(BasePlugin.BUILD_GROUP);
Callable<FileCollection> classpath = () -> {
SourceSetContainer sourceSets = project.getExtensions().getByType(SourceSetContainer.class);
return project.files(sourceSets.getByName(SourceSet.TEST_SOURCE_SET_NAME).getOutput(),
sourceSets.getByName(SourceSet.MAIN_SOURCE_SET_NAME).getOutput());
};
resolveMainClassName.setClasspath(classpath);
resolveMainClassName.getOutputFile()
.set(project.getLayout().getBuildDirectory().file("resolvedMainTestClassName"));
});
}
private static String getJavaApplicationMainClass(ExtensionContainer extensions) {
JavaApplication javaApplication = extensions.findByType(JavaApplication.class);
if (javaApplication == null) {
@ -194,6 +213,26 @@ final class JavaPluginAction implements PluginApplicationAction {
});
}
private void configureBootTestRunTask(Project project, TaskProvider<ResolveMainClassName> resolveMainClassName) {
Callable<FileCollection> classpath = () -> javaPluginExtension(project).getSourceSets()
.findByName(SourceSet.TEST_SOURCE_SET_NAME)
.getRuntimeClasspath()
.filter(new JarTypeFileSpec());
project.getTasks().register("bootTestRun", BootRun.class, (run) -> {
run.setDescription("Runs this project as a Spring Boot application using the test runtime classpath.");
run.setGroup(ApplicationPlugin.APPLICATION_GROUP);
run.classpath(classpath);
run.getConventionMapping().map("jvmArgs", () -> {
if (project.hasProperty("applicationDefaultJvmArgs")) {
return project.property("applicationDefaultJvmArgs");
}
return Collections.emptyList();
});
run.getMainClass().convention(resolveMainClassName.flatMap(ResolveMainClassName::readMainClassName));
configureToolchainConvention(project, run);
});
}
private void configureToolchainConvention(Project project, BootRun run) {
JavaToolchainSpec toolchain = project.getExtensions().getByType(JavaPluginExtension.class).getToolchain();
JavaToolchainService toolchainService = project.getExtensions().getByType(JavaToolchainService.class);

@ -22,6 +22,7 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.Objects;
import java.util.stream.Collectors;
import org.gradle.api.DefaultTask;
import org.gradle.api.InvalidUserDataException;
@ -149,16 +150,29 @@ public class ResolveMainClassName extends DefaultTask {
}
Provider<String> readMainClassName() {
return this.outputFile.map(new ClassNameReader());
String classpath = getClasspath().filter(File::isDirectory)
.getFiles()
.stream()
.map((directory) -> getProject().getProjectDir().toPath().relativize(directory.toPath()))
.map(Path::toString)
.collect(Collectors.joining(","));
return this.outputFile.map(new ClassNameReader(classpath));
}
private static final class ClassNameReader implements Transformer<String, RegularFile> {
private final String classpath;
private ClassNameReader(String classpath) {
this.classpath = classpath;
}
@Override
public String transform(RegularFile file) {
if (file.getAsFile().length() == 0) {
throw new InvalidUserDataException(
"Main class name has not been configured and it could not be resolved");
"Main class name has not been configured and it could not be resolved from classpath "
+ this.classpath);
}
Path output = file.getAsFile().toPath();
try {

@ -1,5 +1,5 @@
/*
* Copyright 2012-2022 the original author or authors.
* Copyright 2012-2023 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.
@ -82,11 +82,20 @@ public class SpringBootPlugin implements Plugin<Project> {
public static final String PRODUCTION_RUNTIME_CLASSPATH_CONFIGURATION_NAME = "productionRuntimeClasspath";
/**
* The name of the {@link ResolveMainClassName} task.
* The name of the {@link ResolveMainClassName} task used to resolve a main class from
* the output of the {@code main} source set.
* @since 3.0.0
*/
public static final String RESOLVE_MAIN_CLASS_NAME_TASK_NAME = "resolveMainClassName";
/**
* The name of the {@link ResolveMainClassName} task used to resolve a main class from
* the output of the {@code test} source set then, if needed, the output of the
* {@code main} source set.
* @since 3.1.0
*/
public static final String RESOLVE_TEST_MAIN_CLASS_NAME_TASK_NAME = "resolveTestMainClassName";
/**
* The coordinates {@code (group:name:version)} of the
* {@code spring-boot-dependencies} bom.

@ -0,0 +1,41 @@
/*
* Copyright 2012-2023 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 com.example.boottestrun.classpath;
import java.io.File;
import java.lang.management.ManagementFactory;
/**
* Application used for testing {@code bootTestRun}'s classpath handling.
*
* @author Andy Wilkinson
*/
public class BootTestRunClasspathApplication {
protected BootTestRunClasspathApplication() {
}
public static void main(String[] args) {
System.out.println("Main class name = " + BootTestRunClasspathApplication.class.getName());
int i = 1;
for (String entry : ManagementFactory.getRuntimeMXBean().getClassPath().split(File.pathSeparator)) {
System.out.println(i++ + ". " + entry);
}
}
}

@ -0,0 +1,39 @@
/*
* Copyright 2012-2023 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 com.example.boottestrun.jvmargs;
import java.lang.management.ManagementFactory;
/**
* Application used for testing {@code bootTestRun}'s JVM argument handling.
*
* @author Andy Wilkinson
*/
public class BootTestRunJvmArgsApplication {
protected BootTestRunJvmArgsApplication() {
}
public static void main(String[] args) {
int i = 1;
for (String entry : ManagementFactory.getRuntimeMXBean().getInputArguments()) {
System.out.println(i++ + ". " + entry);
}
}
}

@ -0,0 +1,26 @@
/*
* Copyright 2012-2023 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 com.example.boottestrun.nomain;
/**
* Application used for testing {@code bootTestRun}'s handling of no test main method
*
* @author Andy Wilkinson
*/
public class BootTestRunNoMain {
}

@ -60,11 +60,21 @@ class JavaPluginActionIntegrationTests {
assertThat(this.gradleBuild.build("tasks").getOutput()).doesNotContain("bootRun");
}
@TestTemplate
void noBootTestRunTaskWithoutJavaPluginApplied() {
assertThat(this.gradleBuild.build("tasks").getOutput()).doesNotContain("bootTestRun");
}
@TestTemplate
void applyingJavaPluginCreatesBootRunTask() {
assertThat(this.gradleBuild.build("tasks").getOutput()).contains("bootRun");
}
@TestTemplate
void applyingJavaPluginCreatesBootTestRunTask() {
assertThat(this.gradleBuild.build("tasks").getOutput()).contains("bootTestRun");
}
@TestTemplate
void javaCompileTasksUseUtf8Encoding() {
assertThat(this.gradleBuild.build("build").getOutput()).contains("compileJava = UTF-8")

@ -0,0 +1,156 @@
/*
* Copyright 2012-2023 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.gradle.tasks.run;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.function.Consumer;
import java.util.jar.Attributes;
import java.util.jar.JarOutputStream;
import java.util.jar.Manifest;
import org.assertj.core.api.Assumptions;
import org.gradle.testkit.runner.BuildResult;
import org.gradle.testkit.runner.TaskOutcome;
import org.gradle.util.GradleVersion;
import org.junit.jupiter.api.TestTemplate;
import org.springframework.boot.gradle.junit.GradleCompatibility;
import org.springframework.boot.testsupport.gradle.testkit.GradleBuild;
import org.springframework.util.FileSystemUtils;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Integration tests for the {@link BootRun} task configured to use the test source set.
*
* @author Andy Wilkinson
*/
@GradleCompatibility(configurationCache = true)
class BootTestRunIntegrationTests {
GradleBuild gradleBuild;
@TestTemplate
void basicExecution() throws IOException {
copyClasspathApplication();
BuildResult result = this.gradleBuild.build("bootTestRun");
assertThat(result.task(":bootTestRun").getOutcome()).isEqualTo(TaskOutcome.SUCCESS);
assertThat(result.getOutput()).contains("1. " + canonicalPathOf("build/classes/java/test"))
.contains("2. " + canonicalPathOf("build/resources/test"))
.contains("3. " + canonicalPathOf("build/classes/java/main"))
.contains("4. " + canonicalPathOf("build/resources/main"));
}
@TestTemplate
void defaultJvmArgs() throws IOException {
copyJvmArgsApplication();
BuildResult result = this.gradleBuild.build("bootTestRun");
assertThat(result.task(":bootTestRun").getOutcome()).isEqualTo(TaskOutcome.SUCCESS);
assertThat(result.getOutput()).contains("-XX:TieredStopAtLevel=1");
}
@TestTemplate
void optimizedLaunchDisabledJvmArgs() throws IOException {
copyJvmArgsApplication();
BuildResult result = this.gradleBuild.build("bootTestRun");
assertThat(result.task(":bootTestRun").getOutcome()).isEqualTo(TaskOutcome.SUCCESS);
assertThat(result.getOutput()).doesNotContain("-Xverify:none").doesNotContain("-XX:TieredStopAtLevel=1");
}
@TestTemplate
void applicationPluginJvmArgumentsAreUsed() throws IOException {
if (this.gradleBuild.isConfigurationCache()) {
// https://github.com/gradle/gradle/pull/23924
GradleVersion gradleVersion = GradleVersion.version(this.gradleBuild.getGradleVersion());
Assumptions.assumeThat(gradleVersion)
.isLessThan(GradleVersion.version("8.0"))
.isGreaterThanOrEqualTo(GradleVersion.version("8.1-rc-1"));
}
copyJvmArgsApplication();
BuildResult result = this.gradleBuild.build("bootTestRun");
assertThat(result.task(":bootTestRun").getOutcome()).isEqualTo(TaskOutcome.SUCCESS);
assertThat(result.getOutput()).contains("-Dcom.bar=baz")
.contains("-Dcom.foo=bar")
.contains("-XX:TieredStopAtLevel=1");
}
@TestTemplate
void jarTypeFilteringIsAppliedToTheClasspath() throws IOException {
copyClasspathApplication();
File flatDirRepository = new File(this.gradleBuild.getProjectDir(), "repository");
createDependenciesStarterJar(new File(flatDirRepository, "starter.jar"));
createStandardJar(new File(flatDirRepository, "standard.jar"));
BuildResult result = this.gradleBuild.build("bootTestRun");
assertThat(result.task(":bootTestRun").getOutcome()).isEqualTo(TaskOutcome.SUCCESS);
assertThat(result.getOutput()).contains("standard.jar").doesNotContain("starter.jar");
}
@TestTemplate
void failsGracefullyWhenNoTestMainMethodIsFound() throws IOException {
copyApplication("nomain");
BuildResult result = this.gradleBuild.buildAndFail("bootTestRun");
assertThat(result.task(":bootTestRun").getOutcome()).isEqualTo(TaskOutcome.FAILED);
if (this.gradleBuild.isConfigurationCache() && this.gradleBuild.gradleVersionIsAtLeast("8.0")) {
assertThat(result.getOutput())
.contains("Main class name has not been configured and it could not be resolved from classpath");
}
else {
assertThat(result.getOutput())
.contains("Main class name has not been configured and it could not be resolved from classpath "
+ "build/classes/java/test");
}
}
private void copyClasspathApplication() throws IOException {
copyApplication("classpath");
}
private void copyJvmArgsApplication() throws IOException {
copyApplication("jvmargs");
}
private void copyApplication(String name) throws IOException {
File output = new File(this.gradleBuild.getProjectDir(), "src/test/java/com/example/boottestrun/" + name);
output.mkdirs();
FileSystemUtils.copyRecursively(new File("src/test/java/com/example/boottestrun/" + name), output);
}
private String canonicalPathOf(String path) throws IOException {
return new File(this.gradleBuild.getProjectDir(), path).getCanonicalPath();
}
private void createStandardJar(File location) throws IOException {
createJar(location, (attributes) -> {
});
}
private void createDependenciesStarterJar(File location) throws IOException {
createJar(location, (attributes) -> attributes.putValue("Spring-Boot-Jar-Type", "dependencies-starter"));
}
private void createJar(File location, Consumer<Attributes> attributesConfigurer) throws IOException {
location.getParentFile().mkdirs();
Manifest manifest = new Manifest();
Attributes attributes = manifest.getMainAttributes();
attributes.put(Attributes.Name.MANIFEST_VERSION, "1.0");
attributesConfigurer.accept(attributes);
new JarOutputStream(new FileOutputStream(location), manifest).close();
}
}

@ -0,0 +1,8 @@
plugins {
id 'application'
id 'org.springframework.boot' version '{version}'
}
application {
applicationDefaultJvmArgs = ['-Dcom.foo=bar', '-Dcom.bar=baz']
}

@ -0,0 +1,15 @@
plugins {
id 'java'
id 'org.springframework.boot' version '{version}'
}
repositories {
flatDir {
dirs 'repository'
}
}
dependencies {
implementation(name: "standard")
implementation(name: "starter")
}

@ -189,6 +189,10 @@ public class GradleBuild {
return this;
}
public boolean gradleVersionIsAtLeast(String version) {
return GradleVersion.version(this.gradleVersion).compareTo(GradleVersion.version(version)) >= 0;
}
public BuildResult build(String... arguments) {
try {
BuildResult result = prepareRunner(arguments).build();

Loading…
Cancel
Save