测试覆盖率与集成测试
概述
测试覆盖率是衡量代码测试质量的定量指标,反映了测试用例对源代码的覆盖程度。集成测试验证多个模块协同工作的正确性,Testcontainers 为集成测试提供轻量级、可复用的容器化环境。本文将系统阐述测试覆盖率指标定义与工具链、基于 Testcontainers 的集成测试实践,以及配套的测试数据管理策略。
测试覆盖率指标
核心指标定义
测试覆盖率从不同维度衡量测试的充分性,常用的指标包括以下几项。
行覆盖率
行覆盖率(Line Coverage)衡量测试执行过程中,代码中被执行到的代码行占总可执行代码行的百分比。它是最直观的覆盖率指标,反映有多少代码行被测试用例命中。
计算公式:
行覆盖率 = (被执行到的代码行数 / 总可执行代码行数) x 100%注意事项:
- 注释、空行、包声明、import 语句不计入可执行代码行
- 单纯的行覆盖率不能反映分支逻辑是否被充分覆盖
- 一行代码包含多个逻辑路径时,行覆盖率为 100% 不代表所有路径都被测试
分支覆盖率
分支覆盖率(Branch Coverage)衡量条件语句中所有可能分支(true/false)被覆盖的比例。它弥补了行覆盖率的不足,确保每个布尔表达式的结果都被验证。
计算公式:
分支覆盖率 = (被执行到的分支数 / 总分支数) x 100%以一个简单的 if-else 为例:
if (a > 0 && b > 0) { // 分支1: true && true; 分支2: true && false; 分支3: false
return a + b;
} else {
return 0;
}上述代码有 3 个分支路径,若只测试了 a=1, b=1 和 a=0, b=1 两个场景,分支覆盖率为 66.7%。
方法覆盖率
方法覆盖率(Method Coverage)衡量代码中被测试用例调用的方法占总方法数的比例。它从粗粒度角度反映测试的覆盖范围。
计算公式:
方法覆盖率 = (被调用的方法数 / 总方法数) x 100%- 构造函数、getter/setter、
toString()、equals()、hashCode()等通常被排除在统计之外 - 私有方法通过公有方法间接调用时,只要被实际执行就会被计入
类覆盖率
类覆盖率(Class Coverage)衡量被测试用例触及的类占总类数的比例。这是一个更加粗粒度的指标,通常用于快速评估测试范围的广度。
计算公式:
类覆盖率 = (被覆盖的类数 / 总类数) x 100%- 接口、抽象类、注解、枚举等根据配置可能被排除
- 只有至少一个方法被调用时,该类才被视为被覆盖
圈复杂度
圈复杂度(Cyclomatic Complexity, McCabe CC)衡量代码中线性独立路径的数量,反映代码的复杂程度和可测试性。
计算公式(基于控制流图):
圈复杂度 = E - N + 2P其中 E 为控制流图中边的数量,N 为节点数量,P 为连通分量数量(通常为 1)。
简化计算方式:
圈复杂度 = 分支节点数(if/while/for/case/catch)+ 1各指标的合理值参考:
| 指标 | 优秀 | 良好 | 需改进 | 危险 |
|---|---|---|---|---|
| 行覆盖率 | 90%+ | 80% ~ 89% | 60% ~ 79% | < 60% |
| 分支覆盖率 | 85%+ | 75% ~ 84% | 50% ~ 74% | < 50% |
| 方法覆盖率 | 90%+ | 80% ~ 89% | 60% ~ 79% | < 60% |
| 类覆盖率 | 95%+ | 85% ~ 94% | 70% ~ 84% | < 70% |
| 圈复杂度 | 1 ~ 5 | 6 ~ 10 | 11 ~ 20 | > 20 |
JaCoCo 覆盖率工具
JaCoCo(Java Code Coverage)是 Java 生态中使用最广泛的代码覆盖率工具,支持行覆盖率、分支覆盖率、方法覆盖率、类覆盖率和圈复杂度等指标,能够生成 HTML、XML、CSV 等多种格式的报告,并可以嵌入 Maven/Gradle 构建流程中。
Maven 插件配置
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.12</version>
<executions>
<!-- 准备 agent,在测试前启动 JaCoCo 运行时代理 -->
<execution>
<id>prepare-agent</id>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<!-- 生成覆盖率报告 -->
<execution>
<id>report</id>
<phase>verify</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
<!-- 覆盖率规则检查 -->
<execution>
<id>check</id>
<phase>verify</phase>
<goals>
<goal>check</goal>
</goals>
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>INSTRUCTION</counter>
<value>COVEREDRATIO</value>
<minimum>0.85</minimum>
</limit>
<limit>
<counter>BRANCH</counter>
<value>COVEREDRATIO</value>
<minimum>0.75</minimum>
</limit>
<limit>
<counter>COMPLEXITY</counter>
<value>TOTALCOUNT</value>
<maximum>500</maximum>
</limit>
</limits>
</rule>
<!-- 针对核心模块设置更高标准 -->
<rule>
<element>PACKAGE</element>
<includes>
<include>com.zhutianwuxian.core.*</include>
</includes>
<limits>
<limit>
<counter>INSTRUCTION</counter>
<value>COVEREDRATIO</value>
<minimum>0.90</minimum>
</limit>
<limit>
<counter>BRANCH</counter>
<value>COVEREDRATIO</value>
<minimum>0.85</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
<configuration>
<!-- 排除不需要统计覆盖率的类 -->
<excludes>
<exclude>**/*Application.class</exclude>
<exclude>**/*Config.class</exclude>
<exclude>**/*DTO.class</exclude>
<exclude>**/*VO.class</exclude>
<exclude>**/model/**/*.class</exclude>
</excludes>
</configuration>
</plugin>Gradle 插件配置
plugins {
id 'java'
id 'jacoco'
}
jacoco {
toolVersion = "0.8.12"
}
// 配置 JaCoCo 覆盖率规则
jacocoTestCoverageVerification {
violationRules {
rule {
limit {
counter = 'INSTRUCTION'
value = 'COVEREDRATIO'
minimum = 0.85
}
limit {
counter = 'BRANCH'
value = 'COVEREDRATIO'
minimum = 0.75
}
}
rule {
enabled = true
element = 'PACKAGE'
includes = ['com.zhutianwuxian.core.*']
limit {
counter = 'INSTRUCTION'
value = 'COVEREDRATIO'
minimum = 0.90
}
}
}
}
// 生成 HTML/XML 报告
jacocoTestReport {
dependsOn test
reports {
html.required.set(true) // HTML 报告,便于人工查看
xml.required.set(true) // XML 报告,供 SonarQube 等工具消费
csv.required.set(true) // CSV 报告,便于脚本处理
}
}
check.dependsOn jacocoTestCoverageVerification
test.finalizedBy jacocoTestReport报告生成
JaCoCo 支持三种报告格式,分别适用于不同的使用场景。
HTML 报告
HTML 报告是开发人员最直观的覆盖率查看方式,以彩色网页形式展示每个 Java 文件的覆盖情况:
- 绿色行表示已被执行
- 红色行表示未被执行
- 黄色行表示部分覆盖(分支覆盖不完整)
- 菱形图标表示分支覆盖率
报告生成路径:target/site/jacoco/index.html(Maven)或 build/reports/jacoco/html/index.html(Gradle)。
XML 报告
XML 报告以结构化数据描述覆盖率结果,主要用于 CI/CD 工具和 SonarQube 的自动解析。报告路径为 target/site/jacoco/jacoco.xml。
CSV 报告
CSV 报告以表格形式呈现汇总数据,适合脚本批量处理和导入电子表格工具。报告路径为 target/site/jacoco/jacoco.csv。
exec 数据文件
JaCoCo 在测试执行期间通过 Java Agent 运行时注入,将覆盖率数据写入 jacoco.exec 文件:
- 默认路径:
target/jacoco.exec(Maven)或build/jacoco/test.exec(Gradle) - 二进制格式,不能直接阅读,需要由
report任务解析后生成可读报告 - 可以通过
destFile配置项自定义输出路径
<configuration>
<destFile>${project.build.directory}/coverage-data/jacoco.exec</destFile>
</configuration>多个模块合并数据时,可以使用 jacoco:merge 目标:
<execution>
<id>merge</id>
<phase>verify</phase>
<goals>
<goal>merge</goal>
</goals>
<configuration>
<fileSets>
<fileSet>
<directory>${project.build.directory}/coverage-data</directory>
<includes>
<include>*.exec</include>
</includes>
</fileSet>
</fileSets>
<destFile>${project.build.directory}/merged.exec</destFile>
</configuration>
</execution>覆盖率规则配置详解
JaCoCo check 目标支持灵活的规则配置,核心配置参数如下。
element(作用范围)
| 取值 | 说明 |
|---|---|
BUNDLE | 整个项目/模块 |
PACKAGE | 包级别 |
CLASS | 类级别 |
METHOD | 方法级别 |
GROUP | 多模块聚合 |
counter(计数指标)
| 取值 | 说明 |
|---|---|
INSTRUCTION | 字节码指令数,等同于行覆盖率 |
BRANCH | 分支数 |
LINE | 代码行数 |
METHOD | 方法数 |
CLASS | 类数 |
COMPLEXITY | 圈复杂度 |
value(评估值类型)
| 取值 | 说明 |
|---|---|
TOTALCOUNT | 总数 |
MISSEDCOUNT | 未覆盖数 |
COVEREDCOUNT | 已覆盖数 |
MISSEDRATIO | 未覆盖率 |
COVEREDRATIO | 覆盖率 |
<!-- 组合示例:方法覆盖率必须达到 90%,且未覆盖方法数不超过 10 -->
<limit>
<counter>METHOD</counter>
<value>COVEREDRATIO</value>
<minimum>0.90</minimum>
</limit>
<limit>
<counter>METHOD</counter>
<value>MISSEDCOUNT</value>
<maximum>10</maximum>
</limit>SonarQube 集成
覆盖率报告上传
SonarQube 本身不执行测试或生成覆盖率数据,需要由 JaCoCo 先生成 XML 报告,然后通过 SonarScanner 上传。
Maven 配置
<!-- pom.xml 中配置 Sonar 属性 -->
<properties>
<sonar.host.url>https://sonar.zhutianwuxian.com</sonar.host.url>
<sonar.login>${env.SONAR_TOKEN}</sonar.login>
<sonar.coverage.jacoco.xmlReportPaths>
${project.build.directory}/site/jacoco/jacoco.xml
</sonar.coverage.jacoco.xmlReportPaths>
<!-- 排除不需要统计的源文件 -->
<sonar.exclusions>
**/*Application.java,
**/*Config.java,
**/*DTO.java,
**/*VO.java,
**/model/**
</sonar.exclusions>
</properties>执行命令:
mvn clean verify sonar:sonarGradle 配置
// build.gradle
plugins {
id 'org.sonarqube' version '5.1.0'
}
sonarqube {
properties {
property 'sonar.host.url', 'https://sonar.zhutianwuxian.com'
property 'sonar.login', System.getenv('SONAR_TOKEN')
property 'sonar.coverage.jacoco.xmlReportPaths',
"${buildDir}/reports/jacoco/test/jacocoTestReport.xml"
property 'sonar.exclusions',
'**/*Application.java,**/*Config.java,**/*DTO.java,**/*VO.java,**/model/**'
}
}执行命令:
gradle clean test sonarqubeCI 流水线集成
# .gitlab-ci.yml
stages:
- test
- sonarqube
unit-test:
stage: test
script:
- mvn clean verify
artifacts:
paths:
- target/site/jacoco/
- target/jacoco.exec
expire_in: 7 days
sonarqube-analysis:
stage: sonarqube
script:
- mvn sonar:sonar
only:
- main
- merge_requests质量门(Quality Gate)
质量门是 SonarQube 的核心机制,用于设定代码合入的质量红线。当代码不满足门禁条件时,流水线应中断。
覆盖率相关质量门配置
在 SonarQube 管理界面中配置覆盖率和质量门的关联条件:
| 条件类型 | 度量指标 | 操作符 | 阈值 | 说明 |
|---|---|---|---|---|
| 新增代码 | 覆盖率 | 小于 | 80.0% | 新增代码行覆盖率不低于 80% |
| 新增代码 | 分支覆盖率 | 小于 | 75.0% | 新增代码分支覆盖率不低于 75% |
| 整体代码 | 覆盖率 | 小于 | 85.0% | 全量代码行覆盖率不低于 85% |
| 整体代码 | 单元测试数量 | 小于 | 100 | 全量单测数不低于 100 |
| 整体代码 | 圈复杂度 | 大于 | 20 | 单个方法的圈复杂度不超过 20 |
通过 API 获取质量门状态
# 获取项目质量门状态
curl -u ${SONAR_TOKEN}: \
"${SONAR_HOST_URL}/api/qualitygates/project_status?projectKey=com.zhutianwuxian:backend"
# 响应示例
# {
# "projectStatus": {
# "status": "ERROR",
# "conditions": [
# {
# "status": "ERROR",
# "metricKey": "new_coverage",
# "actualValue": "72.5",
# "errorThreshold": "80.0"
# }
# ]
# }
# }在 CI 中阻断低覆盖率代码合入
# 在 CI 流水线中添加质量门检查
sonarqube-quality-check:
stage: quality-gate
script:
- mvn sonar:sonar -Dsonar.qualitygate.wait=true
# -Dsonar.qualitygate.wait=true 会让 sonar-scanner 等待分析完成并检查质量门
# 如果质量门未通过,脚本返回非零退出码,流水线中断增量覆盖率分析
增量覆盖率只统计本次变更代码的覆盖情况,避免历史存量代码的覆盖率短板影响新代码的合入。SonarQube 通过 PR/MR 分析模式支持增量覆盖率。
基于分支的增量分析
# GitLab CI MR 流水线
merge-request-analysis:
stage: test
script:
- |-
mvn clean verify sonar:sonar \
-Dsonar.pullrequest.branch=${CI_MERGE_REQUEST_SOURCE_BRANCH_NAME} \
-Dsonar.pullrequest.base=${CI_MERGE_REQUEST_TARGET_BRANCH_NAME} \
-Dsonar.pullrequest.key=${CI_MERGE_REQUEST_IID}
only:
- merge_requests基于 Git 的增量分析
# 对比当前分支与目标分支的差异
git diff --name-only origin/main...HEAD
# SonarQube 自动基于差异计算增量覆盖
mvn sonar:sonar \
-Dsonar.branch.name=feature/my-feature \
-Dsonar.branch.target=main增量覆盖率的优势
- 聚焦变更代码,避免被存量低覆盖拖累
- 反馈速度更快,仅分析变更文件
- 降低新功能开发时的覆盖率心理负担
- 配合代码审查,覆盖率不足的变更在 MR 阶段即被阻断
覆盖率策略
核心模块 vs 非核心模块
不同模块应根据业务重要性和稳定性要求设定差异化的覆盖率标准。
| 模块类型 | 示例 | 行覆盖率目标 | 分支覆盖率目标 | 策略说明 |
|---|---|---|---|---|
| 核心业务模块 | 订单、支付、库存 | 90%+ | 85%+ | 达到标准方可合入 |
| 通用业务模块 | 用户管理、权限 | 85%+ | 75%+ | 鼓励覆盖,弹性执行 |
| 基础设施模块 | 配置、工具类 | 80%+ | 70%+ | 关键路径覆盖即可 |
| 适配器模块 | 外部 API 封装 | 75%+ | 65%+ | 集成测试覆盖为主 |
| 入口模块 | Controller、路由 | 50%+ | 不强制 | 集成测试覆盖 REST 接口 |
<!-- JaCoCo 多规则示例:不同包不同标准 -->
<rules>
<!-- 核心模块 -->
<rule>
<element>PACKAGE</element>
<includes>
<include>com.zhutianwuxian.order.*</include>
<include>com.zhutianwuxian.payment.*</include>
</includes>
<limits>
<limit>
<counter>INSTRUCTION</counter>
<value>COVEREDRATIO</value>
<minimum>0.90</minimum>
</limit>
</limits>
</rule>
<!-- 基础设施模块 -->
<rule>
<element>PACKAGE</element>
<includes>
<include>com.zhutianwuxian.common.*</include>
<include>com.zhutianwuxian.config.*</include>
</includes>
<limits>
<limit>
<counter>INSTRUCTION</counter>
<value>COVEREDRATIO</value>
<minimum>0.80</minimum>
</limit>
</limits>
</rule>
</rules>增量覆盖 vs 全量覆盖
两种策略各有适用场景,实践中通常组合使用。
| 维度 | 增量覆盖 | 全量覆盖 |
|---|---|---|
| 统计范围 | 仅本次变更代码 | 整个项目 |
| 适用阶段 | MR/PR 代码审查时 | 主分支合入、发版前 |
| 优点 | 反馈快、聚焦变更、不累积技术债要求 | 整体质量可见、防止存量退化 |
| 缺点 | 低标准时可能引入新低质量代码 | 存量低覆盖会阻碍新功能合入 |
| 推荐阈值 | 新增代码行覆盖率 >= 80% | 全量行覆盖率 >= 85% |
推荐组合策略:
MR 阶段 (增量覆盖 >= 80%) --> 合入 main (增量覆盖 >= 80%)
--> 每日全量扫描 (全量覆盖 >= 85%)
--> 发版前全量扫描 (全量覆盖 >= 85%)覆盖率卡点 CI 配置
在 CI/CD 流水线中设置覆盖率卡点,确保低覆盖率的代码无法合入主干。
# .gitlab-ci.yml - 完整的覆盖率卡点流水线
stages:
- build
- unit-test
- coverage-check
- integration-test
- sonarqube
- package
build:
stage: build
script:
- mvn compile -DskipTests
unit-test:
stage: unit-test
script:
- mvn test
artifacts:
paths:
- target/jacoco.exec
# JaCoCo 覆盖率检查(硬卡点)
coverage-check:
stage: coverage-check
script:
- mvn jacoco:check
dependencies:
- unit-test
integration-test:
stage: integration-test
script:
- mvn verify -Pintegration-test
artifacts:
paths:
- target/site/jacoco/
# SonarQube 质量门检查
sonarqube:
stage: sonarqube
script:
- mvn sonar:sonar -Dsonar.qualitygate.wait=true
only:
- main
- merge_requests
# 方舟项目:发版前全量覆盖审查
package:
stage: package
script:
- mvn package -DskipTests
only:
- main
needs:
- sonarqubeTestcontainers 集成测试
Testcontainers 是一个 Java 库,支持在 JUnit 测试中启动 Docker 容器作为集成测试的依赖服务(数据库、消息队列、缓存等),测试结束后自动销毁容器。它消除了对固定测试环境的依赖,使集成测试可以在任何安装了 Docker 的环境中运行。
核心概念
@Testcontainers 注解
@Testcontainers 注解标记在测试类上,启用 Testcontainers 生命周期管理。它负责启动和关闭测试类中定义的容器。
@Container 注解
@Container 注解标记在静态或实例字段上,定义需要管理的容器实例。静态字段表示所有测试方法共享同一个容器;实例字段表示每个测试方法独立启动容器。
基础示例
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
@Testcontainers
class UserRepositoryTest {
// 静态容器:所有测试方法共享同一个 MySQL 实例
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
}MySQL 容器启动示例
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
@Testcontainers
class DatabaseTest {
// 指定 MySQL 版本,固定镜像标签以确保可复现
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>(
DockerImageName.parse("mysql:8.0.36")
)
.withDatabaseName("testdb")
.withUsername("test_user")
.withPassword("test_pass_123")
.withInitScript("db/init.sql") // 初始化 DDL/DML 脚本
.withCommand("--character-set-server=utf8mb4",
"--collation-server=utf8mb4_unicode_ci")
.withEnv("TZ", "Asia/Shanghai")
.withStartupTimeout(Duration.ofMinutes(3))
.withConnectTimeoutSeconds(30);
}Redis 容器启动示例
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
@Testcontainers
class RedisCacheTest {
@Container
static GenericContainer<?> redis = new GenericContainer<>(
DockerImageName.parse("redis:7.2-alpine")
)
.withExposedPorts(6379)
.withCommand("redis-server", "--requirepass", "test123",
"--maxmemory", "128mb")
.withStartupTimeout(Duration.ofMinutes(2));
// 获取容器映射端口
public static String getRedisHost() {
return redis.getHost();
}
public static Integer getRedisPort() {
return redis.getMappedPort(6379);
}
public static String getRedisPassword() {
return "test123";
}
}Elasticsearch 容器启动示例
import org.testcontainers.elasticsearch.ElasticsearchContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
@Testcontainers
class ElasticsearchTest {
@Container
static ElasticsearchContainer elasticsearch = new ElasticsearchContainer(
"docker.elastic.co/elasticsearch/elasticsearch:8.12.0"
)
.withPassword("elastic_test")
.withEnv("xpack.security.enabled", "true")
.withEnv("ES_JAVA_OPTS", "-Xms512m -Xmx512m")
.withStartupTimeout(Duration.ofMinutes(5));
}RabbitMQ 容器启动示例
import org.testcontainers.containers.RabbitMQContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
@Testcontainers
class RabbitMQTest {
@Container
static RabbitMQContainer rabbitmq = new RabbitMQContainer(
"rabbitmq:3.13-management-alpine"
)
.withUser("admin", "admin123")
.withVhost("test-vhost")
.withPermission("test-vhost", "admin", ".*", ".*", ".*")
.withStartupTimeout(Duration.ofMinutes(3));
// 获取 AMQP 连接地址
public static String getAmqpUrl() {
return String.format("amqp://admin:admin123@%s:%d/test-vhost",
rabbitmq.getHost(), rabbitmq.getAmqpPort());
}
}动态属性注册
在 Spring Boot 集成测试中,容器启动后需要将动态端口等信息注册到 Spring 环境属性中,以便应用程序正确连接。这是通过 DynamicPropertyRegistry 和 @DynamicPropertySource 实现的。
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
@Testcontainers
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class UserServiceIntegrationTest {
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0");
@Container
static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
.withExposedPorts(6379);
@Container
static RabbitMQContainer rabbitmq = new RabbitMQContainer(
"rabbitmq:3.13-management-alpine"
);
/**
* 将容器的连接信息动态注册到 Spring Environment 中,
* 覆盖 application.yml 中的静态配置。
*/
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
// MySQL
registry.add("spring.datasource.url", mysql::getJdbcUrl);
registry.add("spring.datasource.username", mysql::getUsername);
registry.add("spring.datasource.password", mysql::getPassword);
registry.add("spring.datasource.driver-class-name", mysql::getDriverClassName);
// Redis
registry.add("spring.data.redis.host", redis::getHost);
registry.add("spring.data.redis.port", () -> redis.getMappedPort(6379));
registry.add("spring.data.redis.password", () -> "");
// RabbitMQ
registry.add("spring.rabbitmq.host", rabbitmq::getHost);
registry.add("spring.rabbitmq.port", rabbitmq::getAmqpPort);
registry.add("spring.rabbitmq.username", () -> "admin");
registry.add("spring.rabbitmq.password", () -> "admin123");
}
}docker-compose 模式
对于需要多个容器的复杂测试场景,Testcontainers 支持通过 docker-compose 文件统一管理容器编排。
# src/test/resources/docker-compose-test.yml
version: '3.8'
services:
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: root123
MYSQL_DATABASE: testdb
MYSQL_USER: test
MYSQL_PASSWORD: test123
ports:
- "3306"
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
redis:
image: redis:7-alpine
ports:
- "6379"
command: redis-server --requirepass redis123
rabbitmq:
image: rabbitmq:3.13-management-alpine
environment:
RABBITMQ_DEFAULT_USER: admin
RABBITMQ_DEFAULT_PASS: admin123
ports:
- "5672"
- "15672"import org.testcontainers.containers.DockerComposeContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.io.File;
@Testcontainers
class OrderServiceDockerComposeTest {
@Container
static DockerComposeContainer<?> environment =
new DockerComposeContainer<>(
new File("src/test/resources/docker-compose-test.yml")
)
.withExposedService("mysql", 3306)
.withExposedService("redis", 6379)
.withExposedService("rabbitmq", 5672)
.withStartupTimeout(Duration.ofMinutes(5))
.withLocalCompose(true);
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
// 获取 docker-compose 中各服务的映射端口
String mysqlHost = environment.getServiceHost("mysql", 3306);
Integer mysqlPort = environment.getServicePort("mysql", 3306);
registry.add("spring.datasource.url",
() -> String.format("jdbc:mysql://%s:%d/testdb?useSSL=false&allowPublicKeyRetrieval=true",
mysqlHost, mysqlPort));
registry.add("spring.datasource.username", () -> "test");
registry.add("spring.datasource.password", () -> "test123");
String redisHost = environment.getServiceHost("redis", 6379);
Integer redisPort = environment.getServicePort("redis", 6379);
registry.add("spring.data.redis.host", () -> redisHost);
registry.add("spring.data.redis.port", () -> redisPort);
registry.add("spring.data.redis.password", () -> "redis123");
String rabbitHost = environment.getServiceHost("rabbitmq", 5672);
Integer rabbitPort = environment.getServicePort("rabbitmq", 5672);
registry.add("spring.rabbitmq.host", () -> rabbitHost);
registry.add("spring.rabbitmq.port", () -> rabbitPort);
}
}注意:
DockerComposeContainer在 Testcontainers 1.19+ 中已被标记为弃用,推荐迁移到compose模块的ComposeContainer。建议新项目直接使用ComposeContainer。
// 使用新版 ComposeContainer(Testcontainers 1.19+)
import org.testcontainers.containers.ComposeContainer;
import org.testcontainers.containers.wait.strategy.Wait;
@Testcontainers
class OrderServiceComposeTest {
@Container
static ComposeContainer environment = new ComposeContainer(
new File("src/test/resources/docker-compose-test.yml")
)
.withExposedService("mysql", 3306,
Wait.forLogMessage(".*ready for connections.*", 1))
.withExposedService("redis", 6379,
Wait.forLogMessage(".*Ready to accept connections.*", 1))
.withExposedService("rabbitmq", 5672,
Wait.forListeningPort())
.withStartupTimeout(Duration.ofMinutes(5));
}容器复用
Testcontainers 支持容器复用功能,在同一台机器上多次运行测试时,可以复用已存在的容器,避免重复启动带来的性能开销。
启用容器复用
需要在 ~/.testcontainers.properties 中启用该特性:
# ~/.testcontainers.properties
testcontainers.reuse.enable=true然后在容器定义时指定可复用标签:
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test")
.withReuse(true); // 启用复用复用的工作原理
- Testcontainers 根据容器配置(镜像、环境变量、命令等)生成哈希值作为唯一标识
- 复用模式下,容器不会在测试结束时销毁,而是保持在运行状态
- 后续运行相同测试时,若发现已存在匹配的容器,直接使用而不是创建新容器
- 容器会在 Docker 守护进程重启或手动
docker rm时清理
使用建议
- 仅在开发环境开启复用,CI 环境应关闭复用以确保隔离性
- 复用可显著缩短本地开发时的测试反馈周期(从 30~60 秒减少到 1~2 秒)
- 容器的状态会在测试间残留,不适合测试需要"干净数据库"的场景
自定义镜像
在标准镜像无法满足需求时,可以通过 Dockerfile 构建自定义镜像,或使用 ImageFromDockerfile 动态构建。
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.images.builder.ImageFromDockerfile;
@Testcontainers
class CustomImageTest {
@Container
static GenericContainer<?> customApp = new GenericContainer<>(
new ImageFromDockerfile("my-app-test:latest", false)
.withDockerfileFromBuilder(builder -> builder
.from("eclipse-temurin:21-jre-alpine")
.copy("app.jar", "/app/app.jar")
.workDir("/app")
.entryPoint("java", "-jar", "app.jar")
)
.withFileFromClasspath("app.jar", "test-containers/app.jar")
)
.withExposedPorts(8080)
.withStartupTimeout(Duration.ofMinutes(2));
}Spring Boot + Testcontainers 完整集成测试示例
项目依赖配置
<!-- Maven pom.xml -->
<properties>
<testcontainers.version>1.20.4</testcontainers.version>
</properties>
<dependencies>
<!-- Spring Boot Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- Testcontainers 核心库 -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
<!-- Testcontainers 模块化依赖 -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>mysql</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
<!-- Spring Data Redis / RabbitMQ Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-bom</artifactId>
<version>${testcontainers.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>数据库读写测试
完整的数据库读写集成测试,验证 JPA Repository 的 CRUD 操作。
package com.zhutianwuxian.order.repository;
import com.zhutianwuxian.order.model.Order;
import com.zhutianwuxian.order.model.OrderStatus;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.context.jdbc.Sql;
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
@Testcontainers
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
class OrderRepositoryTest {
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0.36")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test")
.withReuse(true);
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", mysql::getJdbcUrl);
registry.add("spring.datasource.username", mysql::getUsername);
registry.add("spring.datasource.password", mysql::getPassword);
registry.add("spring.datasource.driver-class-name", mysql::getDriverClassName);
// 禁用 Flyway/Liquibase 自动迁移(由测试自己管理 DDL)
registry.add("spring.flyway.enabled", () -> "false");
registry.add("spring.jpa.hibernate.ddl-auto", () -> "create-drop");
}
@Autowired
private OrderRepository orderRepository;
@BeforeEach
void setUp() {
orderRepository.deleteAll();
}
@Test
void shouldSaveAndFindOrderById() {
// 准备测试数据
Order order = new Order();
order.setOrderNo("ORD-2024-00001");
order.setUserId(1001L);
order.setTotalAmount(new BigDecimal("299.00"));
order.setStatus(OrderStatus.PENDING);
order.setCreatedAt(LocalDateTime.now());
// 执行保存
Order savedOrder = orderRepository.save(order);
// 验证保存结果
assertThat(savedOrder.getId()).isNotNull();
assertThat(savedOrder.getOrderNo()).isEqualTo("ORD-2024-00001");
// 执行查询
Optional<Order> foundOrder = orderRepository.findById(savedOrder.getId());
// 验证查询结果
assertThat(foundOrder).isPresent();
assertThat(foundOrder.get().getUserId()).isEqualTo(1001L);
assertThat(foundOrder.get().getTotalAmount())
.isEqualByComparingTo(new BigDecimal("299.00"));
}
@Test
void shouldFindOrdersByUserId() {
// 准备多条测试数据
Order order1 = new Order();
order1.setOrderNo("ORD-2024-00002");
order1.setUserId(1001L);
order1.setTotalAmount(new BigDecimal("199.00"));
order1.setStatus(OrderStatus.PAID);
order1.setCreatedAt(LocalDateTime.now());
Order order2 = new Order();
order2.setOrderNo("ORD-2024-00003");
order2.setUserId(1001L);
order2.setTotalAmount(new BigDecimal("399.00"));
order2.setStatus(OrderStatus.SHIPPED);
order2.setCreatedAt(LocalDateTime.now());
orderRepository.save(order1);
orderRepository.save(order2);
// 执行查询
List<Order> orders = orderRepository.findByUserId(1001L);
// 验证
assertThat(orders).hasSize(2);
assertThat(orders).extracting(Order::getOrderNo)
.containsExactlyInAnyOrder("ORD-2024-00002", "ORD-2024-00003");
}
@Test
void shouldUpdateOrderStatus() {
// 准备数据
Order order = new Order();
order.setOrderNo("ORD-2024-00004");
order.setUserId(1002L);
order.setTotalAmount(new BigDecimal("599.00"));
order.setStatus(OrderStatus.PENDING);
order.setCreatedAt(LocalDateTime.now());
Order savedOrder = orderRepository.save(order);
// 更新状态
savedOrder.setStatus(OrderStatus.PAID);
orderRepository.save(savedOrder);
// 验证更新
Order updatedOrder = orderRepository.findById(savedOrder.getId()).get();
assertThat(updatedOrder.getStatus()).isEqualTo(OrderStatus.PAID);
}
@Test
void shouldDeleteOrder() {
// 准备数据
Order order = new Order();
order.setOrderNo("ORD-2024-00005");
order.setUserId(1003L);
order.setTotalAmount(new BigDecimal("99.00"));
order.setStatus(OrderStatus.CANCELLED);
order.setCreatedAt(LocalDateTime.now());
Order savedOrder = orderRepository.save(order);
// 执行删除
orderRepository.deleteById(savedOrder.getId());
// 验证删除
Optional<Order> deletedOrder = orderRepository.findById(savedOrder.getId());
assertThat(deletedOrder).isEmpty();
}
}Redis 缓存测试
验证 Redis 缓存的读写和过期行为。
package com.zhutianwuxian.product.service;
import com.zhutianwuxian.product.model.Product;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.math.BigDecimal;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
@Testcontainers
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
class ProductCacheServiceTest {
@Container
static GenericContainer<?> redis = new GenericContainer<>("redis:7.2-alpine")
.withExposedPorts(6379)
.withCommand("redis-server", "--requirepass", "test123");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.data.redis.host", redis::getHost);
registry.add("spring.data.redis.port", () -> redis.getMappedPort(6379));
registry.add("spring.data.redis.password", () -> "test123");
registry.add("spring.data.redis.timeout", () -> "2000ms");
}
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private ProductCacheService productCacheService;
private static final String CACHE_KEY_PREFIX = "product:";
@BeforeEach
void setUp() {
// 清理缓存
redisTemplate.getConnectionFactory().getConnection().flushAll();
}
@Test
void shouldCacheProduct() {
// 准备数据
Product product = new Product();
product.setId(1L);
product.setName("测试商品");
product.setPrice(new BigDecimal("99.00"));
product.setStock(100);
// 执行缓存
productCacheService.cacheProduct(product);
// 验证缓存
Product cached = (Product) redisTemplate.opsForValue()
.get(CACHE_KEY_PREFIX + product.getId());
assertThat(cached).isNotNull();
assertThat(cached.getName()).isEqualTo("测试商品");
assertThat(cached.getPrice()).isEqualByComparingTo(new BigDecimal("99.00"));
}
@Test
void shouldReturnNullWhenCacheExpired() throws InterruptedException {
// 准备数据
Product product = new Product();
product.setId(2L);
product.setName("临时商品");
product.setPrice(new BigDecimal("19.00"));
product.setStock(10);
// 执行缓存,设置 1 秒过期
redisTemplate.opsForValue().set(
CACHE_KEY_PREFIX + product.getId(),
product,
1,
TimeUnit.SECONDS
);
// 验证缓存存在
assertThat(redisTemplate.opsForValue().get(CACHE_KEY_PREFIX + 2L)).isNotNull();
// 等待过期
TimeUnit.SECONDS.sleep(2);
// 验证缓存已过期
assertThat(redisTemplate.opsForValue().get(CACHE_KEY_PREFIX + 2L)).isNull();
}
@Test
void shouldEvictCache() {
// 准备数据和缓存
Product product = new Product();
product.setId(3L);
product.setName("待删除缓存商品");
product.setPrice(new BigDecimal("199.00"));
product.setStock(50);
redisTemplate.opsForValue().set(
CACHE_KEY_PREFIX + product.getId(), product, 10, TimeUnit.MINUTES
);
// 执行缓存清除
productCacheService.evictProductCache(product.getId());
// 验证缓存已清除
assertThat(redisTemplate.opsForValue()
.get(CACHE_KEY_PREFIX + product.getId())).isNull();
}
}消息队列测试
验证 RabbitMQ 消息的发送和消费。
package com.zhutianwuxian.order.mq;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.amqp.core.AmqpAdmin;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.core.Queue;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.RabbitMQContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
@Testcontainers
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
class OrderMessageQueueTest {
@Container
static RabbitMQContainer rabbitmq = new RabbitMQContainer(
"rabbitmq:3.13-management-alpine"
)
.withUser("admin", "admin123");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.rabbitmq.host", rabbitmq::getHost);
registry.add("spring.rabbitmq.port", rabbitmq::getAmqpPort);
registry.add("spring.rabbitmq.username", () -> "admin");
registry.add("spring.rabbitmq.password", () -> "admin123");
}
@Autowired
private RabbitTemplate rabbitTemplate;
@Autowired
private AmqpAdmin amqpAdmin;
@Autowired
private OrderMessageConsumer orderMessageConsumer;
private static final String QUEUE_NAME = "test.order.created";
@BeforeEach
void setUp() {
// 创建测试队列
amqpAdmin.declareQueue(new Queue(QUEUE_NAME, false));
}
@Test
void shouldSendAndReceiveMessage() {
// 准备消息
String messagePayload = "{\"orderId\":1001,\"userId\":2001,\"amount\":299.00}";
// 发送消息
rabbitTemplate.convertAndSend(QUEUE_NAME, messagePayload);
// 等待消费
Message received = rabbitTemplate.receive(QUEUE_NAME, 5000);
// 验证消费
assertThat(received).isNotNull();
String receivedBody = new String(received.getBody(), StandardCharsets.UTF_8);
assertThat(receivedBody).isEqualTo(messagePayload);
}
@Test
void shouldConsumeMessageInListener() throws InterruptedException {
// 准备消息
String messagePayload = "{\"orderId\":1002,\"userId\":2002,\"amount\":599.00}";
// 发送消息
rabbitTemplate.convertAndSend(QUEUE_NAME, messagePayload);
// 等待异步消费
TimeUnit.SECONDS.sleep(2);
// 验证消息已被消费
assertThat(orderMessageConsumer.getLastReceivedMessage()).isNotNull();
assertThat(orderMessageConsumer.getLastReceivedMessage())
.contains("1002");
}
}测试数据管理
@Sql / @SqlGroup 脚本执行
Spring 提供 @Sql 和 @SqlGroup 注解,用于在测试方法执行前后加载和执行 SQL 脚本。
基本用法
import org.springframework.test.context.jdbc.Sql;
import org.springframework.test.context.jdbc.Sql.ExecutionPhase;
import org.springframework.test.context.jdbc.SqlGroup;
@Testcontainers
@SpringBootTest
class ProductRepositoryTest {
@Autowired
private ProductRepository productRepository;
/**
* 在每个测试方法执行前执行初始化脚本
*/
@Test
@Sql("/sql/products-init.sql")
void shouldFindAllProducts() {
List<Product> products = productRepository.findAll();
assertThat(products).hasSize(3);
}
/**
* 测试方法执行前准备数据,执行后清理数据
*/
@Test
@SqlGroup({
@Sql(scripts = "/sql/orders-setup.sql", executionPhase = ExecutionPhase.BEFORE_TEST_METHOD),
@Sql(scripts = "/sql/orders-cleanup.sql", executionPhase = ExecutionPhase.AFTER_TEST_METHOD)
})
void shouldCalculateOrderTotal() {
BigDecimal total = orderRepository.calculateTotalByUserId(1001L);
assertThat(total).isEqualByComparingTo(new BigDecimal("598.00"));
}
}测试数据脚本示例
-- src/test/resources/sql/products-init.sql
INSERT INTO product (id, name, price, stock, category_id, created_at)
VALUES (1, '商品A', 29.90, 100, 1, NOW()),
(2, '商品B', 49.90, 200, 1, NOW()),
(3, '商品C', 99.00, 50, 2, NOW());-- src/test/resources/sql/orders-setup.sql
INSERT INTO orders (id, order_no, user_id, total_amount, status, created_at)
VALUES (1, 'ORD-TEST-001', 1001, 199.00, 'PAID', NOW()),
(2, 'ORD-TEST-002', 1001, 399.00, 'PAID', NOW()),
(3, 'ORD-TEST-003', 1002, 599.00, 'PENDING', NOW());-- src/test/resources/sql/orders-cleanup.sql
DELETE FROM orders WHERE order_no LIKE 'ORD-TEST-%';@SqlConfig 配置
通过 @SqlConfig 可以精细化控制 SQL 脚本的执行行为:
@Test
@Sql(scripts = "/sql/products-init.sql",
config = @SqlConfig(
encoding = "utf-8",
separator = ";",
commentPrefix = "--",
blockCommentStartDelimiter = "/*",
blockCommentEndDelimiter = "*/",
transactionMode = SqlConfig.TransactionMode.ISOLATED
))
void shouldLoadDataWithCustomConfig() {
// ...
}数据库测试隔离策略
集成测试中,数据库隔离是保证测试独立性和可重复性的关键。以下是三种常用的隔离策略。
策略一:事务回滚
在每个测试方法执行后自动回滚事务,保证数据库状态不被污染。这是最简单、最快的策略。
import org.springframework.test.annotation.Rollback;
import org.springframework.transaction.annotation.Transactional;
/**
* @Transactional 确保每个测试方法在事务中执行,
* 测试结束后自动回滚,恢复数据库到测试前状态。
*/
@Transactional
@Rollback // 默认 true,测试完成后回滚
class OrderRepositoryTransactionalTest {
@Autowired
private OrderRepository orderRepository;
@Test
void shouldSaveOrder() {
Order order = new Order();
order.setOrderNo("ORD-ROLLBACK-001");
// ... 设置其他字段
orderRepository.save(order);
assertThat(orderRepository.findByOrderNo("ORD-ROLLBACK-001")).isPresent();
// 方法结束后,事务自动回滚,"ORD-ROLLBACK-001" 不会持久化到数据库
}
@Test
@Rollback(false) // 某些场景需要提交,例如验证触发器
void shouldCommitWhenConfigured() {
// 该测试方法的修改会留在数据库中
}
}适用场景:同一次测试运行中各个测试方法之间不需要共享数据,且测试方法数量不多时。
局限性:
- 与 Testcontainers 的
@Container静态容器配合良好,因为容器不会被销毁 - 无法测试数据库的持久化行为(如触发器的最终状态验证)
- 多个测试类并行执行时,事务回滚不能隔离不同类的数据
策略二:DDL 重建
在每次测试前重建表结构,确保数据库处于已知的干净状态。
@Testcontainers
@SpringBootTest
class DatabaseRecreateTest {
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", mysql::getJdbcUrl);
registry.add("spring.datasource.username", mysql::getUsername);
registry.add("spring.datasource.password", mysql::getPassword);
registry.add("spring.jpa.hibernate.ddl-auto", () -> "create");
// create: 每次启动时删除所有表并重建
// create-drop: 启动时创建,关闭时删除
}
@BeforeEach
void recreateDatabase(@Autowired EntityManager entityManager) {
// 手动清理所有实体数据
entityManager.getTransaction().begin();
entityManager.createNativeQuery("DELETE FROM order_items").executeUpdate();
entityManager.createNativeQuery("DELETE FROM orders").executeUpdate();
entityManager.getTransaction().commit();
}
}如果需要更彻底的重建,可以在测试基类中封装重建逻辑:
/**
* 测试基类:在每次测试前执行 DDL 重建和数据清理
*/
public abstract class DatabaseTestBase {
@Autowired
private DataSource dataSource;
@BeforeEach
void resetDatabase() throws SQLException {
try (Connection conn = dataSource.getConnection();
Statement stmt = conn.createStatement()) {
// 获取所有表名
ResultSet rs = stmt.executeQuery(
"SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES " +
"WHERE TABLE_SCHEMA = 'testdb' AND TABLE_TYPE = 'BASE TABLE'"
);
// 禁用外键检查,删除所有表
stmt.execute("SET FOREIGN_KEY_CHECKS = 0");
while (rs.next()) {
stmt.execute("TRUNCATE TABLE " + rs.getString(1));
}
stmt.execute("SET FOREIGN_KEY_CHECKS = 1");
}
}
}适用场景:需要确保每次测试开始时数据库都是完全干净的状态。
局限性:
- DDL 操作较慢,大量测试时性能较差
- 不适合测试数据库迁移脚本
策略三:Testcontainers 独立库
为每个测试类或每个测试方法创建独立的数据库实例。这是最彻底的隔离方式。
/**
* 为每个测试类使用独立数据库
*/
@Testcontainers
class OrderServiceIsolatedTest {
// 每个测试类使用不同的数据库名
private static final String DB_NAME = "test_" + UUID.randomUUID().toString().replace("-", "_");
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
.withDatabaseName(DB_NAME)
.withUsername("test")
.withPassword("test");
// ...
}如果需要更细粒度的隔离(每个测试方法一个独立库),可以使用 @BeforeEach 动态创建数据库:
@Testcontainers
class PerMethodIsolationTest {
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
.withUsername("test")
.withPassword("test");
private String currentDbName;
@BeforeEach
void createIsolatedDatabase(@Autowired DataSource dataSource) throws SQLException {
// 为每个测试方法创建独立数据库
currentDbName = "test_" + UUID.randomUUID().toString().replace("-", "_");
try (Connection conn = dataSource.getConnection();
Statement stmt = conn.createStatement()) {
stmt.execute("CREATE DATABASE IF NOT EXISTS " + currentDbName);
stmt.execute("USE " + currentDbName);
// 执行 DDL 初始化
stmt.execute(RunScript.CREATE_TABLES_SQL);
}
}
@AfterEach
void dropIsolatedDatabase(@Autowired DataSource dataSource) throws SQLException {
try (Connection conn = dataSource.getConnection();
Statement stmt = conn.createStatement()) {
stmt.execute("DROP DATABASE IF EXISTS " + currentDbName);
}
}
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
// 注意:动态数据库名需要特殊处理
registry.add("spring.datasource.url", () ->
mysql.getJdbcUrl() + "/" + currentDbName + "?useSSL=false");
}
@Test
void testOrderCreation() {
// 使用独立数据库执行测试
}
@Test
void testOrderQuery() {
// 使用另一个独立数据库执行测试,与上一个测试完全隔离
}
}三种策略对比
| 策略 | 隔离级别 | 速度 | 实现复杂度 | 适用场景 |
|---|---|---|---|---|
| 事务回滚 | 方法级 | 最快 | 低 | 大多数 CRUD 测试 |
| DDL 重建 | 类级 | 中等 | 中 | 需要干净数据库的测试 |
| Testcontainers 独立库 | 方法级 | 较慢 | 高 | 严格要求隔离的并发测试 |
推荐实践
测试结构分层:
单元测试 (JUnit + Mockito)
- 不需要数据库
- 速度要求 < 100ms
Repository 层测试 (@DataJpaTest + Testcontainers + @Transactional)
- 隔离策略:事务回滚
- 速度要求 < 5s
Service 层集成测试 (@SpringBootTest + Testcontainers)
- 隔离策略:DDL 重建或 @Sql cleanup
- 速度要求 < 10s
端到端测试 (@SpringBootTest + Testcontainers 完整环境)
- 隔离策略:Testcontainers 独立库或独立 schema
- 速度要求 < 30s最佳实践总结
覆盖率策略总结
核心模块 (90%+) +-- JaCoCo check 硬卡点 --> 构建失败
通用模块 (80%+) +-- SonarQube 质量门 --> MR 阻断
基础设施 (70%+) +-- 增量覆盖 >= 80% --> 每日全量扫描
适配器层 (--+) +-- 集成测试补偿 --> 发版前审计Testcontainers 最佳实践
- 使用 BOM 管理版本:通过
testcontainers-bom统一管理所有模块化依赖的版本 - 静态容器 + 类级复用:
static @Container确保所有测试方法共享一个容器实例,减少启动开销 - 指定精确镜像版本:使用
mysql:8.0.36而非mysql:latest,确保可复现性 - 设置合理的超时时间:CI 环境拉取镜像较慢,
withStartupTimeout(Duration.ofMinutes(5)) - 动态属性覆盖静态配置:通过
@DynamicPropertySource将容器连接信息注入 Spring 环境 - 隔离测试数据:根据测试层级选择事务回滚、DDL 重建或独立数据库策略
- CI 环境关闭容器复用:复用只在本地开发时启用,CI 中确保每次都是全新的容器实例
- 使用
@Testcontainers注解:替代手动调用container.start()和container.stop(),由框架自动管理生命周期