Appearance
Mapper 测试为什么只需要最小 Spring 上下文
修改 Mapper、XML 或 SQL 以后,我经常只是想确认一件事:
这次修改到底能不能正常执行。
例如刚刚改了:
text
Mapper 方法
Mapper XML
SQL
参数绑定
ResultMap
字段映射这时候如果为了验证一次修改,先启动完整 Spring Boot 应用,再登录、进入页面、构造业务条件、触发接口,反馈链路就太长了。
我更希望得到的是:
text
修改 Mapper / XML / SQL
↓
直接运行对应 MapperIT
↓
几秒内确认刚刚的调整所以这类测试的核心目标不是尽可能还原完整应用,而是让开发者在修改 Mapper、XML 或 SQL 后,可以很便宜地验证刚刚的调整。
最小上下文不是越少越好
这类 Mapper 测试真正需要的东西通常只有:
text
Spring Test
+
DataSource
+
MyBatis / MyBatis-Plus
+
目标 Mapper
+
测试数据库与这次 Mapper 验证无关的组件,则没有必要一起启动。
例如:
text
Controller
Service
Security
Scheduler
MQ
第三方 Client
Web Filter
业务 Interceptor这些组件即使在正式应用里存在,也不代表测试 Mapper 时必须加载。
不过“最小上下文”不是简单地理解成:
能删多少就删多少。
更准确的是只保留会影响这次 Mapper 验证结果的依赖。
例如某个 MyBatis 插件本身会改变 SQL 行为,而这次测试正好需要验证这种行为,那么它就应该进入测试上下文。
但如果只是登录校验、Web 请求上下文、审计日志或其他业务链路拦截器,就没有必要为了测试 Mapper 一起启动。
核心价值是快速反馈
这套做法最直接的收益当然是启动更快。
在一个 Spring Boot 2.2.10 示例工程中,Mapper 测试的 Spring 上下文启动大约需要:
text
约 2 秒单独执行:
text
BookMapperIT#selectList测试本身大约只需要:
text
约 200 ms但我更看重的不是这个数字本身。
真正重要的是反馈周期足够短以后,开发过程中可以随时运行。
修改 Mapper XML 后,如果不确定 SQL 有没有写错,可以直接在 IDE 中运行对应测试方法。
在实际项目里,这个差异会更加明显。
公司开发机配置一般,启动完整 Spring Boot 应用大约需要约 50 秒或更久。而运行这套最小 Mapper 测试,从启动 Spring 上下文到得到结果大约只需要约 3 秒。
以前遇到过不少很小的 Mapper XML 修改,例如只是增加或删除一个查询字段,结果 SELECT 后面的字段列表多了一个逗号,或者少了一个逗号。
这种错误本身很简单,但如果本地没有一个低成本的验证入口,就可能一直到重新部署以后才发现:
text
修改 Mapper XML
→ 打包
→ 部署
→ 启动应用
→ 进入业务流程
→ SQL 执行失败
→ 再修改
→ 再部署一个本来几秒钟就能发现的问题,最后变成了一次完整的重新部署。
所以这里真正想缩短的,不只是 Spring 的启动时间,而是:
从“刚刚改完代码”到“知道这次修改有没有问题”之间的时间。
开发过程就可以变成:
text
改一点
→ 跑一次
→ 确认
→ 再继续而不是:
text
先改一批
→ 最后再启动整个系统
→ 一次性排查多个问题Mapper、XML 和 SQL 都很适合这种短反馈循环。
最常见的使用场景
这类 Mapper IT 最适合处理的,不是复杂业务回归,而是刚改完 Mapper、XML 或 SQL 后的快速确认。
例如:
text
Mapper XML 少了一个逗号
动态 SQL 的 <if> 只判断了 null,没有处理空字符串
ResultMap 漏了一个字段
SELECT xxx AS yyy 中的别名 yyy 写错
字段名改了,但 SQL 里还保留旧名称
参数名和 XML 中的引用不一致
SQL 能执行,但结果映射不完整这些问题有两类。
一类会让 SQL 直接失败:
text
语法错误
参数绑定错误
字段名不存在另一类则更隐蔽:
text
SQL 可以正常执行
但返回结果不正确例如:
sql
SELECT
user_name AS username,
tenant_id AS tenant
FROM t_user如果:
text
tenant这个别名和目标对象属性不一致,SQL 本身可能完全没有报错,但最终映射结果已经不对了。
这类问题如果通过完整业务流程验证,成本很高。
可能需要:
text
启动完整应用
→ 登录
→ 进入页面
→ 构造条件
→ 调接口
→ 再观察返回值而 Mapper IT 可以直接把反馈链路缩短成:
text
修改 Mapper / XML / SQL
↓
运行对应 MapperIT
↓
马上知道刚才的调整是否有问题它不是为了替代完整业务测试,而是专门处理开发过程中频繁出现的:
text
小修改
小错误
立即验证为什么不用 @SpringBootTest
最常见的 Mapper 集成测试写法之一是:
java
@SpringBootTest
class BookMapperIT {
}它当然可以工作。
但 @SpringBootTest 更接近:
启动一个完整的 Spring Boot ApplicationContext。
实际项目里,这通常意味着除了 Mapper,还会加载大量无关组件:
text
Controller
Service
Security
Redis
Scheduler
MQ
HTTP Client
第三方渠道 Bean
OSS
其他业务配置于是一个 Mapper 测试可能因为完全无关的组件失败。
例如:
text
第三方配置缺失
Redis 无法连接
某个 Scheduler 初始化失败
某个 Bean 创建失败
某个 Web Interceptor 依赖缺失这些失败都不能说明 Mapper 本身有问题。
而 Mapper 测试真正需要验证的链路其实很窄:
text
DataSource
↓
MyBatis-Plus
↓
Mapper
↓
SQL
↓
Database所以没有必要为了验证这一条链路,把整个应用一起启动。
这是 IT,但主要用于开发时手工快速验证
虽然这种测试很轻,但我仍然把它命名为:
text
BookMapperIT而不是:
text
BookMapperUT因为它依赖:
text
Spring Context
DataSource
MyBatis-Plus
真实 SQL
数据库所以从测试性质上看,它仍然属于 Integration Test。
这和我在另一篇文章中使用的:
text
*UT
*IT
*SmokeIT分类是一致的。
关于这套测试执行边界,可以参考别把所有测试都叫 Test:我如何用 UT、IT 和 SmokeIT 划分执行边界
不过,这里的 Mapper IT 有一个很重要的使用场景区别。
它并不是为了让开发者每次修改代码以后,都通过命令行批量执行所有 IT。
它更主要用于开发过程中的手工快速验证。
例如刚刚修改了:
text
BookMapper.java
BookMapper.xml
一段 SQL
一个 ResultMap此时可以直接在 IDE 中运行:
text
BookMapperIT#selectList快速确认刚才的调整有没有问题。
也就是说,它的典型使用方式是:
text
修改 Mapper / XML / SQL
↓
在 IDE 中运行对应 MapperIT
↓
几秒内确认结果而不是:
text
每改一次代码
↓
命令行执行全部 IT*IT 在这里表达的是测试性质和执行边界,不代表它必须以批量命令行方式运行。
关于本文示例
本文示例的目标是演示:如何为 Mapper 测试构造一个足够小的 Spring 上下文。
所以代码刻意做了简化。
例如:
java
mapper.selectList(null);这里只是为了让示例足够短。
读者可以把它理解成:
text
一个实际项目里的复杂 Mapper 方法
+
对应 Mapper XML 中的 <select> 节点
+
动态 SQL
+
ResultMap文章关注的不是 selectList(null) 本身,而是:
修改 Mapper、XML 或 SQL 后,是否可以不启动完整应用,直接运行对应 Mapper IT 快速验证。
示例中使用 H2,只是为了让工程可以独立运行、不依赖外部数据库。
真实项目里数据库怎么准备,可以根据测试目标选择,后文会单独说明。
示例项目结构
下面使用一个 Spring Boot 2.2.10 的示例工程。
项目结构被简化成两个模块:
text
springboot2-sample-common-mapper
├─ common-mapper
│ ├─ src/main/java
│ │ ├─ mapper
│ │ └─ po
│ │
│ └─ src/test
│ ├─ java
│ │ ├─ MapperTestBase
│ │ └─ BookMapperIT
│ │
│ └─ resources
│ ├─ application-dao-test.yml
│ └─ db
│ ├─ schema.sql
│ └─ data.sql
│
└─ web
└─ Spring Boot Application当前项目把 Mapper、PO 和 MyBatis-Plus 相关代码集中在独立的:
text
common-mapper模块中。
web 模块依赖 common-mapper。
这里的模块名只是当前项目的组织方式,不是这套测试方案的必要条件。
真正重要的是:
common-mapper自己就能够完成 Mapper 测试,不需要通过web模块启动完整 Spring Boot 应用。
Spring Boot 2.2.10 示例
这套示例实际使用:
text
Spring Boot 2.2.10.RELEASE
MyBatis-Plus 3.5.15
Java 8
H2父工程中明确版本:
xml
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.2.10.RELEASE</version>
</parent>
<properties>
<java.version>1.8</java.version>
<mybatis-plus.version>3.5.15</mybatis-plus.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-bom</artifactId>
<version>${mybatis-plus.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>common-mapper 模块依赖 MyBatis-Plus:
xml
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>示例中还使用了 P6Spy 观察 SQL:
xml
<dependency>
<groupId>p6spy</groupId>
<artifactId>p6spy</artifactId>
<version>3.9.1</version>
</dependency>P6Spy 不是这套最小 Mapper 测试结构的必要条件。
MapperTestBase 只加载必要配置
核心代码很短:
java
@ExtendWith(SpringExtension.class)
@ContextConfiguration(
initializers = ConfigFileApplicationContextInitializer.class
)
@ActiveProfiles("dao-test")
@Configuration(proxyBeanMethods = false)
@Import({
DataSourceAutoConfiguration.class,
MybatisPlusAutoConfiguration.class
})
public abstract class MapperTestBase {
}这里没有:
java
@SpringBootTest也没有:
java
@SpringBootApplication只明确导入:
java
DataSourceAutoConfiguration.class
MybatisPlusAutoConfiguration.class也就是说,这个测试上下文关心的是:
text
怎么创建 DataSource
怎么初始化 MyBatis-Plus而不是:
text
整个应用怎么启动SpringExtension
java
@ExtendWith(SpringExtension.class)让 JUnit 5 测试使用 Spring TestContext。
这样具体测试类仍然可以使用:
java
@Autowired注入 Mapper。
ConfigFileApplicationContextInitializer
java
@ContextConfiguration(
initializers = ConfigFileApplicationContextInitializer.class
)用于加载 Spring Boot 配置文件。
配合:
java
@ActiveProfiles("dao-test")测试会读取对应测试配置。
只导入两个自动配置
真正决定上下文范围的是:
java
@Import({
DataSourceAutoConfiguration.class,
MybatisPlusAutoConfiguration.class
})这里没有开启整个 Spring Boot 自动配置体系,而是明确选择 Mapper 测试需要的部分。
这使测试目标保持得很窄:
text
DataSource
+
MyBatis-Plus
+
MapperBase 不负责扫描业务 Mapper
MapperTestBase 没有:
java
@MapperScan这是刻意的。
Base 只定义:
一个 Mapper IT 应该如何获得最小测试环境。
至于具体测试需要扫描哪个 Mapper 包,由测试类自己决定。
例如:
java
@Slf4j
@MapperScan(basePackages = {
"cn.xiaoliulab.sample.common.mapper"
})
public class BookMapperIT extends MapperTestBase {
@Autowired
private BookMapper mapper;
@Test
public void selectList() {
List<BookPO> list = mapper.selectList(null);
Assertions.assertFalse(list.isEmpty());
log.info("list={}", list);
}
}这样:
text
MapperTestBase负责:
text
DataSource
MyBatis-Plus
测试配置而:
text
BookMapperIT负责:
text
扫描哪些 Mapper
测试哪个 Mapper
验证什么结果Base 不需要知道具体业务 Mapper 在哪里。
测试数据库使用 H2
测试配置使用独立 profile:
yaml
spring:
datasource:
driver-class-name: com.p6spy.engine.spy.P6SpyDriver
url: jdbc:p6spy:h2:mem:dal-test;MODE=MySQL;DB_CLOSE_DELAY=-1
username: sa
password:
initialization-mode: always
schema:
- classpath:db/schema.sql
data:
- classpath:db/data.sql测试启动时:
text
创建 H2 内存数据库
↓
执行 schema.sql
↓
执行 data.sql
↓
运行 Mapper IT例如测试表:
sql
create table if not exists t_book (
id bigint not null primary key auto_increment,
type_id bigint,
book_isbn varchar(32),
book_name varchar(32),
status enum('PENDING', 'PUBLISHED')
);再准备少量固定测试数据:
sql
INSERT INTO t_book (
id,
type_id,
book_isbn,
book_name,
status
)
VALUES
(1, 100, 'aaa', 'java入门', 'PENDING'),
(2, 101, 'bbb', 'java中级', 'PENDING'),
(3, 102, 'ccc', 'java高级', 'PENDING');这里使用 H2 只是为了让示例无需准备外部 MySQL 就可以运行。
真实项目并不需要照搬这种数据库准备方式。
为什么这里可以测试写操作
如果 Mapper IT 直接连接共享开发数据库,我通常会更加谨慎,甚至限制测试只读。
但这个示例使用的是:
text
H2 内存数据库测试数据本身就是临时创建的。
因此:
java
mapper.insert(...)
mapper.update(...)
mapper.delete(...)都可以安全验证。
这里真正的边界不是 Mapper IT 永远不能写数据库。
而是测试是否能够控制自己写入的数据环境。
不过,H2 是临时数据库,并不代表同一个测试上下文中的多个 @Test 方法天然互不影响。
Spring Test 会缓存 ApplicationContext,schema.sql 和 data.sql 通常只在数据库初始化时执行一次。
如果一个测试方法修改了数据:
java
@Test
void updateBook() {
mapper.update(...);
}后面的另一个测试方法就可能看到已经被修改过的数据。
这容易出现一种典型问题:
text
单独运行某个测试
→ 通过
运行整个测试类
→ 失败原因不是 Mapper 本身,而是测试方法之间共享了数据状态。
对于存在写操作的 Mapper IT,可以使用:
java
@Transactional让每个测试方法执行结束后自动回滚。
例如:
java
@Transactional
@Test
void updateBook() {
// 修改测试数据
// 测试结束后自动回滚
}如果某些场景不适合事务回滚,也应该显式清理测试数据。
目标是:
每个测试方法都能够独立运行,不依赖其他测试方法之前执行了什么。
对于一次性的测试数据库,写操作本身没有问题;真正需要避免的是测试方法之间产生隐式的数据依赖。
为什么不通过 web 模块测试 Mapper
当前模块关系是:
text
web
↓
common-mapper如果为了测试 common-mapper,反过来必须先启动:
text
web那么测试结构就会变成:
text
BookMapper
↓
为了测试它
↓
启动 web
↓
加载完整应用这会让底层模块的验证依赖上层应用。
而现在:
text
common-mapper
↓
自己的 MapperTestBase
↓
自己的测试数据库就可以独立完成验证。
这不仅减少启动内容,也缩短了开发中的反馈链路。
为什么不加载业务 Interceptor
完整应用中可能存在很多 Interceptor。
例如:
text
登录校验
租户上下文
请求日志
审计
权限
Web 请求链路这些组件在正式应用里可能非常重要。
但 Mapper IT 的问题是:
刚刚修改的 Mapper、XML 或 SQL 能不能正确执行?
如果某个 Interceptor 不会影响这个问题的答案,就没有必要加载。
否则测试会重新开始依赖:
text
Token
Request
Session
Redis
当前用户
租户上下文
其他业务 Bean测试范围又会慢慢扩大。
当然,如果测试目标本身就是验证某个 MyBatis Interceptor 对 SQL 的影响,那么这个 Interceptor 就属于必要依赖,应该显式加入。
判断标准始终只有一个:
它是否会影响当前 Mapper 验证结果。
H2 的 MySQL 兼容模式不是 MySQL
示例中使用:
text
MODE=MySQL让 H2 尽量按照 MySQL 的部分语法和行为执行。
这对于演示和开发阶段快速验证很方便。
例如可以很快发现:
text
SQL 少了逗号
字段名写错
参数绑定错误
ResultMap 不完整
SELECT 别名写错
动态 SQL 拼接错误但:H2 的 MySQL 兼容模式并不等于真正的 MySQL。
在一些更依赖数据库实现细节的场景中,两者仍然可能存在差异,例如:
text
数据库函数
日期时间行为
大小写规则
ENUM 行为
JSON 类型
索引
锁
事务隔离级别
执行计划
MySQL 特有语法因此这套 H2 Mapper IT 更适合回答:刚刚修改的 Mapper、XML 或 SQL 是否基本正确?
而不是:这段 SQL 在真实 MySQL 中的所有行为是否完全正确?
真实项目如果需要验证 MySQL 行为,可以直接连接测试 MySQL:
text
Mapper IT
↓
MySQL这种情况下通常不需要:
text
schema.sql
data.sql因为数据库结构和测试数据已经存在。
另一种方式是:
text
Testcontainers
↓
启动临时 MySQL
↓
schema.sql
↓
data.sql
↓
Mapper IT这样仍然能够控制数据环境,同时使用真实 MySQL。
所以:
text
H2 + schema.sql + data.sql只是本文为了方便演示选择的一种实现。
不是这套 Mapper 最小上下文方案的必要组成部分。
最小上下文也让失败更容易定位
完整应用启动失败时,问题可能来自任何地方。
而 Mapper 最小上下文里的失败范围很有限:
text
DataSource 配置
MyBatis-Plus 配置
Mapper 扫描
Mapper XML
SQL
数据库结构
结果映射如果 BookMapperIT 失败,排查范围天然比:
java
@SpringBootTest小很多。
因此最小上下文带来的不只是启动速度,还有:更小的故障搜索空间。
不同 Spring Boot 版本的实现会变化
不同 Spring Boot 和 MyBatis-Plus 版本下:
text
自动配置类
测试依赖
配置文件加载方式都可能发生变化。
因此示例代码不能简单跨版本复制。
但这篇文章关注的原则不依赖具体版本:
text
不启动完整 Application
不加载 Web 层
不加载无关业务 Bean
不加载与 Mapper 验证无关的 Interceptor
只加载会影响 Mapper 执行结果的组件版本变化的是实现方式,不变的是测试边界。
什么时候不适合使用这种方式
最小 Mapper IT 适合快速验证:
text
Mapper
XML
SQL
参数绑定
数据库字段映射
ResultMap
MyBatis 插件行为但它不能替代完整应用测试。
如果真正要验证的是:
text
Controller
↓
Service
↓
事务
↓
Security
↓
Mapper那就已经不是 Mapper 测试的范围。
这时应该使用更高层级的 IT 或 SmokeIT。
同样,如果测试目标依赖真实数据库特性,也不应该因为本文示例用了 H2,就强行继续使用 H2。
测试环境应该跟随测试目标扩大,而不是让一套轻量 Mapper IT 承担所有验证职责。
最后的结构
最终 Mapper 测试的依赖关系很简单:
text
BookMapperIT
↓
MapperTestBase
↓
Spring Test
↓
DataSourceAutoConfiguration
+
MybatisPlusAutoConfiguration
↓
BookMapper
↓
测试数据库没有:
text
Web Application
Controller
Security
Scheduler
第三方 Client
业务 Interceptor
其他业务 Bean当 Mapper、XML 或 SQL 发生修改时,可以直接在 IDE 中运行对应 IT,在很短的时间内得到反馈。
数据库既可以是:
text
H2也可以是:
text
测试 MySQL或者:
text
Testcontainers + MySQL数据库怎么准备可以变化,最小上下文的目标不变。
结论
Mapper 测试使用最小 Spring 上下文,并不是为了证明:
Spring Boot 启动得越少越高级。
它解决的是一个很实际的问题:让开发者在修改 Mapper、XML 或 SQL 后,可以很便宜地验证刚刚的调整。
为了这个目标,测试只需要加载会影响 Mapper 执行结果的组件。
如果某个组件和这次验证无关,就没有必要进入测试上下文。
于是开发过程可以保持一个很短的反馈循环:
text
修改
→ 运行对应 MapperIT
→ 确认
→ 继续这就是最小 Mapper 测试上下文真正的价值。