单测全绿、覆盖率漂亮,上线第一条真实请求就报 You have an error in your SQL syntax。原因不神秘:DAO 被 mock 了,那条 SQL 从未被任何数据库解析过。测试通过证明的只是"代码调用了 mock 并拿到预期对象"。
问题不是"要不要 mock",而是每一层要验证的结论,需要什么级别的仿真才成立。这里给载体判据、代价结构与失效边界。
1. 四类要验证的东西
| 验证对象 | 典型缺陷 | 需要的仿真级别 | 代价 |
|---|---|---|---|
| SQL 正确性 | 语法、方言函数、NULL 语义、隐式类型转换 |
真库(同版本) | 高 |
| 映射正确性 | 列名对不上、驼峰转换、枚举与时间类型、null 与空集合 |
真库或高保真假库 | 中 |
| 事务行为 | 传播、回滚范围、隔离级别、自增与唯一键冲突时机 | 真库 + 真事务管理器 | 高 |
| 性能特征 | 执行计划、索引命中、N+1、批量大小 | 真库 + 真实数据量 | 最高 |
判据:结论里出现"数据库会怎样",就不能用 mock 验证;mock 只验证"假设成立时我的代码会怎样"。
2. 四种载体的代价结构
| 载体 | 仿真度 | 单次成本 | 维护成本 | 失效方式 |
|---|---|---|---|---|
| 内存库(H2/HSQLDB) | 中低 | 毫秒级,进程内 | 低 | 方言差异导致假通过 |
| 容器化真库 | 高 | 秒级启动 + 每用例数据准备 | 中(镜像、端口、并行) | 并行冲突、启动超时 |
| 共享测试库 | 高 | 无启动成本 | 高(数据治理、清理、权限) | 污染、并发互相干扰、不可重复 |
| Mock | 无 | 微秒级 | 低 | 什么都验证不了 |
用法不是选一个,而是分工:日常单测走 mock 与内存库保速度,另有一条数量少但必须过真库的通道,专覆盖 SQL、映射、事务。
<!-- 真库用例单独成 profile,不被日常 build 触发 -->
<profile><id>dal-it</id><build><plugins><plugin>
<artifactId>maven-failsafe-plugin</artifactId>
<configuration><includes><include>**/*DalIT.java</include></includes></configuration>
</plugin></plugins></build></profile>
3. 方言差异:假通过是怎么发生的
内存库不是"小一号的 MySQL",是另一套引擎。以下每条都足以让测试通过、生产报错:
| 差异点 | 内存库常见行为 | 生产库行为 | 后果 |
|---|---|---|---|
| 字符串函数 | 支持 concat、部分 date_format 缺失 |
方言函数齐全 | 迁移到内存库时被改写,回生产即报错 |
| 隐式转换 | 宽松,'1' 与 1 混用可过 |
严格模式下报错或走不了索引 | 测试通过,生产报 Truncated incorrect |
| 隔离级别 | 常只实现读已提交或更弱 | 可重复读为默认 | 幻读类用例在测试里"通过" |
| 锁与超时 | 无真实行锁等待 | 有锁等待与 Lock wait timeout |
并发用例测不出死锁 |
NULL 排序 |
与方言不同 | NULL 最小/最大依方言 |
分页结果顺序不同 |
| DDL 事务性 | 常可回滚 | 隐式提交,不可回滚 | 建表语句污染后续用例 |
最隐蔽的是第三、四条:不产生错误,只产生错误的一致性感受——团队以为并发用例都过了,其实从未真正并发。
-- 这条在宽松内存库上能过,在严格模式的 MySQL 8 上会告警或截断
INSERT INTO orders (id, amount, created_at) VALUES ('abc', '12.345', '2026-11-09');
4. 容器化真库的成本
| 成本项 | 量级 | 缓解手段 | 缓解的副作用 |
|---|---|---|---|
| 首次拉镜像 | 数百 MB,分钟级 | CI 侧预拉、镜像缓存 | 缓存失效后突然变慢 |
| 每类启动 | 秒级 | 套件级复用、reuse 容器 |
复用让用例间出现隐式共享状态 |
| 每用例数据准备 | 与表数量成正比 | 模板库 + 快照恢复 | 快照与迁移脚本可能不同步 |
| 端口与并行 | 每并行一份实例 | 动态端口 | 端口泄漏、连接池上限 |
| 资源占用 | 每实例固定内存 | 限制并发份数 | 并行度不够时排队 |
被低估的是最后两项:并行份数上不去,时长就线性增长;"只慢了几分钟"最终会变成"这条通道被人为跳过"。
5. 共享测试库的成本
共享库初始成本最低、长期最高。四类失效按频率排:
| 失效 | 表现 | 根因 | 处理方向 |
|---|---|---|---|
| 数据污染 | 别人的用例让我的断言失败 | 无隔离,全局可见 | 按 schema 分租户 |
| 并发冲突 | 同一张表被两个流水线同时清空 | 共享一份数据 | 每流水线独立 schema |
| 不可重复 | 同一提交两次跑结果不同 | 残留数据与自增计数 | 每次从迁移脚本重建 |
"不可重复"最伤:它让失败失去信息量,团队开始习惯性重跑,直到真正的失败也被掩盖。
6. 事务回滚做隔离的局限
@Transactional + 回滚是最常用隔离手段,它在四类情况下失效:
| 情况 | 为什么回滚不掉 | 应对 |
|---|---|---|
| DDL 语句 | 多数库隐式提交 | DDL 用例独立库或前置检查 |
| 事务外副作用 | 缓存、消息、文件写、外部调用 | 断言副作用要显式清理 |
| 连接池状态 | 会话变量、临时表、SET 残留 |
用例结束重置会话或换连接 |
| 自增与序列 | 计数器不回滚 | 断言不依赖 ID |
| 异步写入 | 事务提交后异步线程才写 | 用例内同步等待或禁用异步 |
@Test @Transactional // 回滚只保证表数据,不保证上面四类
void createsOrder() { ... }
另一处误解:回滚也挡住了提交可见性测试。要验证"提交后另一个连接能看到"就得真提交,再用独立清理收尾——此时清理顺序比回滚更重要。
7. 数据准备与清理顺序
| 方式 | 速度 | 可读性 | 适用 |
|---|---|---|---|
| 内联 SQL 插入 | 慢 | 好 | 边界用例、少量数据 |
| 共享 fixture 脚本 | 快 | 中 | 主干用例 |
| Builder/对象工厂 | 中 | 最好 | 领域对象复杂时 |
| 生产数据脱敏子集 | 最快接近真实 | 差 | 性能与分布类验证 |
清理必须按外键逆序,或临时关闭外键检查后整表清空再重开:
SET FOREIGN_KEY_CHECKS = 0;
TRUNCATE TABLE order_item; TRUNCATE TABLE orders; TRUNCATE TABLE users;
SET FOREIGN_KEY_CHECKS = 1; -- 忘了这行,后续用例的外键约束全部失效
TRUNCATE 重置自增计数、DELETE 不重置——依赖具体 ID 的断言在两种清理下表现不同,这是"本地过、CI 挂"的常见来源。
8. 分层:SQL 正确性与业务逻辑分开测
| 层次 | 测什么 | 载体 | 用例数量级 |
|---|---|---|---|
| 仓储/DAO | SQL、映射、分页、批量 | 真库 | 少而精,一 SQL 一用例 |
| 服务层 | 分支、校验、事务边界、幂等 | mock 仓储 | 多 |
| 端到端 | 关键链路连通 | 容器化全栈 | 极少 |
把 SQL 正确性塞进服务层,每个服务用例都被真库拖慢;把业务分支塞进 DAO,真库用例就成一锅粥。分层的价值是让"慢的东西数量少"。
9. 迁移脚本的测试
迁移脚本是唯一"跑错就不可逆"的代码,值得单独一条用例:空库从头执行全部脚本,再与基线表结构比对。
@Test
void migratesFromScratch() {
flyway.clean(); // 仅在测试库
flyway.migrate(); // 全部脚本按序执行
assertThat(schemaDiff()).as("结构应与基线一致").isEmpty();
}
要测三件事:空库能否到达最新版本、旧快照能否升级、重复执行是否幂等。第三条常被漏掉,而它正是灰度时"两个实例同时启动"的真实场景。
10. 性能类断言为什么不进单测
| 断言类型 | 为什么不可靠 | 替代 |
|---|---|---|
| 单次耗时 < N ms | 受 CI 机器负载、冷缓存、JIT 影响,方差远大于差异 | 单独的性能门禁或基准 |
| 查询次数 == K | 有价值,但要用计数器而非耗时 | 语句计数器(如 p6spy) |
| 执行计划走索引 | 数据量不够时优化器必然选全表 | 造足数据后 EXPLAIN 人工核对 |
| 内存占用 | 与堆参数耦合 | 压测环境 |
唯一适合进单测的是结构性断言:"这次调用只发一条 SQL"、"批量插入切成 N 批"。它们确定、可重复,重构时能真正抓住 N+1。
11. 固定数据集还是随机化
| 方式 | 优点 | 风险 | 适用 |
|---|---|---|---|
| 固定数据集 | 断言可写死、易读 | 掩盖边界、易与实现耦合 | 映射与 SQL 正确性 |
| 随机化数据 | 能撞出边界与编码问题 | 失败不可复现 | 并发与数据类型边界 |
| 固定种子随机 | 两者兼顾 | 需要记录种子 | 推荐默认 |
随机化必须记录种子并在失败信息里打印,否则一次红之后再复现不了,团队会归为"不稳定用例"直接忽略。
12. CI 中的编排
services: { mysql: { image: "mysql:8.4" } }
steps:
- run: docker pull mysql:8.4 || true # 镜像缓存失败不阻塞
- run: ./gradlew dalIT --tests '*DalIT' # 真库用例单列,不混进 unitTest
三条判据:慢通道单独命名、失败不自动无限重试(重试掩盖污染)、缓存镜像但不缓存数据库状态。
13. 排错
| 现象 | 原因 | 处理 |
|---|---|---|
| 本地绿、CI 红 | 时区、字符集、sql_mode、版本不同 |
三者写进环境变量,版本钉死 |
| 用例顺序一变就挂 | 依赖前一用例的数据 | 每用例自备数据或整库重建 |
| 并行下偶发唯一键冲突 | 共享了自增序列或固定 ID | ID 由数据生成器分配 |
| 事务用例测不出并发 | 回滚让两用例从未真并发 | 改真提交 + 显式清理 |
| 迁移测试过但上线失败 | 只测空库路径 | 补旧快照升级用例 |
14. 边界:可以只做冒烟验证的情况
| 情境 | 更轻的做法 | 理由 |
|---|---|---|
| 纯 CRUD、无复杂查询 | 冒烟 + 一条真库用例 | 方言风险集中在复杂 SQL |
| 已用 ORM 且 SQL 由框架生成 | 映射用例 + 迁移测试 | 框架 SQL 已被广泛验证 |
| 数据可由上游重新同步 | 不测恢复,只测同步 | 测试成本高于重建成本 |
15. 自测
- mock 的 DAO 单测全绿,它到底证明了什么、没证明什么?
- 内存库通过、生产报错,最先核对的三个差异点是什么?
@Transactional回滚为什么挡不住 DDL 与连接池状态污染?- 为什么"查询次数"可以进单测,而"单次耗时"不行?
- 迁移脚本除了空库路径,还必须测哪两条路径?
- 随机化数据要满足什么条件才不至于变成"不稳定用例"?