某团队Maven自动化测试踩坑实录:Jenkins流水线到覆盖率门禁全流程
说实话,写下这篇文章的时候,我的咖啡已经凉透了。不是因为懒,是因为昨晚为了调通覆盖率门禁,我们团队连续奋战到凌晨三点。但今天看到测试覆盖率报告从62%一路飙到91%,那种成就感,确实值了。
如果你正在为Maven自动化测试发愁,或者Jenkins流水线总是莫名其妙地失败,这篇文章可能会帮到你。我们踩过的坑,你就不用踩了。
一、故事从这里开始:我们的测试噩梦
两年前,我们团队还是”手测为主、自动化为辅”的状态。每次发版前,测试同学手动跑一遍核心用例,耗时两三个小时,还得祈祷别漏掉什么。开发同学更惨,代码提交后不知道有没有回归,只能干等。
那时候我们的项目结构是这样的:
my-project/
├── pom.xml
├── src/
│ ├── main/
│ │ └── java/
│ │ └── com/example/
│ │ ├── controller/
│ │ ├── service/
│ │ └── repository/
│ └── test/
│ └── java/
│ └── com/example/
│ ├── controller/
│ ├── service/
│ └── repository/
└── Jenkinsfile
看起来挺规整的,对吧?但问题在于:
- 测试用例跑得慢——有200多个测试,串行执行,每次跑完要15分钟
- 覆盖率没人关注——虽然用了JaCoCo,但没人设置门禁,60%的覆盖率也能发布
- Jenkins流水线不稳定——有时候成功,有时候失败,日志翻半天找不到原因
最崩溃的一次,是测试同学手动发现了一个Bug,但我们自动化测试根本没覆盖到。那个Bug在生产环境造成了半小时的服务中断。
从那天起,我们决定:把测试自动化做到位,Jenkins流水线加上覆盖率门禁,宁可慢一点,也要稳一点。
二、第一步:让Maven测试跑起来,并且跑得稳
2.1 基础配置:Surefire插件
Maven的测试靠的是maven-surefire-plugin。这是标配,但很多人不知道可以配置的东西其实很多。
我们的pom.xml里加了这些关键配置:
<build>
<plugins>
<!-- Surefire:运行测试 -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.1.2</version>
<configuration>
<!-- 并行执行测试,提升速度 -->
<parallel>methods</parallel>
<threadCount>4</threadCount>
<!-- 失败后继续执行其他测试,不要一错就停 -->
<failIfNoTests>false</failIfNoTests>
<rerunFailingTestsCount>2</rerunFailingTestsCount>
<!-- 测试报告格式 -->
<reportFormat>plain</reportFormat>
<!-- 排除某些不稳定的测试 -->
<excludes>
<exclude>**/IT*.java</exclude>
<exclude>**/*IntegrationTest.java</exclude>
</excludes>
</configuration>
</plugin>
<!-- Failsafe:运行集成测试 -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId>
<version>3.1.2</version>
<configuration>
<includes>
<include>**/*IT.java</include>
<include>**/*IntegrationTest.java</include>
</includes>
</configuration>
<executions>
<execution>
<goals>
<goal>integration-test</goal>
<goal>verify</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
这里有个坑:很多人把单元测试和集成测试混在一起跑。结果是什么?集成测试依赖数据库、消息队列等外部服务,不稳定,容易失败。一旦失败,整个流水线就挂了,连单元测试的结果都看不到。
所以我们把单元测试和集成测试分开:
- 单元测试用Surefire,不依赖外部服务,跑得快
- 集成测试用Failsafe,依赖外部服务,单独跑
2.2 测试依赖:Mockito和TestContainers
我们项目主要用Spring Boot,测试时经常需要模拟外部依赖。Mockito是标配,但我们后来发现,对于需要真实数据库的场景,TestContainers才是真正的救星。
<!-- 测试依赖 -->
<dependencies>
<!-- Spring Boot Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- Mockito -->
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<scope>test</scope>
</dependency>
<!-- TestContainers:用Docker容器跑测试数据库 -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<version>1.18.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>1.18.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<version>1.18.3</version>
<scope>test</scope>
</dependency>
</dependencies>
用TestContainers写集成测试是这样的:
@Testcontainers
class OrderServiceIntegrationTest {
// 启动一个PostgreSQL容器
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@DynamicPropertySource
static void configureProperties(DynamicPropertySource properties) {
properties.register("spring.datasource.url", postgres::getJdbcUrl);
properties.register("spring.datasource.username", postgres::getUsername);
properties.register("spring.datasource.password", postgres::getPassword);
}
@Autowired
private OrderService orderService;
@Test
void shouldCreateOrderSuccessfully() {
Order order = new Order("USER-001", Arrays.asList("ITEM-001", "ITEM-002"), 2);
Order created = orderService.createOrder(order);
assertNotNull(created.getId());
assertEquals("USER-001", created.getUserId());
assertEquals(2, created.getItems().size());
}
}
这个方案的好处:测试环境是隔离的、可复现的。不管在本地还是Jenkins上,用的都是同一个PostgreSQL容器,不会出现”我本地能过,Jenkins上挂了”的情况。
但我们踩过的坑:TestContainers在Jenkins上跑的时候,需要Jenkins有Docker权限。我们一开始没配置,结果测试全部失败,日志里满是”Cannot connect to Docker daemon”的错误。后来在Jenkins节点上安装了Docker,并在Jenkins配置里给了Docker权限,才搞定。
三、JaCoCo覆盖率:不只是看数字,而是设门槛
3.1 基础配置
JaCoCo是Java代码覆盖率分析的事实标准。我们一开始以为配个插件就行了,结果发现配置不当会有各种问题。
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.10</version>
<executions>
<!-- 测试前准备agent -->
<execution>
<id>prepare-agent</id>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<!-- 测试后生成报告 -->
<execution>
<id>report</id>
<phase>test</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
<!-- 覆盖率检查(门禁) -->
<execution>
<id>check</id>
<goals>
<goal>check</goal>
</goals>
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>LINE</counter>
<value>COVEREDRATIO</value>
<minimum>0.85</minimum>
</limit>
<limit>
<counter>BRANCH</counter>
<value>COVEREDRATIO</value>
<minimum>0.75</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>
这里的配置有几个关键点:
prepare-agent:在测试开始前注入JaCoCo agent,记录覆盖率数据。report:测试结束后生成HTML和XML报告。check:这是门禁的核心。我们设置了两个规则:- 行覆盖率(LINE)不低于85%
- 分支覆盖率(BRANCH)不低于75%
3.2 覆盖率门禁的坑
坑1:覆盖率计算的范围不对
我们一开始只配了<element>BUNDLE</element>,结果发现生成的报告包括了所有模块的汇总数据,但某个子模块的覆盖率其实很低。后来改成按包(PACKAGE)来检查:
<rule>
<element>PACKAGE</element>
<limits>
<limit>
<counter>LINE</counter>
<value>COVEREDRATIO</value>
<minimum>0.80</minimum>
</limit>
</limits>
<excludes>
<!-- 排除DTO、枚举等不需要测试的类 -->
<exclude>com.example.dto.*</exclude>
<exclude>com.example.enums.*</exclude>
<exclude>com.example.config.*</exclude>
</excludes>
</rule>
坑2:异常分支被计入覆盖率,但测试里没处理
有些代码有try-catch,JaCoCo会把异常分支也算进去。但测试的时候,我们只测了正常路径,异常路径没测。结果覆盖率不达标。解决方法是在测试里加上异常场景的测试用例:
@Test(expected = BusinessException.class)
void shouldThrowExceptionWhenOrderNotFound() {
when(orderRepository.findById("INVALID-ID")).thenReturn(Optional.empty());
orderService.getOrder("INVALID-ID");
}
坑3:覆盖率报告在Jenkins里看不到
我们一开始在Jenkins上跑了Maven,但覆盖率报告没显示。原来是因为Jenkins需要安装JaCoCo Plugin,并且在Pipeline里配置了jacoco步骤。配置如下:
pipeline {
agent any
stages {
stage('Build') {
steps {
sh 'mvn clean test'
}
}
stage('Coverage') {
steps {
jacoco execFile: 'target/jacoco.exec'
}
}
}
post {
always {
jacoco buildHealth(
sourceFilePattern: '**/*.exec',
unhealthy: 75,
unstableThreshold: 85
)
publishHTML(target: [
allowMissing: false,
alwaysLinkToLastBuild: true,
keepAll: true,
reportDir: 'target/site/jacoco',
reportFiles: 'index.html',
reportName: 'JaCoCo Coverage Report'
])
}
}
}
这样每次构建后,Jenkins上都能看到覆盖率趋势图,还能设置阈值——低于阈值就报警。
四、Jenkins流水线:从”能跑”到”靠谱”
4.1 我们的Jenkinsfile
pipeline {
agent any
environment {
MAVEN_OPTS = '-Xmx2g -XX:MaxMetaspaceSize=512m'
DOCKER_HOST = 'unix:///var/run/docker.sock'
}
stages {
stage('Checkout') {
steps {
checkout scm
}
}
stage('Compile') {
steps {
sh 'mvn clean compile -DskipTests'
}
post {
failure {
mail to: 'team@example.com',
subject: "编译失败: ${env.JOB_NAME} #${env.BUILD_NUMBER}",
body: "详情请查看: ${env.BUILD_URL}"
}
}
}
stage('Unit Test') {
steps {
sh 'mvn test -pl . -Dtest=\"!IT*,!*IntegrationTest\"'
}
post {
always {
junit allowEmptyResults: true, testResults: '**/target/surefire-reports/TEST-*.xml'
}
}
}
stage('Coverage Check') {
steps {
sh 'mvn jacoco:check'
}
}
stage('Integration Test') {
steps {
sh 'mvn verify -pl . -Dtest=\"IT*,*IntegrationTest\"'
}
post {
always {
failsafe allowEmptyResults: true, testResults: '**/target/failsafe-reports/TEST-*.xml'
}
}
}
stage('Package') {
steps {
sh 'mvn package -DskipTests'
}
}
stage('Docker Build') {
when {
branch 'main'
}
steps {
sh '''
docker build -t myapp:${BUILD_NUMBER} .
docker tag myapp:${BUILD_NUMBER} registry.example.com/myapp:${BUILD_NUMBER}
docker push registry.example.com/myapp:${BUILD_NUMBER}
'''
}
}
}
post {
success {
mail to: 'team@example.com',
subject: "构建成功: ${env.JOB_NAME} #${env.BUILD_NUMBER}",
body: "构建链接: ${env.BUILD_URL}"
}
failure {
slackSend channel: '#build-failures',
message: "❌ 构建失败: ${env.JOB_NAME} #${env.BUILD_NUMBER} - ${env.BUILD_URL}"
}
always {
cleanWs()
}
}
}
这个流水线的逻辑是:
- Checkout:拉代码
- Compile:编译,失败就发邮件
- Unit Test:跑单元测试(排除集成测试)
- Coverage Check:检查覆盖率门禁
- Integration Test:跑集成测试
- Package:打包
- Docker Build:只在main分支构建镜像
4.2 流水线踩坑实录
坑1:测试并行跑,数据竞争
我们一开始开启了Surefire的并行测试:
<parallel>methods</parallel>
[threadCount>4</threadCount>
结果发现,有些测试用同一个数据库连接,并行跑的时候数据被污染了。一个测试插入数据,另一个测试查数据,结果不稳定。
解决方案:给每个测试类用独立的数据库实例(TestContainers会自动做),或者用@Transactional注解,测试结束后自动回滚。
@TestPropertySource(properties = "spring.datasource.url=jdbc:tc:postgresql:15:///testdb")
@Transactional
class OrderServiceTest {
// 每个测试方法结束后事务自动回滚,数据不会污染其他测试
}
坑2:JaCoCo报告路径不对
我们项目是多模块的,每个模块有自己的jacoco.exec文件。一开始Jenkins只取了一个模块的报告,其他模块的覆盖率数据丢了。
解决方案:在Pipeline里合并所有模块的exec文件:
stage('Coverage Merge') {
steps {
sh 'mvn jacoco:merge'
}
post {
always {
jacoco execFile: 'target/jacoco-aggregate/jacoco.exec'
}
}
}
坑3:Docker构建在Jenkins上超时
我们的镜像比较大(基于Java 17,带了一些工具),Docker构建经常超时。
解决方案:
- 用多阶段构建,减小镜像体积
- 给Jenkins节点加内存,或者给构建过程设置更大的超时
# 多阶段构建
FROM eclipse-temurin:17-jre-alpine AS builder
WORKDIR /app
COPY target/*.jar app.jar
RUN java -Djarmode=layertools -jar app.jar extract
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY --from=builder /app/dependencies/ ./
COPY --from=builder /app/spring-boot-loader/ ./
COPY --from=builder /app/snapshot-dependencies/ ./
COPY --from=builder /app/application/ ./
ENTRYPOINT ["java", "org.springframework.boot.loader.JarLauncher"]
五、覆盖率门禁:如何设定合理的阈值
这是争议最大的问题。很多人会说:”85%够了吗?要不要90%?”
我们的经验是:
5.1 分阶段推进,不要一步到位
一开始我们就设90%的门槛,结果覆盖率只有62%,团队士气大跌。后来我们分了三步走:
- 第一阶段:覆盖率70%以上,重点补充核心业务的测试
- 第二阶段:覆盖率80%以上,开始关注分支覆盖率
- 第三阶段:覆盖率85%以上,追求高质量测试
5.2 区分核心模块和非核心模块
不是所有代码都要同样高的覆盖率。我们的做法是:
<rule>
<element>PACKAGE</element>
<limits>
<limit>
<counter>LINE</counter>
<value>COVEREDRATIO</value>
<minimum>0.85</minimum>
</limit>
</limits>
<excludes>
<!-- 低优先级模块,覆盖率要求50%即可 -->
<exclude>com.example.infrastructure.*</exclude>
<exclude>com.example.dto.*</exclude>
</excludes>
</rule>
<rule>
<element>PACKAGE</element>
<includes>
<include>com.example.service.*</include>
<include>com.example.controller.*</include>
</includes>
<limits>
<limit>
<counter>LINE</counter>
<value>COVEREDRATIO</value>
<minimum>0.90</minimum>
</limit>
<limit>
<counter>BRANCH</counter>
<value>COVEREDRATIO</value>
<minimum>0.80</minimum>
</limit>
</limits>
</rule>
核心服务层和控制器层,要求90%行覆盖率、80%分支覆盖率。基础设施层和DTO,只要50%就行。
5.3 质量比数量重要
覆盖率只是手段,不是目的。我们见过这样的测试:
@Test
void testCreateOrder() {
Order order = new Order();
order.setUserId("USER-001");
order.setItems(Arrays.asList("ITEM-001"));
order.setTotalAmount(BigDecimal.valueOf(100));
Order result = orderService.createOrder(order);
assertNotNull(result);
assertEquals("USER-001", result.getUserId());
}
这种测试只是为了”凑覆盖率”,没有真正验证业务逻辑。好的测试应该是这样的:
@Test
void shouldApplyDiscountWhenUserIsVip() {
// 准备:创建一个VIP用户
User vipUser = new User("USER-001", UserType.VIP);
when(userRepository.findById("USER-001")).thenReturn(Optional.of(vipUser));
// 准备:订单金额为200元,VIP打8折
Order order = new Order("USER-001", Arrays.asList("ITEM-001"), 200);
// 执行
Order result = orderService.createOrder(order);
// 验证:折扣后金额应该是160元
assertEquals(BigDecimal.valueOf(160), result.getTotalAmount());
assertEquals(OrderStatus.CONFIRMED, result.getStatus());
}
这样的测试才真正验证了业务逻辑——VIP用户能享受折扣。
六、我们现在的状态
经过半年的折腾,我们团队的测试体系已经比较成熟:
- 单元测试:200+个测试,并行执行,5分钟跑完
- 集成测试:50+个测试,依赖TestContainers,10分钟跑完
- 覆盖率:核心模块90%+,整体85%+
- Jenkins流水线:从代码提交到构建完成,20分钟内
- 发布频率:从两周一次,提升到每天多次
最重要的是,团队对代码质量的重视程度提高了。以前写测试是被迫的,现在大家会觉得”不写测试就不舒服”。
七、给你的建议
如果你也想搭建类似的体系,我的建议是:
- 从小处着手:不要一开始就搞全套,先挑一个核心模块,把测试和Jenkins流水线跑通
- 分阶段推进:覆盖率阈值不要一步到位,给团队适应的时间
- 工具选型要慎重:TestContainers确实好用,但要确保Jenkins有Docker权限
- 培养团队习惯:技术只是手段,最重要的是让团队形成”写测试是本职工作”的意识
这条路我们走了半年,踩了不少坑,但也收获了很多。如果你正在走这条路,希望这篇文章能帮你少踩一些坑。
有问题随时留言,我看到了都会回。毕竟,一个人踩过的坑,不应该让另一个人再踩一遍。
