Appearance
单元测试的maven插件
本文介绍maven对单元测试的支持,主要介绍maven-surefire-plugin和maven-surefire-report-plugin两个插件,前者主要是用来运行单元测试,后者主要用来生产单元测试报告。
1. 如何运行JUnit单元测试
在了解maven是如何运行单元测试之前,我们先了解如何自己运行单元测试。
首先,引入以下依赖:
xml
<dependency>
<groupId>org.junit.platform</groupId>
<artifactId>junit-platform-launcher</artifactId>
<version>6.0.3</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>6.0.3</version>
</dependency>junit-platform-launcher:核心API,用于配置和启动测试计划,通常用于IDE或构件工具(如maven、gradle)启动测试;该依赖间接引入了
junit-platform-engine,junit-platform-engine定义了测试引擎接口;junit-jupiter-engine:jupiter测试引擎实现,用于实际执行测试用例;该依赖间接引入了
junit-jupiter-api,junit-jupiter-api定义了@Test等API;注意,以上依赖的
<scope>都是默认的compile,所以下面的代码都是在src/main/java目录;
然后,编写一个简单的测试类:
java
package org.example;
import org.junit.jupiter.api.Test;
public class MyFirstTest {
@Test
public void hellworld() {
System.out.println("hello world");
}
@Test
public void test(){
System.out.println("test");
}
}之后,编写测试启动类:
java
package org.example;
import org.junit.platform.launcher.Launcher;
import org.junit.platform.launcher.LauncherDiscoveryRequest;
import org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder;
import org.junit.platform.launcher.core.LauncherFactory;
import org.junit.platform.launcher.listeners.SummaryGeneratingListener;
import org.junit.platform.launcher.listeners.TestExecutionSummary;
import static org.junit.platform.engine.discovery.DiscoverySelectors.selectClass;
public class ManualTestRunner {
public static void main(String[] args) {
// 1. 第一步:创建启动器 (Launcher)
// 这一步只是初始化了执行引擎的入口
Launcher launcher = LauncherFactory.create();
// 2. 第二步:注册监听器,用于获取测试报告
SummaryGeneratingListener listener = new SummaryGeneratingListener();
launcher.registerTestExecutionListeners(listener);
// 3. 第三步:定义要跑的内容 (DiscoveryRequest)
LauncherDiscoveryRequest request = LauncherDiscoveryRequestBuilder.request()
.selectors(selectClass(MyFirstTest.class))
.build();
// 4. 第四步:执行
launcher.execute(request);
// 5. 第五步:获取并打印统计结果
TestExecutionSummary summary = listener.getSummary();
System.out.println("测试完成!总数: " + summary.getTestsStartedCount());
System.out.println("成功数量: " + summary.getTestsSucceededCount());
System.out.println("失败数量: " + summary.getTestsFailedCount());
// 如果有失败,可以打印具体的异常
summary.getFailures().forEach(failure ->
System.out.println("失败原因: " + failure.getException().getMessage()));
}
}运行启动类,结果如下:
java
test
hello world
测试完成!总数: 2
成功数量: 2
失败数量: 0可以发现,测试启动器非常简单,简单总结以下步骤:
- 创建启动器,用于运行测试用例;
- 注册监听器,用于获取测试结果;
- 发现测试用例,即要运行哪些测试用例,并将这些测试用例封装为
LauncherDiscoveryRequest; - 将第3步生成的测试请求发送给启动器,运行测试用例;
- 获取测试结果;
最重要的一点是发现测试用例和运行测试用例是解耦的。
2. maven-surefire-plugin
2.1 工作原理
如何运行JUnit单元测试,背后其实就是IDE或构建工具的实现原理,因此,可以以此为基础引入maven-surefire-plugin的工作原理,这对于理解后续插件的配置更有帮助。
可以简单理解,maven-surefire-plugin就是一个测试运行器,等待着外部传送给它测试请求(LauncherDiscoveryRequest),然后它运行完测试后,将测试结果返回。
这其中有两个问题需要回答:
- 外部是谁?
- 外部和测试运行器之间是如何交换数据的?
第一个问题,外部是谁?外部是指MavenCli进程,即我们运行mvn命令时,会启动一个JVM进程,该进程称为主进程,入口类为org.apache.maven.cli.MavenCli,主要作用是解析 POM、执行生命周期、调用插件(包括 Surefire)、收集报告、决定构建成功/失败等。
第二个问题,外部(主进程)和测试运行器之间是如何交换数据的?要回答这个问题,需要知道测试运行期是以什么形式运行的,答案是以单独的进程运行的(称为子进程),所以主进程和子进程间的交流通过进程间通讯方式(Inter-Process Communication,简称IPC)。
下面总结当运行mvn test命令时,都发生了什么。
当运行mvn test时,启动主进程(入口类为org.apache.maven.cli.MavenCli),在该主进程中会调用插件maven-surefire-plugin,实际就是运行插件的org.apache.maven.plugin.surefire.SurefireMojo,在su refire中会扫描测试类、启动子进程(fork 进程),以上都是在主进程完成的。
启动了子进程后(子进程的入口类为org.apache.maven.surefire.booter.ForkedBooter),主进程就通过IPC给子进程发送测试请求,子进程执行完测试后,返回结果给主进程,这个过程可能重复多次,最终运行完测试任务,主进程生成测试报告,子进程关闭。
在JUnit Platform(jupiter)环境下,主进程发送给子进程的测试请求,是一个粗略的请求,其只是一个类名列表,子进程得到该测试请求后,还会进行精准过滤,使用@Disabled、@Nested等来判断实际执行的测试用例是哪些。
2.2 基本使用
要使用maven-surefire-plugin,只需要在pom.xml中配置如下:
xml
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>6.0.3</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.4</version>
</plugin>
</plugins>
</build>然后,就可以通过mavn test命令运行项目下src/main/test的测试类了。例如:
txt
[INFO] -------------------------------------------------------
[INFO] T E S T S
[INFO] -------------------------------------------------------
[INFO] Running org.test.DemoTest
hello world
hello world2
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.018 s -- in org.test.DemoTest
[INFO]
[INFO] Results:
[INFO]
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0并且在target/surefire-reports目录下,会有txt和xml两种格式的测试报告:

2.3 参数配置
除了surefire的基本使用,该插件还支持很多配置项。
2.3.1 筛选测试
这里的筛选测试,是指主进程识别出哪些类需要发送给子进程进行测试。
默认情况下,符合以下命名规则的类会被surefire发送给子进程:
**/Test*.java**/*Test.java**/*Tests.java**/*TestCase.java
如果要覆盖默认配置,我们可以在插件中使用<includes>配置,如下:
xml
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.4</version>
<configuration>
<includes>
<include>**/*.java</include>
</includes>
</configuration>
</plugin>如果想排除某些类,可以使用<excludes>配置,如下:
xml
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.4</version>
<configuration>
<includes>
<include>**/*.java</include>
</includes>
<excludes>
<exclude>**/DemoTest.java</exclude>
</excludes>
</configuration>
</plugin>TIP
注意,如果DemoTest中有嵌套的测试类,那么嵌套的测试类不会被排除:
java
public class DemoTest {
@Test
public void test() {
System.out.println("hello world");
}
@Nested
class NestedDemoTest {
@Test
public void test() {
System.out.println("hello world");
}
}
}因为嵌套的测试类全限定名称为org.test.DemoTest$NestedDemoTest,不满足<exclude>中指定的排除规则。
如果要排除某个类及其嵌套类,可以这样写:
xml
<excludes>
<exclude>**/DemoTest*.java</exclude>
</excludes>除了按类名称进行过滤,还可以按标签进行过滤。
在JUnit中,可以使用@Tag为测试类和测试方法打标签,然后在插件配置中,使用<groups>和<excludedGroups>进行筛选:
<groups>:指定包含的标签规则,选择符合该标签规则的测试用例进行测试;<excludeGroups>:指定排除的标签规则,符合该标签规则的测试用例,不会进行测试;
WARNING
注意,按标签过滤的应用前提是<includes>和<excludes>,即在<includes>和<excludes>过滤后,再使用标签进行过滤。
例如,先在测试用例上打上标签:
java
public class Demo {
@Test
@Tag("in")
public void test() {
System.out.println("hello world");
}
@Test
public void test2() {
System.out.println("hello world2");
}
}然后设置标签规则:
xml
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.4</version>
<configuration>
<includes>
<include>**/*.java</include>
</includes>
<groups>in</groups>
</configuration>
</plugin>运行测试,发现只有test()方法执行了。
JUnit 5 的标签表达式遵循标准的布尔逻辑。以下是可用的运算符,按优先级从高到低排列:
| 运算符 | 符号 | 描述 | 示例 | 含义 |
|---|---|---|---|---|
| NOT | ! | 非:排除带有该标签的测试 | !slow | 运行所有没有打 slow 标签的测试 |
| AND | & | 与:必须同时满足多个标签 | fast & smoke | 只有同时标注了 fast 和 smoke 的才运行 |
| OR | | | 或:满足其中之一即可 | api | web | 标注了 api 或 web 的测试都会运行 |
| Parentheses | () | 括号:强制改变运算优先级 | (a | b) & !c |
除了以上的配置,还可以再使用mvn运行测试的时候,指定要运行的测试类,例如:
bash
mvn -Dtest=org.test.ClassA,org.test.ClassB#test test表示只运行ClassA中的所有测试方法,和ClassB中的test()测试方法。
也可以使用通配符*:
bash
mvn -Dtest="org.test.User*,org.test.OrderTest" test在某些终端(如 zsh 或 PowerShell)中,* 或 # 可能被视为特殊字符,如果报错,请用引号包裹。
也可以在mvn中指定<groups>,例如:
bash
mvn -Dgroups="mytag" test2.3.2 启动子进程的设置
在maven-surefire-plugin的工作原理中,我们得知子进程是由maven主进程产生的,因此,关于启动子进程有一些参数可以配置:
<forkCount>:指定要启动的子进程最高数量,默认值为1,我们也可以在数字后面跟上大写字母C,表示最多启动数字✖️CPU核心数的子进程数量,例如:xml<configuration> <!-- 最多启动2个子进程来同时运行测试 --> <forkCount>2</forkCount> <!-- 假设CPU核心数为8,那么0.5C=0.5 x 8 = 4,表示最多启动4个子进程来同时运行测试 --> <forkCount>0.5C</forkCount> </configuration>如果将
forkCount设置为0,表示不启动子进程,将由maven主进程运行测试。<reuseForks>:表示在运行完一个测试类后,子进程是否重用,默认值为true表示重用,如果为false,表示运行完一个测试类后就关闭子进程,然后要运行下一个测试类时,又启动一个新的子进程,显然,设置为false会显著增加测试时间,但是也会带来最高等级的隔离级别。<argLine>:表示在启动子进程时,给子进程设置的JVM启动参数,例如:xml<configuration> <argLine> -Xms512m -Xmx2048m -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -Dfile.encoding=UTF-8 --add-opens java.base/java.lang=ALL-UNNAMED <!-- Java 17+ 常见需求 --> </argLine> </configuration>
TIP
${surefire.forkNumber}占位符的使用
场景:假设现在开启了多个子进程,每个子进程如果都要启动一个服务器,绑定相同的端口号,显然会造成端口号冲突,服务器启动失败,最终造成测试失败。
如附录中服务器测试的例子,使用如下配置(最多只开启一个子进程):
xml
<configuration>
<forkCount>1</forkCount>
<includes>
<include>**/HttpServer*.java</include>
</includes>
</configuration>运行测试,发现结果为成功。
但是,使用如下配置:
xml
<configuration>
<forkCount>2</forkCount>
<includes>
<include>**/HttpServer*.java</include>
</includes>
</configuration>再次运行测试,发现结果为失败,提示:HttpServerTest02.beforeAll:21 » Bind Address already in use
这是因为启动了两个子进程,其中一个子进程运行HttpServerTest01测试类,开启服务器并绑定端口8080,然后另一个子进程运行HttpServerTest02,也想开启端口8080,此时发现端口已经被占用了,所以报错。
此时,我们就可以使用占位符${surefire.forkNumber},在测试运行时,maven会将占位符替换为具体的子进程号,从1到forkCount × Maven 并行构建的最大线程数(即 -T 的有效值)。
什么是maven并行构建,参考附录2。
${surefire.forkNumber}占位符可以用在<argLine>、<environmentVariables>和系统配置中(通过mvn test -D...指定或<systemPropertyVariables>配置)。
如果设置了forkCount=0,那么该占位符始终会被替换为1。
例如,在<argLine>中使用该占位符:
xml
<configuration>
<forkCount>2</forkCount>
<argLine>-Dport=${surefire.forkNumber}</argLine>
<includes>
<include>**/HttpServer*.java</include>
</includes>
</configuration>然后启动服务器时,端口号随机加上${surefire.forkNumber}的值:
java
Integer port = Integer.parseInt(System.getProperty("port"));
port = 8080 + port;
server = HttpServer.create(new InetSocketAddress(port), 0);最终,子进程1占用端口号8081,子进程2占用端口号8082,这样就不会出现端口占用的情况了,最终测试成功。
TIP
在程序中显式指定端口号,有可能会出现其他程序占用相同端口号,导致运行失败的情况,可以把选择端口号的任务交给操作系统:
java
server = HttpServer.create(new InetSocketAddress(0), 0);这样操作系统就会选择一个空闲的端口号,避免冲突。
2.3.3 关闭子进程的设置
有启动子进程,那么就有关闭子进程的动作。
正常情况下,当测试结束后,maven主进程会给每个子进程发送测试结束的信号,子进程执行java.lang.System.exit(0)启动关闭流程,这回触发这会触发所有的 Shutdown Hooks(比如关闭数据库连接、删除临时文件、日志落盘等)。子进程默认会等待(默认等待30秒)所有非守护线程(Non-daemon Threads)自然死亡。等待结束后,子进程执行java.lang.Runtime.halt(0)强制退出。等待时间可以通过forkedProcessExitTimeoutInSeconds参数设置:
xml
<configuration>
<forkedProcessExitTimeoutInSeconds>5</forkedProcessExitTimeoutInSeconds>
</configuration>除了正常关闭子进程,还有异常关闭子进程的情况:
- maven主进程关闭
- 子进程被异常关闭:如使用
kill -9命令、在测试代码中使用System.exit(0)等;
在maven主进程关闭的异常情况下,子进程需要有一种机制知道主进程关闭了。
最开始使用ping机制,即主进程定期发送NOOP命令给子进程,子进程在一定时间内(30秒)如果没有收到NOOP命令,则认为主进程已关闭,所以子进程也会随之退出。
ping机制的缺点在于,如果主进程发生了长时间的GC,那么就不会发送NOOP命令给子进程,子进程就会错认主进程已退出,导致子进程退出。
后来又提出了native机制,即主进程启动子进程时,会把主进程的PID和启动时间传给子进程,子进程在运行期间,定期监控PID是否还存在(主进程没有退出),同时验证这个 PID 是否还是原来的进程(防止 PID 被系统复用)。通过以上机制,子进程可以知道主进程是否关闭了。
子进程监控主进程是否退出机制,需要在配置中通过enableProcessChecker显式开启:
xml
<configuration>
<enableProcessChecker>all</enableProcessChecker>
</configuration>ping:使用ping机制;native:使用native机制;all:同时使用ping机制和native机制,推荐使用该方式,因为ping机制可以用来监测通信通道是否正常,native机制可以用来监测主进程是否退出;
可以运行如下测试:
java
@Test
public void test() {
while (true){
}
}然后在命令行中运行以下命令,查看java进程:
bash
jps -l结果如下:
txt
86925 org.codehaus.classworlds.Launcher
86927 /Users/xxx/projects/junit-demo/target/surefire/surefirebooter-20260308144748357_3.jar其中第一行就是maven主进程,第二行就是子进程。
然后运行如下命令,关闭主进程:
bash
kill -9 86925再次运行jps -l,会发现主进程和子进程全都关闭了。
在实际情况中,即使不配置
<enableProcessChecker>all</enableProcessChecker>使用
kill -9 主进程PID,会发现子进程仍然会退出这是因为Surefire 的“管道存活”检测 (隐式机制)
Surefire 的父进程和子进程之间通过 标准输入/输出流 (Stdin/Stdout) 建立了一个双向管道。
- 管道断开 (Broken Pipe):子进程 JVM 内部其实运行着一个隐藏的“监听线程”。它会不断读取来自父进程的指令。
- 检测 EOF:一旦你
kill -9了父进程,父进程持有的文件描述符会被内核强制关闭。子进程在读取管道时会立即收到一个 EOF (End of File)。- 自毁逻辑:Surefire 子进程的代码逻辑中包含一个判断:“如果与父进程的通信管道断开,说明父进程已死,我也必须自杀。” 这不需要显式开启
enableProcessChecker,它是 Surefire 保证系统不被僵尸子进程堆满的默认自我防御。
除了使用kill -9这种形式杀死主进程,还可以使用kill -15或Ctrl+C的方式杀死主进程,使用这种方式,主进程会立即发送SHUTDOWN命令给每个子进程。在这种情况下,我们可以通过配置shutdown来控制子进程的动作:
xml
<configuration>
<shutdown>exit</shutdown>
</configuration>testset:子进程接收SHUTDOWN命令后,什么都不做,继续当前测试。主进程不会再下发测试任务,主进程执行自己的shutdown hooks,在其中通过调用java.lang.Process.destroy()来关闭每个子进程,此时子进程会接收到SIGTERM命令,子进程收到这个 SIGTERM 后:JVM 默认触发 shutdown hooks(如果能触发的话),但由于子进程可能还在跑测试、死循环或 GC 卡住,经常不响应或响应得很慢(因此文档中说 “not always reliable depending on VM and OS”)。exit:子进程接收SHUTDOWN命令后,立即执行java.lang.System.exit(1)来关闭,此时会触发完整的 JVM 优雅关闭:- 运行所有 shutdown hooks
- 运行 finalizers
- 等待非守护线程结束(受
forkedProcessExitTimeoutInSeconds控制,默认 30s) - 超时后自动 halt
同时主进程的 shutdown hook 还是会调用 Process.destroy()(发 SIGTERM),但通常来不及起作用,因为子进程已经自己优雅退出了。
kill:子进程接收SHUTDOWN命令后,执行java.lang.Runtime.halt(1)来关闭,直接暴力终止,不跑任何 hooks、不等任何线程。
综上,推荐使用exit方式。并且,要配置shutdown,需要配置enableProcessChecker,将其设置为ping或native或all。
子进程异常关闭的情况,还有直接关闭子进程的方式,如直接使用kill -9 子进程PID,此时,控制台会列出消息“Crashed tests”,例如:
[ERROR] Crashed tests:
[ERROR] org.test.Test01
[ERROR] org.apache.maven.surefire.booter.SurefireBooterForkException: The forked VM terminated without properly saying goodbye. VM crash or System.exit called?
总结一下关于关闭子进程的设置:
xml
<configuration>
<forkedProcessExitTimeoutInSeconds>30</forkedProcessExitTimeoutInSeconds>
<shutdown>exit</shutdown>
<enableProcessChecker>all</enableProcessChecker>
</configuration>2.3.4 系统参数的设置
在启动子进程时,子进程会从主进程获取到以下配置:
默认继承的“基础基因”:无论是否配置,子进程都会从主进程继承以下最基本的 JVM 环境:
工作目录 (
user.dir):子进程默认继承父进程的运行路径(通常是项目的根目录,即pom.xml所在位置)。Java 运行时环境:默认情况下,子进程会使用与启动 Maven 相同的
JAVA_HOME。基础系统变量:一些由 JVM 自动生成的标准变量,如
os.name,file.separator,user.home等。
受控继承的“用户自定义变量” (User Properties):这是最常遇到的场景:在命令行输入
mvn test -Dmy.var=123。默认行为:子进程会继承这些变量。
控制开关:
promoteUserPropertiesToSystemProperties。- 如果为
true(默认):所有-D传给 Maven 的参数都会被注入到子进程。 - 如果为
false:子进程将看不见在命令行定义的这些变量。
- 如果为
环境变量 (Environment Variables)
默认行为:子进程通常会继承父进程的所有 操作系统环境变量(如
PATH,CLASSPATH, 以及在export或set中定义的自定义变量)。覆盖方式:如果在 Surefire 配置中使用了
<environmentVariables>标签,那么同名的环境变量会被覆盖。
surefire插件还提供了其他机制用来设置系统参数,例如:
systemPropertyVariablessystemPropertiesFileargLine
在使用systemPropertyVariables时,既可以使用字面量,也可以使用占位符${},例如:
xml
<configuration>
<systemPropertyVariables>
<propertyName>propertyValue</propertyName>
<buildDirectory>${project.build.directory}</buildDirectory>
</systemPropertyVariables>
</configuration>当使用占位符时,maven主进程会在启动测试前,先将 ${} 占位符替换为真实值,Maven 启动新的子进程运行测试,并将真实值传给子进程。
如果系统配置过多,可以写在文件中,并通过systemPropertiesFile引入。例如,在src/test/resources/test-config.properties写入以下内容:
properties
# 数据库配置
db.url=jdbc:mysql://172.19.0.1:3306/test_db
db.username=xxx
db.password=secret_pass
# 业务逻辑开关
feature.enable_trace_code=true然后使用systemPropertiesFile引入:
xml
<configuration>
<systemPropertiesFile>src/test/resources/test-config.properties</systemPropertiesFile>
</configuration>我们还可以在argLine中设置系统配置,例如:
xml
<configuration>
<argLine>-Dport=8080</argLine>
</configuration>以上各种设置,优先级从低到高为:argLine -> systemPropertiesFile -> SystemPropertyVariables -> mvn -Dxxx=yyy test,即后面出现的同名配置会覆盖前面的。
argLine 的定位是“底座”:它主要用于配置 JVM 的行为(如内存 -Xmx、垃圾回收 -XX:+UseG1GC、或者开启调试端口)。
systemPropertyVariables 的定位是“精准控制”:它是专门为测试配置设计的结构化标签。如果一个变量在专门的配置标签里定义了,它理应拥有比通用的“参数行”更高的权威。
在测试代码中,我们可以通过以下API获取系统配置:
java
System.getProperty("xxx")除了系统配置,还可以配置环境变量:
xml
<environmentVariables>
<test>aaaaa</test>
</environmentVariables>然后,在程序中通过以下API获取环境变量:
java
System.getenv("xxx");2.3.5 测试结果的显示
假设现在有如下测试类:
java
@DisplayName("测试类")
public class Test02 {
@Test
@DisplayName("第一个测试")
void test01(){
System.out.println("test01");
}
@Test
@DisplayName("第二个测试")
void test02(){
System.out.println("test02");
}
@Nested
@DisplayName("嵌套测试")
class NestedTest {
@Test
@DisplayName("第三个测试")
void test03(){
System.out.println("test03");
}
}
}运行以上测试,在控制台中输出如下:

在target/surefire-reports/TEST-org.test.Test02.xml测试报告中,部分内容如下:
xml
<testcase name="test01" classname="org.test.Test02" time="0.009">
<system-out><![CDATA[test01
]]></system-out>
</testcase>
<testcase name="test02" classname="org.test.Test02" time="0.003">
<system-out><![CDATA[test02
]]></system-out>
</testcase>在target/surefire-reports/TEST-org.test.Test02$NestedTest.xml测试报告中,部分内容如下:
xml
<testcase name="test03" classname="org.test.Test02$NestedTest" time="0.0">
<system-out><![CDATA[test03
]]></system-out>
</testcase>可以发现,完全没有使用到@DisplayName注解的内容。
surefire提供了扩展机制,用于控制测试结果的显示,例如:
xml
<configuration>
<statelessTestsetInfoReporter implementation="org.apache.maven.plugin.surefire.extensions.junit5.JUnit5StatelessTestsetInfoReporter">
<disable>false</disable>
<usePhrasedFileName>true</usePhrasedFileName>
<usePhrasedClassNameInRunning>true</usePhrasedClassNameInRunning>
<usePhrasedClassNameInTestCaseSummary>true</usePhrasedClassNameInTestCaseSummary>
</statelessTestsetInfoReporter>
</configuration>usePhrasedFileName:最终生成的txt测试结果文件,是否使用@DisplayName里的描述作为文件名。usePhrasedClassNameInRunning: 当控制台显示Running ...时,是否显示@DisplayName里的中文或短语描述。usePhrasedClassNameInTestCaseSummary: 在测试结束后的总结列表里,是否使用更易读的短语。
当使用以上配置后,结果如下:



但是,此时XML格式的测试报告,仍然使用的是类名,如果要配置xml测试报告,使用如下配置:
xml
<configuration>
<statelessTestsetReporter implementation="org.apache.maven.plugin.surefire.extensions.junit5.JUnit5Xml30StatelessReporter">
<disable>false</disable>
<version>3.0.2</version>
<usePhrasedFileName>true</usePhrasedFileName>
<usePhrasedTestSuiteClassName>true</usePhrasedTestSuiteClassName>
<usePhrasedTestCaseClassName>true</usePhrasedTestCaseClassName>
<usePhrasedTestCaseMethodName>true</usePhrasedTestCaseMethodName>
</statelessTestsetReporter>
</configuration>结果如下:

xml
<testcase name="第一个测试" classname="测试类" time="0.013">
<system-out><![CDATA[test01
]]></system-out>
</testcase>
<testcase name="第二个测试" classname="测试类" time="0.004">
<system-out><![CDATA[test02
]]></system-out>
</testcase>除了以上配置,还有以下配置:
xml
<consoleOutputReporter implementation="org.apache.maven.plugin.surefire.extensions.junit5.JUnit5ConsoleOutputReporter">
<disable>true</disable>
<encoding>UTF-8</encoding>
<usePhrasedFileName>true</usePhrasedFileName>
</consoleOutputReporter>disable:是否禁用System.out.println,当设置为true时,如果在程序中使用System.out,内容不会出现在控制台;
除了使用surefire内置的插件来配置测试结果的显示,还可以使用第三方插件,配置如下:
xml
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.5</version>
<dependencies>
<dependency>
<groupId>me.fabriciorby</groupId>
<artifactId>maven-surefire-junit5-tree-reporter</artifactId>
<version>0.1.0</version>
</dependency>
</dependencies>
<configuration>
<reportFormat>plain</reportFormat>
<consoleOutputReporter>
<disable>true</disable>
</consoleOutputReporter>
<statelessTestsetInfoReporter implementation="org.apache.maven.plugin.surefire.extensions.junit5.JUnit5StatelessTestsetInfoTreeReporter"/>
</configuration>
</plugin>测试结果如下:

可以看到测试结果是以树状结构显示的。
TIP
reportFormat是用来设置target/surefire-reports/testName.txt测试结果格式的,可选值如下:
brief:默认值,只显示该测试类的总体测试结果及耗时;plain:除了显示该测试类的总体测试结果,还会显示每个测试方法的耗时和结果;
3. maven-surefire-report-plugin
maven-surefire-report-plugin的作用是解析位于${basedir}/target/surefire-reports目录下的TEST-*.xml文件,并使用DOXIA渲染为HTML界面,以展示测试结果。
3.1 基础使用
在pom.xml中配置maven-surefire-report-plugin插件如下:
xml
<build>
<plugins>
<!-- 用于运行单元测试 -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.3</version>
</plugin>
<!-- 测试报告生成插件,绑定在test阶段,执行report-only目标 -->
<!-- 当使用mvn test命令后,会生成target/reports/surefire.html文件,即测试报告 -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-report-plugin</artifactId>
<version>3.5.5</version>
<executions>
<execution>
<phase>test</phase>
<goals>
<goal>report-only</goal>
</goals>
</execution>
</executions>
</plugin>
<!-- 用于执行mvn site动作的插件 -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-site-plugin</artifactId>
<version>4.0.0-M16</version>
</plugin>
</plugins>
</build>
<reporting>
<plugins>
<!-- 在执行mvn site目标时,提供项目信息 -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-project-info-reports-plugin</artifactId>
<version>3.9.0</version>
</plugin>
<!-- 在执行mvn site目标时,提供测试结果信息,因此在生成的站点中,会出现单元测试结果 -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-report-plugin</artifactId>
<version>3.5.5</version>
</plugin>
</plugins>
</reporting>为什么只是简单配置测试报告,需要这么多内容?
为什么需要maven-site-plugin?因为从maven-surefire-report-plugin的版本2.8后,需要与maven-site-plugin:2.1及以上的版本配合使用;
为什么maven-site-plugin是放在
<build>中?Maven 的逻辑模型中,可以这样理解:
<build>是“工具箱”:这里放的是执行动作的插件(Worker)。maven-site-plugin是一个 Worker,它的工作是“搭建网站”。<reporting>是“原材料单”:这里放的是提供数据内容的插件(Data Providers)。比如surefire-report提供测试数据,javadoc提供代码文档。
maven-site-plugin必须在<build>中,因为它需要参与 Maven 的生命周期(Lifecycle)。 当输入mvn site时,是build里的这个插件启动了引擎,然后它去询问reporting里的插件:“嘿,你们有什么内容要放进我的网站里吗?”,然后site-plugin插件将获取到的数据整合构建成站点。为什么需要maven-project-info-reports-plugin?如果没有配置该插件,会提示以下内容:
txtReport plugin org.apache.maven.plugins:maven-project-info-reports-plugin has an empty version. It is highly recommended to fix these problems because they threaten the stability of your build. For this reason, future Maven versions might no longer support building such malformed projects.Maven 在 3.8.x 之后的版本中,对**隐式版本号(Implicit Versions)**的容忍度越来越低。
当运行
mvn site,Maven 就会默认去调用maven-project-info-reports-plugin。这个插件是 Maven Site 的“门面”,负责生成左侧菜单栏里的项目基本信息(如:项目概览、依赖列表、开发团队、源码仓库等)。如果不显式指定版本,Maven 会尝试从它的“超级 POM”里找一个过时的默认版本,这会导致构建的不稳定。为什么在
<build>和<reporting>中都有maven-surefire-report-plugin?在
<build>中的插件,是作为执行器的,可以将插件的目标绑定到某个生命周期上,当使用mvn运行该生命周期时,就会执行报告插件的目标,如上配置,会在mvn test周期调用插件的report-only目标,最终生成target/reports/surefire.html文件,即测试报告。
在
<reporting>中的插件,是作为数据提供者的,当运行mvn site时,提供测试结果数据,在生成的站点(target/site/index.html)中,会展示测试结果,如下:
3.2 配置
3.2.1 只显示失败的测试用例
xml
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-report-plugin</artifactId>
<version>3.5.5</version>
<configuration>
<showSuccess>false</showSuccess>
</configuration>
</plugin>3.2.2 修改报告名称
即生成的HTML默认名称为surefire.html,可以配置修改:
xml
<configuration>
<outputName>test-report</outputName>
</configuration>最后生成的HTML名称为test-report.html。
3.2.3 修改报告输出位置
默认情况下,报告生成位置在target/reports,可以使用以下配置修改报告输出位置:
xml
<configuration>
<outputDirectory>${basedir}/target/surefire-reports/reports</outputDirectory>
</configuration>TIP
注意,该项配置只有插件在<build>中有效。
3.2.4 maven-jxr-plugin
maven-jxr-plugin 是 Maven 生态中一个非常实用的辅助插件,它的全称是 Java Cross-Reference(Java 交叉引用)。
简单来说,它的作用是将 Java 源代码转换成一套带语法高亮、带行号、且具有超链接跳转功能的 HTML 页面。
在pom.xml中引入依赖:
xml
<reporting>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jxr-plugin</artifactId>
<version>3.6.0</version>
</plugin>
</plugins>
</reporting>然后,运行mvn site后,在站点中就多出了两个报告:

Source Xref:显示Java源代码;Test Source Xref:显示Java测试源代码;
有了maven-jxr-plugin,当测试失败时,就可以直接点接超链接,跳转到源码位置:

参考资料
[1] maven-surefire-plugin:https://maven.apache.org/surefire/maven-surefire-plugin/index.html
[2] maven-surefire-report-plugin:https://maven.apache.org/surefire/maven-surefire-report-plugin/index.html
[3] 子进程ForkedBooter源码:https://github.com/apache/maven-surefire/blob/master/surefire-booter/src/main/java/org/apache/maven/surefire/booter/ForkedBooter.java
[4] SurefireMojo源码:https://github.com/apache/maven-surefire/blob/master/maven-surefire-plugin/src/main/java/org/apache/maven/plugin/surefire/SurefireMojo.java
附录
1. 服务器测试
内置服务器:
java
package org.util;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
public class EmbeddedServer {
private static HttpServer server = null;
private static String url = null;
public static String getUrl(){
return url;
}
public static void start() throws IOException {
if (server != null) {
System.out.println("服务器已启动");
return;
}
// 1. 创建服务器,绑定 8080 端口
server = HttpServer.create(new InetSocketAddress(8080), 0);
// 2. 创建上下文(路由)
server.createContext("/hello", (exchange) -> {
String response = "Hello, this is a Java server!";
exchange.sendResponseHeaders(200, response.length());
try (OutputStream os = exchange.getResponseBody()) {
os.write(response.getBytes());
}
});
server.createContext("/world", (exchange) -> {
String response = "Hello, world!";
exchange.sendResponseHeaders(200, response.length());
try (OutputStream os = exchange.getResponseBody()) {
os.write(response.getBytes());
}
});
// 3. 启动
int port = server.getAddress().getPort();
url = "http://localhost:" + port;
System.out.printf("服务器已启动,访问 http://localhost:%d\n",port);
server.start();
}
public static void stop(){
if (server != null) {
server.stop(0);
server = null;
url = null;
}
}
}测试类,在@beforeAll中开启服务器:
java
package org.test;
import org.util.EmbeddedServer;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public class HttpServerTest01 {
@BeforeAll
public static void beforeAll() throws IOException {
EmbeddedServer.start();
}
@Test
void test01() throws IOException, InterruptedException {
// 1. 创建客户端
HttpClient client = HttpClient.newHttpClient();
// 2. 构建请求
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(EmbeddedServer.getUrl() + "/hello"))
.header("Accept", "application/json")
.GET()
.build();
// 3. 发送请求并获取响应(同步)
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
// 4. 打印结果
System.out.println("状态码: " + response.statusCode());
System.out.println("内容: " + response.body());
}
}java
package org.test;
import org.util.EmbeddedServer;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public class HttpServerTest02 {
@BeforeAll
public static void beforeAll() throws IOException {
EmbeddedServer.start();
}
@Test
void test01() throws IOException, InterruptedException {
// 1. 创建客户端
HttpClient client = HttpClient.newHttpClient();
// 2. 构建请求
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(EmbeddedServer.getUrl() + "/world"))
.header("Accept", "application/json")
.GET()
.build();
// 3. 发送请求并获取响应(同步)
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
// 4. 打印结果
System.out.println("状态码: " + response.statusCode());
System.out.println("内容: " + response.body());
}
}2. maven并行构建
Maven 并行构建(Parallel Maven Builds)指的是使用 Maven 的多线程/多进程能力,在构建过程中同时处理多个模块(multi-module 项目)或多个生命周期阶段,而不是按顺序逐个执行,从而大幅缩短整体构建时间。
这是 Maven 3.x(从 2010 年左右引入)的一个核心特性,主要通过命令行参数 -T(或 --threads)来启用。
例如:
txt
mvn clean install -T 4 # 使用 4 个线程并行构建
mvn clean install -T 1C # 使用 “1 × CPU 核心数” 个线程(最常用,自动适配机器)
mvn clean install -T 2C # 使用 2 × CPU 核心数
mvn clean install -T 4C # 使用 4 × CPU 核心数(适合大项目 + 多核机器)Maven 项目通常是 multi-module(多模块)的,例如一个父 pom 下有 10 个子模块(core、service、web、test 等)。
正常单线程构建:按顺序一个模块一个模块编译 → 打包 → 测试 → 安装,耗时长(尤其是模块多、依赖复杂时)。
启用 -T 后:Maven 会智能分析模块间的依赖关系(哪些模块可以并行构建),然后用多个线程/进程同时构建独立的模块。
结果:构建时间可能从 10 分钟降到 3-4 分钟(视项目规模和机器配置)。
当同时启用 Maven 核心并行构建(-T) + Surefire fork 多进程 时,会出现“嵌套并行”:
- Maven 用 -T 4 启动 4 个并行构建线程,每个线程负责构建一部分模块。
- 每个模块的 test 阶段,如果配置了 forkCount=2C(比如机器 8 核 → 16 个 fork子进程),则每个 Maven 构建线程又会启动多个 Surefire fork 子进程。
- 全局 fork 进程编号(从 1 开始计数)会累加:不只是单个模块的 forkCount,还要考虑 Maven 并行构建的线程数(-T 值)。
因此,子进程的编号,从 1 开始,一直数到 forkCount × Maven 并行构建的最大线程数(即 -T 的有效值)。
例如:
- 机器 8 核
- 运行:
mvn test -T 2C -DforkCount=1.5C(Maven 并行 16 线程,Surefire 每个模块 fork 12 个进程) - 则全局 forkNumber 可能从 1 到 16×12 = 192(极端情况)
3. 为什么mvn site会运行测试
为什么运行 mvn site 时,却看到了测试(Test)在运行?这背后隐藏着 Maven 为了保证“报告真实性”而做的一个自动触发机制。
Maven 定义了三套互不干扰的生命周期:
- Clean Lifecycle: 负责清理(
pre-clean,clean,post-clean)。 - Default (Build) Lifecycle: 负责构建(
compile,test,package,install,deploy)。 - Site Lifecycle: 负责生成文档(
pre-site,site,post-site,site-deploy)。
当你运行 mvn site 时,maven-site-plugin 会调用你在 <reporting> 中配置的插件。
对于 maven-surefire-report-plugin 来说:
- 它的任务是生成“测试报告”。
- 要生成报告,它必须要有测试结果文件(即
target/surefire-reports/*.xml)。 - 如果它发现这些文件不存在,或者为了确保报告是最新的,它会通过 Maven 内部机制 自动调用
test阶段。
这就是为什么运行mvn site会看到 default 生命周期中的 compiler:compile、resources:resources 和 surefire:test 被拉起来运行了。
因此,如果项目很大,测试很多,跑一次测试很长,那么就不要盲目使用mvn site了,推荐方案:
先跑
mvn test(确保测试逻辑通过)。再跑
mvn site -DskipTests(快速生成可视化文档)。