SpringBoot+MyBatis添加功能测试:从参数绑定到主键回填
发布时间:2026/10/2 3:00:25来源:尧图网络
从查询功能一路走到添加功能测试这篇我得把话放在前面真正检验一个MyBatis框架搭得好不好不是看查询跑没跑通而是看写入能否稳稳落地。查询只需要把结果读出来错了最多就是改SQL添加不一样它牵扯参数绑定、主键回填、事务提交、缓存失效一环扣一环。前五篇我们把SqlSessionFactory、Mapper扫描、基础查询都过了一遍现在到了第六篇目标只有一个把添加功能测试做到位让后续的注册、下单之类业务都能踩在同一条踏实路径上。这篇博文主要适合正在用SpringBoot集成MyBatis的开发者也适合那些刚把MyBatis跑起来、正卡在“insert怎么写都不对”阶段的初学者。我会从接口设计、XML映射、主键回填、TypeHandler介入时机到测试环境搭建、SQL日志打印、XMLConfigBuilder初始化流程再到缓存和常见坑位把测试添加功能的完整链路拆开讲清楚。每个人手里的框架版本可能略有差异但核心机制是通用的照着这个思路走基本不会跑偏。1. 添加功能测试的前置拆分接口、参数与事务边界1.1 为什么添加功能适合当框架的试金石很多人在搭框架时第一个能跑通的功能永远是查询因为查询的验证路径最短Mapper方法写上、XML写上、跑一遍、返回结果看一眼就完事。可一旦数据要往数据库里写事情开始变得微妙。先不提SQL本身光是参数是怎么从Java对象传到PreparedStatement里的就足够让你排查半天。我的经验是添加功能是最好的框架试金石。它会逼着你把三个问题想清楚参数以什么形式传给Mapper、XML里用什么占位符取参数、事务何时提交数据才算真正落库。这三件事在查询阶段几乎不会暴露但到写入阶段一个都躲不掉。所以这一篇虽然标题叫“测试添加功能”它实际考验的是整个框架写路径的通畅度。以我手头这个博客系统为例用户注册就是典型的添加场景。用户名、密码、邮箱这些字段要插进user表还要拿到数据库自动生成的主键后续才能关联其他业务表。这里不存在复杂的查询逻辑但测试用例必须覆盖插入成功、主键回填、重复数据异常、事务回滚生效。把这些测完框架的写路径就稳了。1.2 接口方法与参数绑定的三种写法先说Mapper接口的写法。添加功能最简单的形态就是定义一个方法public interface UserMapper { int insertUser(User user); }这是最推荐的方式参数直接传实体对象。XML里取属性时也最直观#{username}、#{age}MyBatis会自动调用User类里对应的getter方法完成取值。用对象传参的好处是字段多了以后接口签名依然稳定不会因为增加一个字段就改一次方法定义。但如果你的添加方法只需要其中几个字段或者临时测试不想建一个完整实体类也可以拆成散参传int insertUser(Param(username) String username, Param(age) Integer age);这里一定要加上Param注解。这是很多新手踩坑的地方不加注解时单参数情况下XML里写什么都无所谓但多参数时MyBatis解析参数名的行为就不那么友好了。它会默认用param1、param2这样的位置占位符或者arg0、arg1这种索引下标。比如你接口里写的是String username, Integer ageXML里写#{username}不加注解直接报错加了Param(username)之后框架就明确知道这个参数叫usernameXML里怎么引用都对得上。其实不写注解也能跑通前提是你得在XML里用#{param1}、#{param2}这种索引方式。为什么我不推荐因为一旦接口参数顺序调整XML全要跟着改排查问题的时候特别容易漏。索引为零、参数一这种写法代码review的人也看得一头雾水。真的很抵触这种隐式索引。1.3 事务边界测试数据会不会留在库里写完Mapper和XML很多人直接跑测试结果发现日志里SQL也执行了控制台也没报错但数据库里就是查不到那条数据。99%的第一反应是MyBatis配置错了实际上基本都在事务提交上。原生MyBatis的环境下通过sqlSessionFactory.openSession()拿到SqlSession时默认是不自动提交的。你需要手动调用session.commit()数据才会真正落库。看这个示例try (SqlSession session sqlSessionFactory.openSession(false)) { UserMapper mapper session.getMapper(UserMapper.class); int rows mapper.insertUser(user); session.commit(); System.out.println(affected rows: rows); }注意openSession(false)的意思是关闭自动提交事务边界由我自己控制。如果你不在乎中间状态只想快速看效果可以openSession(true)但实际项目里我从不建议这么干。写入操作必须要有明确的事务边界这是底线。在SpringBoot环境里事务就交给Spring管理了。最简单的做法是在测试类上标注TransactionalSpring会在每个测试方法执行完后自动回滚测试数据不会污染真实的业务库。这招我用得最多省去了每次手工清理测试数据的烦恼。后面第三节讲测试用例时我会再展开。2. XML映射细节与TypeHandler的介入时机2.1 insert语句的动态SQL与占位符选择Mapper接口只定义了方法签名真正干活的SQL在XML里。先看一个最常规的insertinsert idinsertUser insert into t_user (username, age, email) values (#{username}, #{age}, #{email}) /insert这段SQL看起来简单但有一个细节值得说道说道参数占位符为什么是#{}而不是${}。#{}是预编译占位符MyBatis会把#{username}解析成JDBC的?再通过PreparedStatement绑定参数。这样既避免了SQL注入又自动处理了字符串类型的引号拼装。而${}是直接做字符串替换把传入的值原样拼到SQL里例如排序字段的动态拼接偶尔会用到但它绝对不能用在普通参数上。添加功能涉及用户输入这里的选型没有第二个答案必须#{}。如果你的添加场景是“只插入非空字段”那就需要动态SQL了。比如用户表新增了电话号码字段但有些用户没填你不能让整条insert因空值而失败也不能硬插一个空字符串进去。MyBatis里最常见的做法是配合if和trim标签insert idinsertUserDynamic useGeneratedKeystrue keyPropertyid insert into t_user trim prefix( suffix) suffixOverrides, if testusername ! nullusername,/if if testage ! nullage,/if if testemail ! nullemail,/if /trim trim prefixvalues ( suffix) suffixOverrides, if testusername ! null#{username},/if if testage ! null#{age},/if if testemail ! null#{email},/if /trim /insert这里每个trim的作用是在片段最前面加上prefix最后加上suffix并把末尾多余的逗号用suffixOverrides去掉。比如只有username和email不为空拼出来的SQL就是insert into t_user (username, email) values (?, ?)字段缺席时SQL照样成立不会出现values ()的空括号错误。这种写法在“新增用户”这一类字段可选的场景里几乎是标配。2.2 主键回填useGeneratedKeys与keyProperty的配合添加功能跟查询有个很大的区别插入之后业务方往往立刻需要知道新数据的主键好去关联子表、写日志或者返回给前端。如果你依赖数据库自增主键那么XML里的useGeneratedKeys必须配置上。insert idinsertUser useGeneratedKeystrue keyPropertyid insert into t_user (username, age, email) values (#{username}, #{age}, #{email}) /insertuseGeneratedKeystrue告诉MyBatis这条insert执行完之后把数据库生成的自增主键取回来keyPropertyid则指定把取回的键值赋值到传入对象User的id属性上。测试时就能看到User user new User(); user.setUsername(张三); userMapper.insertUser(user); System.out.println(user.getId()); // 这里拿到的就是数据库生成的主键注意这个操作依赖数据库的JDBC驱动是否支持getGeneratedKeys。MySQL支持得很好PostgreSQL也支持但Oracle这类不带自增主键的数据库就得换思路用selectKey在插入前从序列里取主键。所以我们在设计添加功能时要先搞清楚底层数据库的特性再决定主键回填的方式。2.3 TypeHandler的工作流程与自定义时机“添加”这个动作本质上就是把Java对象的属性值变成JDBC驱动能识别的参数然后写进数据库。这一步的翻译工作就是TypeHandler干的。TypeHandler的工作流程图用大白话描述是这样的MyBatis解析到#{username}时会根据参数的实际Java类型在Configuration里找到一个对应的TypeHandler。执行insert语句之前站起身调用这个TypeHandler的setParameter方法。setParameter内部把Java对象的值取出来调用PreparedStatement.setXxx写入占位符。查询语句则走反向流程getResult方法从ResultSet里拿到数据库字段值再翻译成Java对象。你在互联网上搜“MyBatis中TypeHandler的工作流程图”看到的那些图无论画得多复杂核心始终是这条双向翻译链路Java类型转JDBC类型JDBC类型转Java类型。什么时候需要自己写TypeHandler最常见的场景是枚举类型和特殊格式字段。比如用户状态字段用tinyint存储Java对象里却是一个枚举public enum UserStatus { ACTIVE(1), DISABLED(0); private final int value; UserStatus(int value) { this.value value; } public int getValue() { return value; } public static UserStatus fromValue(int value) { for (UserStatus status : UserStatus.values()) { if (status.value value) { return status; } } throw new IllegalArgumentException(未知状态: value); } }默认情况下MyBatis不知道这个枚举该怎么往数据库里写你要么在代码里手动调用getValue()要么就自定义一个TypeHandlerMappedTypes(UserStatus.class) MappedJdbcTypes(JdbcType.INTEGER) public class UserStatusTypeHandler extends BaseTypeHandlerUserStatus { Override public void setNonNullParameter(PreparedStatement ps, int i, UserStatus parameter, JdbcType jdbcType) throws SQLException { ps.setInt(i, parameter.getValue()); } Override public UserStatus getNullableResult(ResultSet rs, String columnName) throws SQLException { return UserStatus.fromValue(rs.getInt(columnName)); } Override public UserStatus getNullableResult(ResultSet rs, int columnIndex) throws SQLException { return UserStatus.fromValue(rs.getInt(columnIndex)); } Override public UserStatus getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { return UserStatus.fromValue(cs.getInt(columnIndex)); } }写完这个处理器在SpringBoot的配置文件里注册一下mybatis: type-handlers-package: com.example.myblog.handler测试添加功能时直接往User对象里放一个UserStatus.ACTIVE插入完成后去数据库里看存进去的就是整数1。这就是TypeHandler帮我们省下的规则。3. SpringBoot环境下的添加功能测试实操3.1 测试环境怎么搭最省事先说依赖。用SpringBoot做单元测试spring-boot-starter-test基本是必加的dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-test/artifactId scopetest/scope /dependency然后是数据库。如果不想让测试数据污染开发库我建议在测试环境里用H2这种内存数据库。它的配置非常简单spring: datasource: url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY-1 driver-class-name: org.h2.Driver username: sa password: sql: init: schema-locations: classpath:test-schema.sql仓库里放一份test-schema.sql在测试启动时自动建表CREATE TABLE IF NOT EXISTS t_user ( id BIGINT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(64) NOT NULL, age INT, email VARCHAR(128), status INT DEFAULT 1 );加一行MyBatis的配置mybatis: mapper-locations: classpath:mapper/*.xml type-handlers-package: com.example.myblog.handler configuration: map-underscore-to-camel-case: truemap-underscore-to-camel-case这个配置很实用它能让数据库字段user_name自动映射到Java属性userName省掉大量XML里的resultMap手工字段。测试添加功能的时候因为你正处在“配置合不合规”的验证阶段所以这个开关一开可以提前暴露一部分字段映射问题。3.2 一个完整的添加测试用例我一般会把添加功能测试分成两段一段走SpringBoot全量容器一段走原生MyBatis入口。先看全量容器的版本SpringBootTest Transactional class UserMapperTest { Autowired private UserMapper userMapper; Test void insertUser_shouldReturnGeneratedKey() { User user new User(); user.setUsername(张三); user.setAge(25); user.setEmail(zhangsanexample.com); int affected userMapper.insertUser(user); assertThat(affected).isEqualTo(1); assertThat(user.getId()).isNotNull(); } }这里SpringBootTest拉起整套容器Transactional让每个测试方法结束前自动回滚。第一次跑的时候我需要你认真看一眼日志里有没有insert语句输出有没有报字段找不到的错误第二次跑的时候重点观察user.getId()有没有被正确回填。这个方法测的是Spring、MyBatis、Mapper代理、事务管理之间的配合是日常开发最常用的一层。再看原生MyBatis入口的测试这能帮你穿透Spring的封装看框架底层的真实行为Test void nativeMyBatisInsertTest() throws Exception { String resource mybatis-config.xml; InputStream inputStream Resources.getResourceAsStream(resource); SqlSessionFactory factory new SqlSessionFactoryBuilder().build(inputStream); try (SqlSession session factory.openSession(false)) { UserMapper mapper session.getMapper(UserMapper.class); User user new User(); user.setUsername(李四); user.setAge(30); int affected mapper.insertUser(user); session.commit(); assertThat(affected).isEqualTo(1); assertThat(user.getId()).isNotNull(); } }这种写法的意义在于它让你亲眼看到openSession(false)不自动提交、session.commit()强制落库、SqlSession关闭后一级缓存失效的完整生命周期。如果你在SpringBoot里怎么都查不出添加功能的bug回到这种原生写法往往能更快定位问题。3.3 配置打印SQL日志让每一条语句都看得见测试添加功能最忌讳的就是“黑盒”式跑测试看到一条绿灯就以为万事大吉实际上SQL长什么样、参数绑成了什么值完全不知道。我的习惯是先把SQL日志打开亲眼确认SQL和参数再谈通过。SpringBoot MyBatis最直接的打印方式是在application.yml里加一行mybatis: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl加了之后控制台会直接输出类似这样的内容 Preparing: insert into t_user ( username, age, email ) values ( ?, ?, ? ) Parameters: 张三(String), 25(Integer), zhangsanexample.com(String) Updates: 1这一段日志信息量很大。Preparing后的SQL如果是?占位符说明#{}解析正确Parameters列出了每个参数的实际类型能帮你快速发现“字符串传成了数字”“日期传成字符串”这类问题Updates返回了影响行数insert正常时基本是1。如果你用SLF4J体系我更喜欢设置mapper包下的日志级别logging: level: com.example.myblog.mapper: debug这样能保证SQL日志和业务日志混在一起按时间顺序排列排查上下文也更方便。3.4 聊聊XMLConfigBuilder与初始化流程我们一直在写测试用例但鲜少有人停下来想一个问题那些Mapper XML里的insert标签是什么时候变成可执行语句的这个答案藏在MyBatis的启动初始化流程里。MyBatis的初始化从SqlSessionFactoryBuilder.build()开始它内部会创建XMLConfigBuilder。XMLConfigBuilder负责解析mybatis-config.xml这个主配置文件过程大致如下读取根配置里的settings、typeAliases、typeHandlers、environments等节点填充到一个Configuration对象里。解析到mappers节点时根据配置加载所有Mapper XML文件。每个Mapper XML里写的insert、select、update、delete会被逐条解析转换成MappedStatement对象注册进Configuration。最终Configuration对象交给SqlSessionFactorySqlSessionFactory再根据它创建SqlSession。这个流程常出现在MyBatis源码分析里面也是面试题“MyBatis中基于XML配置的初始化工作原理”的答案主线。理解了它你测试时遇到“Invalid bound statement (not found)”这类错误就不会慌说明Mapper接口方法没能在Configuration里找到对应的MappedStatement要么是XML没加载要么是namespace或id写错了。你甚至可以自定义Configuration对象扩展缓存逻辑或补充动态的MappedStatement注册不过日常项目里用到这个深度的人很少。需要提一句的是MyBatis本身不绑定具体数据库品牌它只面向JDBC协议。你项目里用的是MySQL也好PostgreSQL也好或者有人问到的GaussDB也好只要数据库厂商提供符合JDBC规范的驱动MyBatis就能对接。区别主要落在具体方言上比如主键生成策略、分页语法框架层面不会替你抹平这些差异。4. 常见问题与排查技巧实录4.1 数据插不进去先查这四个点测试添加功能报错或者“假成功”时我建议按这个顺序排雷。第一事务有没有提交。这是最隐蔽的。原生SqlSession模式下没调用commit()数据就停留在未提交状态控制台不报错数据库也查不到。判断方法很简单日志里如果打印了update语句但连接断开后数据消失基本就是事务问题。第二字段映射对不对。数据库字段是user_nameJava属性是username你既没开map-underscore-to-camel-case也没用resultMap插入时MyBatis取不到值SQL跑起来要么报“Unknown column”要么成功插入但字段是NULL。第三参数名能不能对上。传的是实体对象时#{name}必须对应对象里的属性名传的是Param参数时#{username}必须等于注解的值。拿不准就翻SQL日志Parameters那行如果显示null或直接报错基本就是参数名没匹配上。第四SQL语法本身与数据库方言不匹配。比如MySQL的AUTO_INCREMENT和H2的AUTO_INCREMENT差别不大但Oracle的序列用法完全不同。这个只能靠目标数据库的报错去对照没有银弹。4.2 MyBatis缓存对添加功能的影响与避坑缓存是MyBatis绕不开的机制添加功能测试阶段也常被它坑到尤其是一级缓存。一级缓存是SqlSession级别的同一个SqlSession里执行相同的查询第二次会直接命中缓存不再查数据库。这个特性在某些场景下会掩盖问题你在同一个SqlSession里先插入一条数据然后立刻查询因为一级缓存存的是上一次查询的结果你拿到的是旧缓存怎么都看不到刚插入的数据于是开始怀疑insert没生效。一级缓存在SqlSession执行commit、rollback、close时会被清空。所以测试代码里插入之后先提交再用新的SqlSession去查才能拿到可靠结果。SpringBoot环境下SqlSessionTemplate每次操作都会走代理事务边界由Spring控制一级缓存行为有一定差异但“提交后才有真实数据可见”这条原则不变。二级缓存是Mapper级别的默认不开启只有在XML里配置了cache标签后才会生效。实现机制也不算复杂首次查询结果存入缓存对象后续查询命中缓存就直接返回执行增删改时MyBatis默认会把相关namespace的缓存清空防止脏读。但它要求缓存对象可序列化并且缓存生命周期与事务绑定一旦配置不当插入操作后查出来还是旧数据这种坑排查起来非常痛苦。我的建议是添加功能测试期间先老老实实把二级缓存关掉等功能全部验证通过再在查询压力大的Mapper上按需开启。4.3 条件不生效的经典原因添加功能本身一般不涉及查询条件但你在测试插入后如果顺手做了个“按条件查新数据”的验证很容易碰到条件不生效的经典问题。最常见的写法错误是XML里拿#{}去跟数据库字段做比较时参数名和接口参数对不上。比如接口方法里写了Param(status) UserStatus statusXML里却写了where status #{statusValue}MyBatis自然取不到值条件直接变成空参查询结果变成全表扫描看起来就是“条件不生效”。这个问题的排查窍门是开SQL日志日志里如果Preparing一段显示where status ?而Parameters是null基本可以锁定是参数名不匹配。另一个误区是把#{status}和枚举类型混用。如果你没有自定义TypeHandlerMyBatis对枚举的处理可能跟你预期不一致最终条件值跟数据库存的不一样。这也是为什么我前面建议枚举字段一定要配套写TypeHandler不要依赖MyBatis的默认行为。4.4 批量添加的简单扩展添加功能测试稳定之后难免会碰到批量插入的需求比如用户导入、批量初始化数据。在原生MyBatis里批量插入无非两种做法。一种是在Java代码里循环调用单条insert好处是简单直观缺点是有N条数据就要执行N次数据库交互性能很差。另一种是用批处理执行器try (SqlSession session sqlSessionFactory.openSession(ExecutorType.BATCH)) { UserMapper mapper session.getMapper(UserMapper.class); for (User user : users) { mapper.insertUser(user); } session.commit(); }ExecutorType.BATCH会把一批SQL攒起来一次性发给数据库网络往返次数大幅减少。不过它也有代价批处理模式下如果你中途需要读取某条插入生成的主键时机不对可能拿不到。如果你的项目本身用了MyBatis-Plus那它的saveBatch方法已经帮你封装好了批处理逻辑直接调用即可底层同样走的是批量刷新机制。我的态度是先理解原生MyBatis的批处理原理再用MyBatis-Plus的封装出了问题才不难定位。5. 说点掏心窝的实操体会这套添加功能测试写下来我个人最深的感触是测试的重点不只是“让用例变绿”而是让每一条数据流的路径都清晰可见。我在核心环节上花的时间远比写SQL的时间多。事务边界的控制、参数名的统一、主键回填时机、缓存失效规则的验证几乎每个点都出过问题。如果用一句话总结踩坑经验测试添加功能时永远别只盯着insert语句本身要把“事务什么时候提交”“缓存什么时候清掉”“映射什么时候生效”这三个时间点同步想清楚。最后分享一个小技巧在测试类里多写一个“验证插入后能按主键查回”的用例不要只测affected行数。别看它简单它能一次性检验useGeneratedKeys是否生效、insert和select的TypeHandler是否匹配、字段映射是否完整三个关键点全在这个用例里暴露无遗。框架搭到这一步添加功能的闭环就算真正打通了。
网站建设高端定制企业官网