Add a test-run goal to the Maven plugin

Closes gh-35202
This commit is contained in:
Andy Wilkinson
2023-05-03 12:34:11 +01:00
parent 5f84588b5d
commit e43998615c
10 changed files with 327 additions and 26 deletions

View File

@@ -43,7 +43,7 @@ You can restore it at any time by configuring your project:
include::../maven/running/hot-refresh-pom.xml[tags=hot-refresh]
----
When `addResources` is enabled, any `src/main/resources` directory will be added to the application classpath when you run the application and any duplicate found in `target/classes` will be removed.
When `addResources` is enabled, any `src/main/resources` directory will be added to the application classpath when you run the application and any duplicate found in the classes output will be removed.
This allows hot refreshing of resources which can be very useful when developing web applications.
For example, you can work on HTML, CSS or JavaScript files and see your changes immediately without recompiling your application.
It is also a helpful way of allowing your front end developers to work without needing to download and install a Java IDE.
@@ -53,14 +53,14 @@ NOTE: A side effect of using this feature is that filtering of resources at buil
In order to be consistent with the `repackage` goal, the `run` goal builds the classpath in such a way that any dependency that is excluded in the plugin's configuration gets excluded from the classpath as well.
For more details, see <<packaging.examples.exclude-dependency,the dedicated example>>.
Sometimes it is useful to include test dependencies when running the application.
For example, if you want to run your application in a test mode that uses stub classes.
If you wish to do this, you can set the `useTestClasspath` parameter to true.
NOTE: This is only applied when you run an application: the `repackage` goal will not add test dependencies to the resulting JAR/WAR.
Sometimes it is useful to run a test variant of your application.
For example, if you want to {spring-boot-reference}/#features.testing.testcontainers.at-development-time[use Testcontainers at development time] or make use of some test stubs.
Use the `test-run` goal with many of the same features and configuration options as `run` for this purpose.
include::goals/run.adoc[leveloffset=+1]
include::goals/test-run.adoc[leveloffset=+1]
[[run.examples]]
@@ -70,7 +70,7 @@ include::goals/run.adoc[leveloffset=+1]
[[run.examples.debug]]
=== Debug the Application
The `run` goal runs your application in a forked process.
The `run` and `test-run` goals run your application in a forked process.
If you need to debug it, you should add the necessary JVM arguments to enable remote debugging.
The following configuration suspend the process until a debugger has joined on port 5005:

View File

@@ -0,0 +1,56 @@
/*
* 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.maven;
import java.io.File;
import java.io.IOException;
import org.junit.jupiter.api.TestTemplate;
import org.junit.jupiter.api.extension.ExtendWith;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.contentOf;
/**
* Integration tests for the Maven plugin's {@code test-run} goal.
*
* @author Andy Wilkinson
*/
@ExtendWith(MavenBuildExtension.class)
class TestRunIntegrationTests {
@TestTemplate
void whenTheTestRunGoalIsExecutedTheApplicationIsRunWithTestAndMainClassesAndTestClasspath(MavenBuild mavenBuild) {
mavenBuild.project("test-run")
.goals("spring-boot:test-run", "-X")
.execute((project) -> assertThat(buildLog(project))
.contains("Main class name = org.test.TestSampleApplication")
.contains("1. " + canonicalPathOf(project, "target/test-classes"))
.contains("2. " + canonicalPathOf(project, "target/classes"))
.containsPattern("3\\. .*spring-core")
.containsPattern("4\\. .*spring-jcl"));
}
private String canonicalPathOf(File project, String path) throws IOException {
return new File(project, path).getCanonicalPath();
}
private String buildLog(File project) {
return contentOf(new File(project, "target/build.log"));
}
}

View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>org.springframework.boot.maven.it</groupId>
<artifactId>test-run</artifactId>
<version>0.0.1.BUILD-SNAPSHOT</version>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.source>@java.version@</maven.compiler.source>
<maven.compiler.target>@java.version@</maven.compiler.target>
</properties>
<build>
<plugins>
<plugin>
<groupId>@project.groupId@</groupId>
<artifactId>@project.artifactId@</artifactId>
<version>@project.version@</version>
</plugin>
</plugins>
</build>
<dependencies>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
<version>@spring-framework.version@</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,25 @@
/*
* 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.test;
public class SampleApplication {
public static void main(String[] args) {
System.out.println("I haz been run");
}
}

View File

@@ -0,0 +1,32 @@
/*
* 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.test;
import java.io.File;
import java.lang.management.ManagementFactory;
public class TestSampleApplication {
public static void main(String[] args) {
System.out.println("Main class name = " + TestSampleApplication.class.getName());
int i = 1;
for (String entry : ManagementFactory.getRuntimeMXBean().getClassPath().split(File.pathSeparator)) {
System.out.println(i++ + ". " + entry);
}
}
}

View File

@@ -173,20 +173,13 @@ public abstract class AbstractRunMojo extends AbstractDependencyFilterMojo {
private String[] directories;
/**
* Directory containing the classes and resource files that should be packaged into
* the archive.
* Directory containing the classes and resource files that should be used to run the
* application.
* @since 1.0.0
*/
@Parameter(defaultValue = "${project.build.outputDirectory}", required = true)
private File classesDirectory;
/**
* Flag to include the test classpath when running.
* @since 1.3.0
*/
@Parameter(property = "spring-boot.run.useTestClasspath", defaultValue = "false")
private Boolean useTestClasspath;
/**
* Skip the execution.
* @since 1.3.2
@@ -200,11 +193,29 @@ public abstract class AbstractRunMojo extends AbstractDependencyFilterMojo {
getLog().debug("skipping run as per configuration.");
return;
}
String startClass = (this.mainClass != null) ? this.mainClass
: SpringBootApplicationClassFinder.findSingleClass(this.classesDirectory);
run(startClass);
run(determineMainClass());
}
private String determineMainClass() throws MojoExecutionException {
if (this.mainClass != null) {
return this.mainClass;
}
return SpringBootApplicationClassFinder.findSingleClass(getClassesDirectories());
}
/**
* Returns the directories that contain the application's classes and resources. When
* the application's main class has not been configured, each directory is searched in
* turn for an appropriate main class.
* @return the directories that contain the application's classes and resources
* @since 3.1.0
*/
protected List<File> getClassesDirectories() {
return List.of(this.classesDirectory);
}
protected abstract boolean isUseTestClasspath();
private void run(String startClassName) throws MojoExecutionException, MojoFailureException {
List<String> args = new ArrayList<>();
addAgents(args);
@@ -361,17 +372,21 @@ public abstract class AbstractRunMojo extends AbstractDependencyFilterMojo {
for (Resource resource : this.project.getResources()) {
File directory = new File(resource.getDirectory());
urls.add(directory.toURI().toURL());
FileUtils.removeDuplicatesFromOutputDirectory(this.classesDirectory, directory);
for (File classesDirectory : getClassesDirectories()) {
FileUtils.removeDuplicatesFromOutputDirectory(classesDirectory, directory);
}
}
}
}
private void addProjectClasses(List<URL> urls) throws MalformedURLException {
urls.add(this.classesDirectory.toURI().toURL());
for (File classesDirectory : getClassesDirectories()) {
urls.add(classesDirectory.toURI().toURL());
}
}
private void addDependencies(List<URL> urls) throws MalformedURLException, MojoExecutionException {
Set<Artifact> artifacts = (this.useTestClasspath) ? filterDependencies(this.project.getArtifacts())
Set<Artifact> artifacts = (isUseTestClasspath()) ? filterDependencies(this.project.getArtifacts())
: filterDependencies(this.project.getArtifacts(), new ExcludeTestScopeArtifactFilter());
for (Artifact artifact : artifacts) {
if (artifact.getFile() != null) {

View File

@@ -51,6 +51,13 @@ public class RunMojo extends AbstractRunMojo {
@Parameter(property = "spring-boot.run.optimizedLaunch", defaultValue = "true")
private boolean optimizedLaunch;
/**
* Flag to include the test classpath when running.
* @since 1.3.0
*/
@Parameter(property = "spring-boot.run.useTestClasspath", defaultValue = "false")
private Boolean useTestClasspath;
@Override
protected RunArguments resolveJvmArguments() {
RunArguments jvmArguments = super.resolveJvmArguments();
@@ -69,6 +76,11 @@ public class RunMojo extends AbstractRunMojo {
.run(workingDirectory, args, environmentVariables);
}
@Override
protected boolean isUseTestClasspath() {
return this.useTestClasspath;
}
private static final class RunProcessKiller implements Runnable {
private final RunProcess runProcess;

View File

@@ -18,6 +18,7 @@ package org.springframework.boot.maven;
import java.io.File;
import java.io.IOException;
import java.util.List;
import org.apache.maven.plugin.MojoExecutionException;
@@ -34,11 +35,17 @@ abstract class SpringBootApplicationClassFinder {
private static final String SPRING_BOOT_APPLICATION_CLASS_NAME = "org.springframework.boot.autoconfigure.SpringBootApplication";
static String findSingleClass(File classesDirectory) throws MojoExecutionException {
return findSingleClass(List.of(classesDirectory));
}
static String findSingleClass(List<File> classesDirectories) throws MojoExecutionException {
try {
String mainClass = MainClassFinder.findSingleMainClass(classesDirectory,
SPRING_BOOT_APPLICATION_CLASS_NAME);
if (mainClass != null) {
return mainClass;
for (File classesDirectory : classesDirectories) {
String mainClass = MainClassFinder.findSingleMainClass(classesDirectory,
SPRING_BOOT_APPLICATION_CLASS_NAME);
if (mainClass != null) {
return mainClass;
}
}
throw new MojoExecutionException("Unable to find a suitable main class, please add a 'mainClass' property");
}

View File

@@ -85,6 +85,12 @@ public class StartMojo extends AbstractRunMojo {
private final Object lock = new Object();
/**
* Flag to include the test classpath when running.
*/
@Parameter(property = "spring-boot.run.useTestClasspath", defaultValue = "false")
private Boolean useTestClasspath;
@Override
protected void run(JavaProcessExecutor processExecutor, File workingDirectory, List<String> args,
Map<String, String> environmentVariables) throws MojoExecutionException, MojoFailureException {
@@ -188,6 +194,11 @@ public class StartMojo extends AbstractRunMojo {
"Spring application did not start before the configured timeout (" + (wait * maxAttempts) + "ms");
}
@Override
protected boolean isUseTestClasspath() {
return this.useTestClasspath;
}
private class CreateJmxConnector implements Callable<JMXConnector> {
private final int port;

View File

@@ -0,0 +1,113 @@
/*
* 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.maven;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.apache.maven.plugin.MojoExecutionException;
import org.apache.maven.plugin.MojoFailureException;
import org.apache.maven.plugins.annotations.Execute;
import org.apache.maven.plugins.annotations.LifecyclePhase;
import org.apache.maven.plugins.annotations.Mojo;
import org.apache.maven.plugins.annotations.Parameter;
import org.apache.maven.plugins.annotations.ResolutionScope;
import org.springframework.boot.loader.tools.RunProcess;
/**
* Run an application in place using the test runtime classpath. The main class that will
* be used to launch the application is determined as follows:
*
* <ol>
* <li>The configured main class, if any</li>
* <li>The main class found in the test classes directory, if any</li>
* <li>The main class found in the classes directory, if any</li>
* </ol>
*
* @author Phillip Webb
* @author Dmytro Nosan
* @author Stephane Nicoll
* @author Andy Wilkinson
* @since 3.1.0
*/
@Mojo(name = "test-run", requiresProject = true, defaultPhase = LifecyclePhase.VALIDATE,
requiresDependencyResolution = ResolutionScope.TEST)
@Execute(phase = LifecyclePhase.TEST_COMPILE)
public class TestRunMojo extends AbstractRunMojo {
/**
* Whether the JVM's launch should be optimized.
*/
@Parameter(property = "spring-boot.test-run.optimizedLaunch", defaultValue = "true")
private boolean optimizedLaunch;
/**
* Directory containing the test classes and resource files that should be used to run
* the application.
*/
@Parameter(defaultValue = "${project.build.testOutputDirectory}", required = true)
private File testClassesDirectory;
@Override
protected List<File> getClassesDirectories() {
ArrayList<File> classesDirectories = new ArrayList<>(super.getClassesDirectories());
classesDirectories.add(0, this.testClassesDirectory);
return classesDirectories;
}
@Override
protected boolean isUseTestClasspath() {
return true;
}
@Override
protected RunArguments resolveJvmArguments() {
RunArguments jvmArguments = super.resolveJvmArguments();
if (this.optimizedLaunch) {
jvmArguments.getArgs().addFirst("-XX:TieredStopAtLevel=1");
}
return jvmArguments;
}
@Override
protected void run(JavaProcessExecutor processExecutor, File workingDirectory, List<String> args,
Map<String, String> environmentVariables) throws MojoExecutionException, MojoFailureException {
processExecutor
.withRunProcessCustomizer(
(runProcess) -> Runtime.getRuntime().addShutdownHook(new Thread(new RunProcessKiller(runProcess))))
.run(workingDirectory, args, environmentVariables);
}
private static final class RunProcessKiller implements Runnable {
private final RunProcess runProcess;
private RunProcessKiller(RunProcess runProcess) {
this.runProcess = runProcess;
}
@Override
public void run() {
this.runProcess.kill();
}
}
}