我在项目里见过太多这样的情况:Mapper里一个SQL写好了,不测试就往上走,等到联调阶段才发现参数绑定错了、结果集映射出来全是null,然后在Service、Controller、Debug日志之间来回折腾。MyBatis作为Java后端最常用的持久层框架,它和JUnit4搭配做单元测试,其实没有很多人想象的那么重——不需要启动Spring,不需要连公司那台动不动就挂的测试库,一个内存数据库加少量配置就能把Mapper问题挡在联调之前。这篇文章就是讲我怎么用JUnit4给MyBatis写单元测试的:从环境搭建、用例组织,到缓存、分页、慢SQL这类真实问题的复现方法,适合正在学MyBatis的入门者,也适合想让DAO层测试从零到一落地的人。
1. 为什么DAO层一定要有单元测试:趁SQL还没进联调环境
1.1 一个SQL映射错误在联调环境里的代价
先讲一个我印象很深的例子。之前有个项目联调,前端说列表接口返回的数据不对,多出来一批记录。排查下来,根因是Mapper XML里联表查询的条件少写了一个关联字段,SQL本身没错,但语义完全不对。这个Bug从现象出现到定位根因,整整花了一个下午,绕过了Controller、Service、Mapper三层,最后才在XML里找到一个不起眼的问题。如果当时有单元测试,第一次执行SQL就能发现筛选条件没生效。
类似的情况后端同学多半都遇到过。MyBatis的Mapper方法不像普通Java方法,方法调错了编译器会直接报错。SQL映射错误、参数个数对不上、返回字段和实体属性对不齐,这一系列问题在编译期完全暴露不出来,只能到运行期、联调期甚至上线之后才现形。持久层单元测试的核心价值,就是把这段"看不见错误的时间窗口"压缩到最小。
1.2 MyBatis单元测试到底在测什么
有人会问,MyBatis框架自己不是有测试吗,为什么还要我们自己写?这句话对了一半。我们要测的不是MyBatis框架本身,而是自己写的Mapper接口和对应的SQL映射。具体来说,JUnit4测试覆盖的是下面几件事:
- SQL语法是否正确,数据库能不能正常执行
- 参数绑定是否匹配,尤其是多参数、集合参数、对象属性引用
- 动态SQL在条件成立、不成立、参数为null时,拼接结果是否符合预期
- 结果集映射是否正确,数据库字段和实体属性能否对上
- insert、update、delete的受影响行数是否符合预期,事务回滚是否正常
这些恰恰是实际项目里最容易出Bug的地方。往大了说,Mapper方法的质量基本决定了持久层的稳定性,与其等别人在联调时帮你找出问题,不如自己第一次写完就测掉。
1.3 什么时候该投入写测试,什么时候可以偷懒
不是所有Mapper方法都值得花同样的精力去测。简单的单表CRUD,生成器生成的selectByPrimaryKey这类,跑一条冒烟用例确认能通就行。真正值得认真写测试的是下面这些:
- 动态SQL复杂,if、choose、foreach嵌套多
- 多表联查,结果集涉及resultMap嵌套映射
- 批量插入、批量更新,涉及事务一致性
- 使用了一级缓存、二级缓存、分页插件、自定义TypeHandler等扩展机制
我的习惯是:写完一个Mapper方法,先写对应的JUnit测试,把正常输入、边界输入、null输入都测一遍,确认逻辑没问题再往Service层走。这个开销其实很小,一个方法多写几个@Test而已,但后面每次改SQL都能自动回归,安全感和效率都上来了。
2. 五步搭好MyBatis测试环境:H2内存库加JUnit4足够轻量
2.1 依赖引入:三个核心包就够了
MyBatis不依赖Spring,SqlSessionFactory完全可以用代码直接构建。这给测试带来了一个很大的便利:环境极轻量。现在新建一个测试工程,只需要引入MyBatis本身、JUnit4、以及一个能跑SQL的数据库即可。
<dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>3.5.16</version> </dependency> <dependency> <groupId>junit</groupId> <artifactId>junit</artifactId> <version>4.13.2</version> <scope>test</scope> </dependency> <dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <version>2.2.224</version> <scope>test</scope> </dependency>版本上注意一点:MyBatis用最新的3.5.x稳定版就好,H2用2.x系列。如果项目里已经有这些依赖,把scope改成test,只在测试生命周期里出现,避免污染生产产物。
2.2 为什么测试库我首选H2内存模式
选择H2而不是直接连项目里的MySQL/Oracle,核心原因是可重复性和可移植性。H2的内存模式不需要连接远程数据库,本地跑、CI跑都不会因为网络问题失败;每个测试运行前可以独立初始化表结构,测完即毁,不污染共享开发库;启动速度极快,几十个测试用例跑下来也就几秒。
生产环境是MySQL的话,H2可以开MODE=MySQL来模拟部分方言。但要认清一点:H2和MySQL在少数SQL语法、函数行为上依然有差异,比如某些字符串函数、锁语义,兼容模式并不能做到100%一致。所以我的实践是:日常快速验证用H2,涉及数据库特有SQL或复杂关联查询时,再在CI里额外跑一份指向Docker真实数据库的集成测试。两边配合,性价比很稳。
2.3 最小化的mybatis-config.xml长什么样
测试用的配置文件不需要像生产环境那么复杂,核心就是数据源指向H2,再注册要测试的Mapper XML。
<?xml version="1.0" encoding="UTF-8" ?> <!DOCTYPE configuration PUBLIC "-//mybatis.org//DTD Config 3.0//EN" "https://mybatis.org/dtd/mybatis-3-config.dtd"> <configuration> <settings> <setting name="mapUnderscoreToCamelCase" value="true"/> <setting name="logImpl" value="STDOUT_LOGGING"/> </settings> <environments default="development"> <environment id="development"> <transactionManager type="JDBC"/> <dataSource type="POOLED"> <property name="driver" value="org.h2.Driver"/> <property name="url" value="jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;MODE=MySQL"/> <property name="username" value="sa"/> <property name="password" value=""/> </dataSource> </environment> </environments> <mappers> <mapper resource="mapper/UserMapper.xml"/> </mappers> </configuration>这里有几个容易踩的细节。第一个是jdbc:h2:mem:testdb后面的DB_CLOSE_DELAY=-1,这是关键参数,不加的话,最后一个连接关闭时整个内存数据库会被销毁,SqlSessionFactory一关表结构就没了。第二个是logImpl设为STDOUT_LOGGING,这样SQL日志会直接打到控制台,测试过程中能直观看到MyBatis实际执行了哪条SQL。第三个是Mapper XML的resource路径必须和你项目里的实际路径一致,不一致会直接报找不到XML。
2.4 几行代码构建SqlSessionFactory
配置准备好之后,测试里构建工厂非常直白:
package com.example.mybatis.test; import org.apache.ibatis.io.Resources; import org.apache.ibatis.session.SqlSessionFactory; import org.apache.ibatis.session.SqlSessionFactoryBuilder; import org.junit.Before; import java.io.Reader; public class MyBatisTestBase { protected SqlSessionFactory sqlSessionFactory; @Before public void setUp() throws Exception { // 从classpath下加载mybatis-config.xml try (Reader reader = Resources.getResourceAsReader("mybatis-config.xml")) { sqlSessionFactory = new SqlSessionFactoryBuilder().build(reader); } initDatabase(); } protected void initDatabase() { // 子类中实现建表和初始化数据 } }注意这段代码里我们完全没有引入Spring。有人会担心,真实环境是Spring管理Mapper的,不经过Spring的测试还有效吗?其实MyBatis的核心行为都发生在SqlSession和Mapper层,跟Spring怎么注入无关。你在JUnit里已经能验证Mapper方法的SQL、参数、映射、事务行为;剩下那些Bean装配问题,属于另一层集成测试的范畴,不该和Mapper测试混在一起。
2.5 建表和测试数据初始化:@Before里一次搞定
H2内存库每次JVM启动都是空库,所以需要在测试执行前把表结构和基础数据准备好。用JUnit4的@Before方法就行,每个测试执行前都会跑一遍,保证用例之间初始状态一致。
public class UserMapperTest extends MyBatisTestBase { @Override protected void initDatabase() { try (SqlSession session = sqlSessionFactory.openSession(); Statement stmt = session.getConnection().createStatement()) { stmt.execute("CREATE TABLE IF NOT EXISTS user (" + "id BIGINT PRIMARY KEY AUTO_INCREMENT," + "username VARCHAR(50) NOT NULL," + "password VARCHAR(50) NOT NULL," + "status INT DEFAULT 1)"); stmt.execute("DELETE FROM user"); stmt.execute("INSERT INTO user (id, username, password, status) VALUES (1, '张三', '123456', 1)"); stmt.execute("INSERT INTO user (id, username, password, status) VALUES (2, '李四', '654321', 0)"); } catch (Exception e) { throw new RuntimeException(e); } } }我习惯把建表语句放到test/resources下的schema.sql里,初始化数据放到data.sql里,然后用JDBC读取执行,而不是把CREATE TABLE硬编码在Java里。还有一个很重要的原则:每个测试用例必须建立在自己的"已知初始状态"之上。所以不要在@Before里只建表不插数,否则你断言查询结果是2条,却不清楚这2条是从哪来的,测试就失去了意义。
3. JUnit4测Mapper的标准套路与事务回滚细节
3.1 最直接的测试写法
一个最基础的MyBatis加JUnit4测试,代码结构是这样的:
@Test public void testSelectById() { try (SqlSession session = sqlSessionFactory.openSession()) { UserMapper mapper = session.getMapper(UserMapper.class); User user = mapper.selectById(1L); assertNotNull(user); assertEquals("张三", user.getUsername()); assertEquals(Integer.valueOf(1), user.getStatus()); } }try-with-resources把SqlSession包住,确保用完后关掉,避免连接泄漏。方法里的步骤就是三件事:拿Mapper、调用方法、断言结果。这也是MyBatis测试的标准骨架,后面所有测试都是在这个框架上展开的。
3.2 封装一个BaseTest,把重复代码收起来
项目里Mapper很多,如果每个测试类都从头构建SqlSessionFactory、初始化表数据,代码会非常啰嗦。所以我通常写一个公共基类,把工厂构建、数据源配置、通用辅助方法都收进去,每个Mapper测试类继承它即可。
public abstract class BaseMapperTest { protected SqlSessionFactory sqlSessionFactory; @Before public void baseSetUp() throws Exception { try (Reader reader = Resources.getResourceAsReader("mybatis-config.xml")) { sqlSessionFactory = new SqlSessionFactoryBuilder().build(reader); } initSchema(); initData(); } protected abstract void initSchema(); protected abstract void initData(); protected SqlSession openSession() { return sqlSessionFactory.openSession(false); // 显式关闭自动提交 } }openSession(false)这里的false很关键,表示显式关闭自动提交。这样一来,Session里所有写操作都在我们可控范围内,方便做回滚型测试。子类测试里,需要事务就自己commit/rollback,不需要事务就try-with-resources关门走人,互不干扰。
3.3 写操作的测试:提交还是回滚
涉及insert、update、delete的测试,我强烈建议用回滚而不是提交。原因很简单:测试数据一旦真的写进数据库,第二次运行测试时库里的数据状态就变了,你的断言可能因此失败,开发库里也会堆满测试垃圾数据。
@Test public void testInsertUser() { SqlSession session = openSession(); try { UserMapper mapper = session.getMapper(UserMapper.class); User user = new User("王五", "abc123", 1); int rows = mapper.insertUser(user); assertEquals(1, rows); assertNotNull(user.getId()); // 自增主键已被回填 User reloaded = mapper.selectById(user.getId()); assertNotNull(reloaded); assertEquals("王五", reloaded.getUsername()); session.rollback(); // 回滚,避免留下测试数据 } finally { session.close(); } }看到session.rollback()就以为是失败操作的读者,可能要调整一下认知:在单元测试里,回滚不是出错了,而是"让测试环境回到原点"的一种手段。配合JUnit4每个方法都会执行@Before的特点,写操作测试每次都能从干净状态开始,反复跑结果一致,这比拿真实库硬测要舒服得多。
有一种情况确实需要commit,比如你想在测试后检查数据库里的持久化数据、想验证数据库触发器、或者想配合事务日志观察SQL执行情况。这类场景可以改成commit,但更多业务方法测试不需要,回滚就够了。
3.4 断言怎么写才真正有意义
很多测试方法写的是assertNotNull(user),然后就没下文了。这个断言几乎等于摆设——它只能发现"返回了null"这种问题,但你如果把SQL改成永远返回一条固定记录,这个断言依然会通过,根本察觉不到错误。
有效的断言一定要落到具体值上:
assertNotNull(user); assertEquals("张三", user.getUsername()); assertEquals(Integer.valueOf(1), user.getStatus()); assertEquals("123456", user.getPassword());如果是列表返回,除了断言size,还要断言顺序和关键字段值。如果是更新操作,要断言受影响行数,并且最好再查一次数据库,确认数据真的变了。总之,让断言贴近真实业务预期,而不是停留在"非空"这种浅层检查上。
4. Mapper测试最常翻车的四个地方:参数、动态SQL、缓存、结果映射
4.1 参数绑定:多参数和@Param的经典坑
MyBatis参数绑定是Mapper测试第一个要过的坎。单参数场景相对简单,用#{value}就能绑定;一旦方法有多个参数,问题就来了。
// 错误写法:没有加@Param注解,XML里又直接用#{username} List<User> selectByUsernameAndStatus(String username, Integer status); // 正确写法:每个参数用@Param显式声明名字 @Select("SELECT * FROM user WHERE username = #{username} AND status = #{status}") List<User> selectByUsernameAndStatus(@Param("username") String username, @Param("status") Integer status);多参数不加@Param时,MyBatis虽然也能运行,但XML里只能通过arg0、arg1或param1、param2这种位置参数引用,稍不留神就会写错。写单元测试时,最好每个参数都测到:传正常值、传null、传空字符串,确认都能走通且返回预期结果。这里如果测试没覆盖到,上线后一旦有调用方传了null,很容易出现SQL条件失效的问题。
4.2 动态SQL:null和空集合是翻车重灾区
动态SQL是MyBatis最灵活的部分,也是最容易出问题的地方。if标签的test条件是拼SQL的开关,但实际传参时,null和空集合经常触发意想不到的分支。
<select id="selectByCondition" resultType="User"> SELECT * FROM user <where> <if test="username != null and username != ''"> AND username = #{username} </if> <if test="statusList != null and statusList.size() > 0"> AND status IN <foreach collection="statusList" item="status" open="(" separator="," close=")"> #{status} </foreach> </if> </where> </select>针对这种SQL,我建议JUnit测试至少覆盖四组数据:所有条件为null、statusList为空集合、只传username、只传statusList。每组都要断言返回的结果集符合预期。特别是statusList为空时,如果没有加size() > 0判断,拼出来的SQL会变成AND status IN (),在MySQL/H2里直接语法报错。这种问题不跑单测很难在开发时发现。
4.3 缓存:一级缓存容易制造"假通过"
缓存是另一个在测试中常见但很容易被忽视的点。MyBatis一级缓存默认开启,作用范围是同一个SqlSession。同一个Session内执行相同的SQL,第二次根本不会真正查库,而是直接返回缓存对象。
@Test public void testFirstLevelCache() { SqlSession session = openSession(); try { UserMapper mapper = session.getMapper(UserMapper.class); User u1 = mapper.selectById(1L); u1.setUsername("临时改个名"); User u2 = mapper.selectById(1L); // 如果没有清缓存或重新开Session,u2和u1是同一个对象,username也是改过的 assertEquals(u1.getUsername(), u2.getUsername()); // 这里可能产生假象 } finally { session.close(); } }写测试时,如果断言全在同一个Session里完成,很容易被一级缓存"保护"住,查出来的数据和数据库真实状态不一样。解决方法是:要么每个断言前session.clearCache(),要么涉及数据变更后重新开一个Session查询。二级缓存的范围是SqlSessionFactory级别,如果XML里配置了<cache/>,测试时还要注意测试类之间的缓存共享,必要时在@Before里调用sqlSessionFactory.getConfiguration().clearCache()。
4.4 结果映射:字段对不上时最隐蔽
结果映射问题最折磨人,因为SQL执行成功了,但返回的实体里某些字段是null。常见原因有两个:一是没有开启mapUnderscoreToCamelCase,数据库的user_name映射不到实体的userName;二是resultMap里column写错、property拼错。
@Test public void testResultMap() { try (SqlSession session = openSession()) { UserMapper mapper = session.getMapper(UserMapper.class); User user = mapper.selectByUsername("张三"); assertNotNull(user); assertEquals("张三", user.getUsername()); // 映射错误时这里会失败 assertEquals("123456", user.getPassword()); // 别只断言username assertEquals(Integer.valueOf(1), user.getStatus()); } }这类测试的价值在于,只要SQL联表或字段变更,结果映射的改动就会被立刻暴露出来。再加上mapUnderscoreToCamelCase的配置,以及MyBatis的自动映射机制,提前用单元测试锁住字段映射,可以省掉很多"为什么这个字段查出来是null"的排查时间。
5. 用JUnit4复现真实项目里的MyBatis问题
5.1 复现一级缓存导致的数据不一致
有一次线上反馈,说同一个事务里先查了用户信息,然后更新了状态,再查一次发现还是旧状态。排除代码逻辑后,我写了个JUnit测试去复现:
@Test public void testStaleDataFromFirstLevelCache() { SqlSession session = openSession(); try { UserMapper mapper = session.getMapper(UserMapper.class); User u1 = mapper.selectById(1L); u1.setStatus(0); mapper.updateStatus(u1.getId(), 0); // 不clearCache也不关Session,一级缓存中还是更新前的数据 User u2 = mapper.selectById(1L); assertNotEquals(Integer.valueOf(0), u2.getStatus()); // 测试暴露缓存问题 } finally { session.rollback(); session.close(); } }测试跑出来确实复现了问题。根因是一级缓存中保存了更新前的User对象,第二次查询直接命中缓存。项目里的真实场景也是类似的:Service层拿着同一个SqlSession做多次操作,中途更新后没有清缓存。加上session.clearCache()或者重新打开Session,问题就消失了。这个测试把一个"偶发"问题变成了可复现、可验证的确定性问题,效率非常明显。
5.2 在JUnit4里测PageHelper分页插件
分页插件是MyBatis生态里的高频需求,热搜里也一直有mybatis的分页插件的用法。想在单元测试里覆盖分页逻辑,需要在构建SqlSessionFactory时注册PageInterceptor:
public class BasePageHelperTest extends BaseMapperTest { @Before public void setUpPageInterceptor() { org.apache.ibatis.session.Configuration config = sqlSessionFactory.getConfiguration(); config.addInterceptor(new com.github.pagehelper.PageInterceptor()); } }然后测试分页行为:
@Test public void testPageHelper() { SqlSession session = openSession(); try { UserMapper mapper = session.getMapper(UserMapper.class); PageHelper.startPage(1, 2); // 第一页,每页2条 List<User> users = mapper.selectAll(); PageInfo<User> pageInfo = new PageInfo<>(users); assertEquals(2, users.size()); assertEquals(2, pageInfo.getPageSize()); assertEquals(1, pageInfo.getPageNum()); assertTrue(pageInfo.getTotal() >= 2); } finally { PageHelper.clearPage(); // 记得清理线程变量 session.close(); } }分页插件测试有两个注意点:一是PageHelper.startPage之后必须紧跟Mapper查询,中间不要插入别的数据库操作,否则分页会失效;二是在线程里使用后要调用PageHelper.clearPage(),否则线程池复用时会污染下一次查询。这些容易被忽略的细节,跑一次单测就全都暴露了。
5.3 用测试定位@Update执行慢的真实原因
热搜里mybatis @update执行慢也是常客。这类问题如果直接在Service层感觉慢,很难定位,因为影响因素多。我习惯在JUnit里写一个专门的耗时测试,把方法执行时间量化出来,再根据执行计划对比优化前后效果。
@Test public void testUpdatePerformance() { SqlSession session = openSession(); try { UserMapper mapper = session.getMapper(UserMapper.class); // 预热连接 mapper.selectById(1L); long start = System.nanoTime(); int rows = mapper.updateStatus(1L, 0); long costMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start); System.out.println("updateStatus 耗时: " + costMs + "ms"); assertEquals(1, rows); } finally { session.rollback(); session.close(); } }测试不是为了证明一个方法快还是慢,而是为了建立可对比的基准。比如你在XML里发现update语句对索引列做了隐式类型转换,导致索引失效全表扫描,那就在JUnit里跑一次,记录耗时;改了SQL后再跑一次,对比耗时差距。这比凭感觉"好像变快了"要可靠得多。借助STDOUT_LOGGING日志,还能直接看到MyBatis打印出的SQL和参数,配合H2或真实数据库的执行计划,慢SQL的根因基本都能在现场定位出来。
最后分享一个我自己的操作习惯。写完一个Mapper方法,我会先写对应的JUnit测试,把正常输入、边界输入、null输入都测一遍,确认SQL和映射没有问题,再继续往上写Service。这样做的直接感受是,持久层代码在后面联调阶段几乎不需要返工,别人接手你的Mapper时也更有底气。还有一个很实用的小技巧:测试中如果SQL报错但报错信息很模糊,把mybatis-config.xml里的日志级别调到DEBUG,控制台会完整输出SQL和参数值,定位效率能提升一大截。