3096 字
约 10 分钟
21
S08-A09 · 数据访问层的测试策略:真库、假库与容器

单测全绿、覆盖率漂亮,上线第一条真实请求就报 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. 自测

  1. mock 的 DAO 单测全绿,它到底证明了什么、没证明什么?
  2. 内存库通过、生产报错,最先核对的三个差异点是什么?
  3. @Transactional 回滚为什么挡不住 DDL 与连接池状态污染?
  4. 为什么"查询次数"可以进单测,而"单次耗时"不行?
  5. 迁移脚本除了空库路径,还必须测哪两条路径?
  6. 随机化数据要满足什么条件才不至于变成"不稳定用例"?
S08-A09 · 数据访问层的测试策略:真库、假库与容器
https://www.clxhxhhr.top/posts/6245/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。