Skip to content

Maven 测试生命周期与测试目录约定 ​

T1-014建议学进阶约 45 分钟JDK 21测试专项 A:单元测试 · 覆盖率与门禁

1. 本节定位 ​

"测试写好了,怎么让它跑起来"——这个问题看起来简单,但三个具体的坑很常见:测试类没被发现、依赖没加 scope=test、集成测试被当单元测试跑。

本节把 Maven 与测试相关的约定、命令与报告位置讲清楚。这些都是"知道了就不会再踩,不知道就会反复查"的知识。

2. 学习目标 ​

完成本节后,你应该能够:

  • 说出标准的 Maven 目录结构,以及测试代码放在哪;
  • 说出 surefire 插件默认发现哪些测试类(命名约定);
  • 解释 scope=test 的作用,以及写错会有什么后果;
  • 用 -Dtest / -DskipTests / -Dmaven.test.skip=true 精确控制测试执行;
  • 找到并读懂 surefire 的测试报告;
  • 说清 surefire(单元测试)与 failsafe(集成测试)的分工。

3. 前置知识检查 ​

4. 为什么需要这个知识 ​

三个真实场景:

场景一:测试写了,但 mvn test 说 "No tests to run"。

原因通常是类名不符合约定——surefire 默认只认特定命名,这不会报错,只是安静地跳过。

场景二:JUnit 依赖忘了写 scope=test,测试框架被打进了生产包。

后果是包体积变大、依赖冲突,甚至在某些容器里因为多出来的类而出错。这个错误在开发阶段完全无感,只在发布时暴露。

场景三:集成测试和单元测试混在一起跑。

每次执行都要起数据库、等网络——本该 5 秒的单元测试变成了 5 分钟,于是没人再跑它。

这三个问题的共同点是:都是"约定"没搞清楚。

5. 核心概念 ​

5.1 标准目录结构 ​

text
testing-unit/
├── pom.xml
└── src/
    ├── main/
    │   ├── java/          # 生产代码
    │   └── resources/     # 生产资源(配置文件等)
    └── test/
        ├── java/          # 测试代码(不进入生产包)
        └── resources/     # 测试专用资源

两个关键约定:

  1. src/test/java 里的类不会被打进产物——这是 Maven 的硬保证,你不用担心测试代码泄露到生产;
  2. src/test/resources 优先于 src/main/resources 被加载(同名文件测试版本覆盖生产版本)——这是做测试配置覆盖的标准手段。

依赖作用域:

xml
<dependency>
  <groupId>org.junit.jupiter</groupId>
  <artifactId>junit-jupiter</artifactId>
  <scope>test</scope>        <!-- 关键:只在测试编译与运行期可见 -->
</dependency>
scope编译生产代码编译/运行测试进入生产包
compile(默认)✓✓✓
test✗✓✗
provided✓✓✗(由运行时环境提供)

记忆点:测试框架、断言库、Mock 库,一律 test。忘了写的后果是"生产包里多了 JUnit",得不偿失。

5.2 Maven 生命周期里的测试 ​

text
compile  →  test-compile  →  test  →  package  →  verify  →  install
           (编译测试代码)   (运行单元测试)

几个常用命令的语义:

命令做什么
mvn test编译生产代码 + 编译测试代码 + 运行单元测试
mvn package上面全部 + 打包(同时会跑测试)
mvn verify上面全部 + 运行集成测试(配置了 failsafe 时)
mvn install上面全部 + 装进本地仓库

一个容易忽略的点:mvn package 会跑测试。所以"打包时测试失败"不是环境问题,而是设计如此——这是防止把坏代码打进制品的最后一道门。

5.3 surefire 默认发现哪些测试类 ​

surefire(Maven 的单元测试插件)默认按类名匹配:

模式例子
**/Test*.javaTestOrderCalculator
**/*Test.javaOrderCalculatorTest ✓ 本项目用这种
**/*Tests.javaOrderCalculatorTests
**/*TestCase.javaOrderCalculatorTestCase

命名不符合约定 → 类不会被加载 → 安静地不执行。这就是场景一的成因。

排查方法:跑 mvn test 时注意 "Tests run" 的总数是否与预期一致。数字对不上,第一件事就是检查类名。

5.4 精确控制测试执行 ​

需求命令
只跑某个类-Dtest=OrderCalculatorTest
只跑某个方法-Dtest=OrderCalculatorTest#should_apply_discount_when_total_equals_threshold
按通配符跑-Dtest=OrderCalculator*
跑多个类-Dtest=OrderCalculatorTest,PasswordValidatorTest
排除某个类-Dtest=!OrderCalculatorTest
跳过执行(仍编译测试代码)-DskipTests
完全跳过(不编译也不运行)-Dmaven.test.skip=true
只跑某个 tag-Dgroups=slow(配合 @Tag("slow"))

-DskipTests 与 -Dmaven.test.skip=true 的区别很重要:

-DskipTests-Dmaven.test.skip=true
编译测试代码会不会
运行测试不会不会
适用临时跳过执行,仍希望测试代码能编译通过测试代码本身有问题、或需要极速构建时

实践建议:优先用 -DskipTests。因为它会编译测试代码——如果测试代码有编译错误,你会立刻知道,而不是等到某次正常构建才炸。

注意:-DskipTests 不应该出现在发布流程里。它只是本地排查用的临时开关。

5.5 测试报告在哪 ​

text
testing-unit/target/surefire-reports/
├── cn.kb.learn.testing.OrderCalculatorTest.txt      # 每个类一个文本报告
├── cn.kb.learn.testing.OrderCalculatorTest.xml      # 同名的 XML(给 CI 解析)
├── cn.kb.learn.testing.LifecycleTest.txt
├── ...

文本报告的内容(实测):

text
-------------------------------------------------------------------------------
Test set: cn.kb.learn.testing.OrderCalculatorTest
-------------------------------------------------------------------------------
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.083 s
  -- in cn.kb.learn.testing.OrderCalculatorTest

XML 报告的用途:CI 系统(Jenkins、GitHub Actions)解析它来渲染"测试趋势图"和"失败列表"。这就是 CI 上能看到"哪些测试失败"的原因——靠的不是控制台输出,而是这个 XML。

注意 target/ 目录不进版本库(本项目在 .gitignore 里忽略了它):报告是构建产物,每次构建重新生成。

5.6 surefire 与 failsafe:两种测试分开跑 ​

surefirefailsafe
目标单元测试集成测试
默认类名*Test / Test* / *Tests / *TestCase*IT / IT* / *ITCase
绑定阶段testintegration-test + verify
失败时的行为立即终止构建先跑完所有测试,再在 verify 阶段报告失败
mvn test 会跑吗✓✗(要 mvn verify)

为什么集成测试要单独一个插件:

  1. 速度不同:集成测试通常慢几十倍,不该拖累日常的 mvn test;
  2. 失败处理不同:failsafe 会先跑完所有集成测试再报告失败,避免"第一个失败就中断、后面的测试有没有问题都不知道";
  3. 环境要求不同:集成测试需要数据库、容器、外部服务,本地未必具备。

配置片段(在需要使用集成测试的模块里加):

xml
<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-failsafe-plugin</artifactId>
  <version>3.2.5</version>
  <executions>
    <execution>
      <goals>
        <goal>integration-test</goal>
        <goal>verify</goal>
      </goals>
    </execution>
  </executions>
</plugin>

这也是本项目当前的状态:labs 只配置了 surefire(单元测试),集成测试在 T1-017 Spring 集成测试 里才引入。

5.7 用 @Tag 分组执行 ​

java
@Tag("slow")
@Test
void calls_remote_service() { }
bash
./mvnw test -Dgroups=slow          # 只跑慢测试
./mvnw test -DexcludedGroups=slow  # 排除慢测试(本地开发常用)

适用场景:把"依赖外部环境""执行时间长""需要特定数据"的测试标出来,让日常构建跑得快、让完整构建跑得全。这也是 CI 分层执行的基础(T2-017 测试流水线)。

6. 递进示例 ​

6.1 最小示例:跑单个测试类 ​

bash
./mvnw -pl testing-unit test -Dtest=OrderCalculatorTest

执行结果(实测):

text
[INFO] Running cn.kb.learn.testing.OrderCalculatorTest
[INFO] Tests run: 9, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.116 s
  -- in cn.kb.learn.testing.OrderCalculatorTest
[INFO] Tests run: 9, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS

注意 Tests run: 9——只跑了这一个类的 9 个测试,其他类都没执行。

6.2 业务示例:只跑一个方法 ​

bash
./mvnw -pl testing-unit test -Dtest=OrderCalculatorTest#should_apply_discount_when_total_equals_threshold
text
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.083 s
[INFO] BUILD SUCCESS

这是排查单个失败用例时最有用的命令——不用等整个套件跑完。

6.3 反例:用 -DskipTests 掩盖问题 ​

bash
./mvnw package -DskipTests          # 打包时不跑测试

在紧急修复时可以用,但要注意:

  1. 它不会跳过编译,所以测试代码如果引用了已删除的方法,构建仍会失败——这是好事;
  2. 不要把它写进 CI 脚本或发布流程。测试是"最后一道门",绕过去等于把这个门拆了;
  3. 用完记得在本地跑一次完整的 mvn test。

7. 完整可运行命令 ​

本节不新增测试代码,用 labs/testing-unit 现有工程实操。

命令一:跑整个模块的测试

powershell
cd labs
./mvnw -pl testing-unit test

执行结果(实测):

text
[INFO] Tests run: 58, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS

命令二:只跑一个测试类

powershell
./mvnw -pl testing-unit test -Dtest=OrderCalculatorTest
text
[INFO] Running cn.kb.learn.testing.OrderCalculatorTest
[INFO] Tests run: 9, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS

命令三:只跑一个测试方法

powershell
./mvnw -pl testing-unit test "-Dtest=OrderCalculatorTest#should_apply_discount_when_total_equals_threshold"
text
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS

命令四:跳过测试执行但保留编译

powershell
./mvnw -pl testing-unit test -DskipTests
text
[INFO] Tests are skipped.
[INFO] BUILD SUCCESS

命令五:查看测试报告

powershell
Get-ChildItem testing-unit/target/surefire-reports
text
cn.kb.learn.testing.ArticleServiceTest.txt
cn.kb.learn.testing.ArticleServiceTest.xml 的同类文件...
cn.kb.learn.testing.AssertjDemoTest.txt
cn.kb.learn.testing.ExceptionAndTimeoutTest.txt
cn.kb.learn.testing.LifecycleTest.txt
cn.kb.learn.testing.LifecycleTest$PerClassLifecycle.txt
cn.kb.learn.testing.LifecycleTest$WhenQuantityIsOne.txt
cn.kb.learn.testing.OrderCalculatorTest.txt
cn.kb.learn.testing.OrderServiceTest.txt
cn.kb.learn.testing.ParameterizedOrderCalculatorTest.txt
cn.kb.learn.testing.PasswordValidatorTest.txt
cn.kb.learn.testing.UserServiceTest.txt
TEST-cn.kb.learn.testing.OrderCalculatorTest.xml 等 XML 文件

注意 LifecycleTest$PerClassLifecycle 这类文件名:嵌套测试类会单独生成一份报告。所以报告文件数可能多于"测试类个数"——这也是本项目 Tests run: 58 与报告文件数不完全对应的原因。

8. Debug 观察点 ​

  1. 运行 ./mvnw -pl testing-unit test -Dtest=OrderCalculatorTest,观察 Maven 输出的阶段顺序:resources → compile → testResources → testCompile → surefire:test——这就是"生命周期"的字面含义;
  2. 把 OrderCalculatorTest 改名为 OrderCalculatorCheck,重跑 ./mvnw -pl testing-unit test,观察 Tests run 数字变小(这个类不再被执行)——亲手体验"命名约定"这道隐形规则(完成后改回来);
  3. 打开 target/surefire-reports/*.xml,找到 <testcase> 节点,观察它记录了类名、方法名、耗时——CI 的测试报告就是解析这个文件渲染出来的;
  4. 在 labs/pom.xml 里临时删掉 <scope>test</scope>,跑一次 ./mvnw -pl testing-unit dependency:tree,观察 JUnit 从 test 作用域变成了 compile 作用域(完成后改回来)。

9. 常见错误 ​

错误 1:测试类名不符合约定 ​

java
class OrderCalculatorCheck { }        // 不以 Test 开头或结尾 → 不会被 surefire 发现

症状:mvn test 显示 "Tests run: 0" 或总数比预期少,但不报错。

修复:改成 OrderCalculatorTest(或用 <includes> 显式配置匹配规则——但不建议偏离约定)。

错误 2:测试依赖忘记 scope=test ​

xml
<dependency>
  <groupId>org.assertj</groupId>
  <artifactId>assertj-core</artifactId>
  <version>3.26.3</version>
  <!-- 漏了 <scope>test</scope> -->
</dependency>

后果:AssertJ 被打进生产包。修复:所有测试专用依赖都加 scope=test。

排查方法:./mvnw dependency:tree -Dscope=compile 看生产依赖里有没有测试库。

错误 3:把集成测试放在 *Test 命名下 ​

java
class UserRepositoryIntegrationTest { }     // 以 Test 结尾 → 每次 mvn test 都会跑它

后果:本地没有数据库时 mvn test 直接失败,开发体验崩塌。

修复:改名为 UserRepositoryIT,并配置 failsafe 插件——让需要环境的测试只在 mvn verify 时执行。

错误 4:生产代码依赖了测试代码 ​

症状:编译成功(IDE 里都能看到),但 mvn package 时报"找不到符号"。

原因:IDE 把 src/test/java 也放进了类路径,而 Maven 不会。Maven 的行为才是正确的——这也说明不要只信 IDE 的编译结果。

错误 5:靠 -DskipTests 让构建变绿 ​

见 6.3 节。修复:找出失败原因,而不是跳过测试。

10. 动手实验 ​

目标:把 Maven 的测试执行完全摸一遍。

步骤:

  1. 依次运行第 7 节的五条命令,核对每一条的输出;
  2. 把 OrderCalculatorTest 临时改名为 OrderCalculatorCheck,跑完整测试,记录 Tests run 从 58 变成多少;再改回来;
  3. 故意让 OrderCalculatorTest 里的一个断言失败,分别用 -Dtest=OrderCalculatorTest 与完整 mvn test 各跑一次,对比"失败时构建是否继续执行后续测试类";
  4. 打开 testing-unit/target/surefire-reports/cn.kb.learn.testing.OrderCalculatorTest.txt,找出失败用例的记录格式(提示:第 3 步之后它会有 <failure> 记录);
  5. 给 ParameterizedOrderCalculatorTest 加 @Tag("slow"),用 -DexcludedGroups=slow 跑一次,观察它是否被排除。

验收方式:能说出第 3 步两次运行的差别,以及第 5 步 -DexcludedGroups 的效果。

扩展任务:在 testing-unit 模块里加一个 UserRepositoryIT(只打印一行"集成测试执行"),配置 failsafe 插件,验证:mvn test 不跑它、mvn verify 会跑它。

11. 练习题 ​

基础题

  1. 判断:src/test/java 里的类会被打进最终的 jar 包。(对 / 错)
  2. 填空:surefire 默认匹配的四种类名模式是 、、、。
  3. 简答:-DskipTests 与 -Dmaven.test.skip=true 的区别是什么?

代码阅读题

text
src/
├── main/java/com/example/UserService.java
├── main/resources/application.properties
├── test/java/com/example/UserServiceTests.java
├── test/java/com/example/UserServiceIntegrationIT.java
└── test/resources/application.properties
  1. 运行 mvn test 会执行哪些测试类?
  2. 测试里的 application.properties 与生产的同名文件,哪个会被测试加载到?

编程题

  1. 为一个新模块配置测试环境:
    • 创建 labs/testing-lifecycle 模块(packaging 为 jar);
    • 添加 JUnit 5 与 AssertJ 依赖(注意 scope);
    • 写一个 Calculator(加法与除法,除数为 0 抛 ArithmeticException)与对应的 CalculatorTest;
    • 验证:mvn -pl testing-lifecycle test 能跑通;
    • 额外验证:把测试类改名为 CalculatorCheck 后 Tests run 变成 0,再改回来。

综合任务

  1. 设计一套"分层测试执行策略":
    • 单元测试(*Test,快):每次 mvn test 都跑;
    • 慢测试(@Tag("slow")):本地默认排除,CI 上执行;
    • 集成测试(*IT,需要外部服务):只在 mvn verify 时执行;
    • 写一份"开发日常 / 提交前 / CI 上"三条命令清单,并说明每条命令的耗时预期;
    • 说明:为什么"让 CI 跑得比本地全"是合理的,而"让本地跑得和 CI 一样全"往往不被接受?
分级提示

提示 1(方向):第 4 题的关键是 surefire 只认 *Test / Test* / *Tests / *TestCase,*IT 归 failsafe。

提示 2(关键点):第 6 题的 pom 只需要 parent 继承 + JUnit 依赖(scope test)——参考 labs/testing-unit/pom.xml。

提示 3(接近答案):第 7 题的核心矛盾是"反馈速度 vs 覆盖范围"。本地要快(秒级),CI 要全(分钟级),用命名约定与 tag 把两者分开。

12. 参考答案 ​

基础题
  1. 错。src/test/java 只在测试编译与运行期使用,不会被打进产物。这是 Maven 的硬保证,也是"测试代码可以随便写辅助工具类"的底气。
  2. **/Test*.java、**/*Test.java、**/*Tests.java、**/*TestCase.java。
  3. -DskipTests:跳过执行,但仍编译测试代码;-Dmaven.test.skip=true:不编译也不执行。

实践上优先用 -DskipTests:因为它保留了"测试代码能否编译通过"这道检查。测试代码引用了已删除的方法时,-DskipTests 会让构建失败(好事),而 -Dmaven.test.skip=true 会掩盖这个问题。

代码阅读题(第 4、5 题)

第 4 题:mvn test 只执行 UserServiceTests。

UserServiceIntegrationIT 以 IT 结尾,归 failsafe 管,只在 mvn verify 时执行。

第 5 题:测试目录下的 application.properties 会生效。

Maven 的类路径里 target/test-classes 排在 target/classes 之前,所以同名资源测试版本胜出。这是做"测试环境配置覆盖"的标准手段——不用改生产配置,也能让测试连到测试数据库。

一个常见困惑:本地跑测试通过、CI 上失败,有时候就是"某个测试配置在 CI 上没有对应文件"导致的。检查顺序:src/test/resources → src/main/resources。

编程题(第 6 题)
xml
<!-- labs/testing-lifecycle/pom.xml -->
<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <parent>
    <groupId>cn.kb.learn</groupId>
    <artifactId>labs</artifactId>
    <version>1.0.0</version>
  </parent>

  <artifactId>testing-lifecycle</artifactId>
  <name>labs-testing-lifecycle</name>

  <dependencies>
    <dependency>
      <groupId>org.junit.jupiter</groupId>
      <artifactId>junit-jupiter</artifactId>
      <scope>test</scope>
    </dependency>
  </dependencies>
</project>
java
// src/main/java/cn/kb/learn/lifecycle/Calculator.java
package cn.kb.learn.lifecycle;

public class Calculator {
    public long add(long a, long b) {
        return a + b;
    }

    public long divide(long a, long b) {
        if (b == 0) {
            throw new ArithmeticException("除数不能为 0");
        }
        return a / b;
    }
}
java
// src/test/java/cn/kb/learn/lifecycle/CalculatorTest.java
package cn.kb.learn.lifecycle;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

class CalculatorTest {
    private final Calculator calculator = new Calculator();

    @Test
    @DisplayName("加法")
    void adds() {
        assertThat(calculator.add(2, 3)).isEqualTo(5L);
    }

    @Test
    @DisplayName("除以 0 抛 ArithmeticException")
    void rejects_division_by_zero() {
        assertThatThrownBy(() -> calculator.divide(1, 0))
                .isInstanceOf(ArithmeticException.class)
                .hasMessage("除数不能为 0");
    }
}

验证:

powershell
./mvnw -pl testing-lifecycle test
# Tests run: 2, Failures: 0, Errors: 0, Skipped: 0

# 改名成 CalculatorCheck 后:
# Tests run: 0, Failures: 0, Errors: 0, Skipped: 0

注意:把类改名为 CalculatorCheck 后,构建仍然 BUILD SUCCESS——这正是"命名约定"最危险的地方:它不报错,只是安静地不执行。

综合任务(第 7 题)

三条命令清单:

bash
# 1. 开发日常(秒级):改一行代码跑一次
./mvnw -pl testing-unit test -Dtest=OrderCalculatorTest        # 单个类,最快
./mvnw -pl testing-unit test -DexcludedGroups=slow             # 整个模块,排除慢测试

# 2. 提交前(十几秒到一分钟):确保不会污染主分支
./mvnw -pl testing-unit test                                    # 全部单元测试
./mvnw -pl testing-unit test -Dgroups=slow                      # 慢测试也跑一遍

# 3. CI 上(几分钟):完整验证
./mvnw -B -ntp verify                                           # 单元 + 集成测试
./mvnw -B -ntp test -Dgroups=slow                               # 慢测试
# 附加:覆盖率门禁(T1-018)、静态检查

为什么"CI 比本地全"是合理的:

维度本地CI
目标快速反馈(改代码 → 知道对不对)完整验证(合并前必须全绿)
频率每分钟可能多次每次推送一次
成本开发者等待的时间机器时间(可并行、可等待)
环境可能缺数据库、外部服务可以准备完整环境

核心矛盾:反馈速度与覆盖范围成反比。开发者如果每次改一行都要等 5 分钟,就会开始绕过测试(-DskipTests 满天飞),测试的实际保护力反而下降。

所以分层不是妥协,而是为了让"该跑的时候一定跑,不该跑的时候不拖累人"。这也是为什么"测试金字塔"(T1-005 测试金字塔与测试左移)强调"大量快速单元测试 + 少量慢速集成测试"——速度本身就是测试策略的一部分。

13. 掌握标准 ​

  • [ ] 知道 src/test/java 与 src/test/resources 的约定与作用;
  • [ ] 能给测试依赖正确加 scope=test;
  • [ ] 能说出 surefire 的四种命名模式,并在"测试没跑"时第一时间检查类名;
  • [ ] 会用 -Dtest 精确指定类/方法,用 -DskipTests 临时跳过;
  • [ ] 知道报告在 target/surefire-reports,并知道 XML 是给 CI 用的;
  • [ ] 能说清 surefire 与 failsafe 的分工与"为什么要分开"。

14. 扩展资源 ​

15. 下一步学习 ​

《JaCoCo:覆盖率指标与报告》:让测试的"执行情况"变成可量化的数字。

进度只保存在本机浏览器,可随时清除

个人学习项目:AI 生成内容均经人工复核,第三方资源仅作索引