一段业务逻辑有 300 行,测试只覆盖了 40%。剩下的部分全在 DAO 里:动态 SQL 拼条件、分页、批量更新。测试这些需要数据库,而团队没有可用的测试库,于是"DAO 不测"成了默认。
DAO 难测不是因为它复杂,而是因为它的行为由 SQL 与数据库共同决定——而这两者都不在单元测试的范围内。这个拆分还有个好处:它让"SQL 写错了"与"数据库行为不同"变成两类可区分的问题。前者能用快速测试拦住,后者才需要容器与集成环境。
要提高可测性,思路不是"写更多测试",而是"把可测的部分从不可测的部分里分出来"。
1. 三层可测性
| 层 | 可测性 | 依赖 |
|---|---|---|
| SQL 文本生成 | 高 | 无(纯字符串/参数) |
| 参数绑定与类型 | 中 | 无(可断言) |
| 执行结果与数据库行为 | 低 | 需要真实数据库 |
第一层最被忽略:**动态 SQL(<if>、<where>)本质是"根据输入拼字符串"**第二层(参数绑定)也有可测点:断言"参数个数与占位符个数一致" 能抓住最常见的低级错误——改了 SQL 忘了改参数,运行时才报索引越界。
,完全可以不连数据库就测——只要能让它输出 SQL 文本并断言。
第三项的判据是"它会不会因为数据库版本变化而失败":会,说明它测的是数据库,不是你的逻辑——这类测试的价值在于"发现升级风险",而不是"保证代码正确"。
第三层才需要数据库,而它测的其实是"数据库的行为",不是"你的代码"。
// 可测的第一层:断言生成的 SQL 与参数
@Test void 只有状态条件时() {
var ctx = new SqlContext();
ctx.put("status", "PAID");
String sql = sqlSession.selectOne("OrderMapper.countByCondition", ctx) ...
// 更实用:用 MyBatis 的 BoundSql 直接取
assertThat(boundSql(ctx).getSql()).contains("WHERE status = ?");
assertThat(boundSql(ctx).getParameterMappings()).hasSize(1);
}
2. 把 SQL 文本测试做起来
| 手段 | 说明 |
|---|---|
BoundSql 断言 |
取生成后的 SQL 与参数 |
| SQL 快照 | 首次运行记录,之后比对 |
| 关键字断言 | 断言含/不含某片段 |
| 参数数量断言 | 防"多一个占位符" |
第二项最省力:把生成的 SQL 存成快照文件,后续改动会显示 diff。第四项(参数数量)有个意外收益:它能在重构时发现"某个条件被顺手删了"——因为参数从 2 个变成 1 个,测试会失败,而人眼读 diff 常常看不出。
它不需要人工写期望值,且能立刻看出"少了一个条件"这类回归。
第三项用于关键约束:断言"必须包含 AND deleted = 0",防止软删除条件被误删。
// SQL 快照:改动的 diff 直接进评审
@Test void sql_snapshot() {
String sql = boundSql(Map.of("status", "PAID", "from", "2026-11-01")).getSql();
assertThat(sql).isEqualTo(readResource("sql/order_count.sql.snap"));
}
3. 需要数据库的部分怎么测
| 方式 | 适用 |
|---|---|
| 真实数据库(容器化) | 最可靠 |
| 内存数据库(H2/SQLite) | 快,但语义不同 |
| 只测关键查询 | 折中 |
| 不测(靠集成环境) | 有风险 |
第三项(只测关键查询)是现实中的主流折中:挑 3–5 条最贵的查询做集成测试,其余靠 SQL 文本测试兜住。这个组合覆盖了大部分风险,而成本可控。
第二项的问题是语义差异:分页语法、函数、锁行为、默认隔离级别都可能不同。用 H2 测通过的 SQL 在 MySQL 上可能慢十倍或直接报错。
所以稳妥做法是容器化的真实数据库(Testcontainers 类工具):启动成本几秒,语义与生产一致。
// 集成测试:真实数据库,容器内启动
testcontainers:
image: mysql:8.0.34
init: schema.sql
reuse: true # 复用容器,避免每次都启动
// 代价:首次拉镜像;收益:语义与生产一致,分页/锁/函数都可测
4. 不可测的信号
| 信号 | 含义 |
|---|---|
| 一个方法 200 行 | 混合了太多逻辑 |
| SQL 里带业务分支 | 逻辑位置错 |
| 测试必须连生产库 | 环境设计有问题 |
| 只能靠"跑一遍看看" | 缺断言点 |
第一项与第二项是同一件事:SQL 里的业务分支越多,越难测。第四项(只能靠"跑一遍看看")是最坏的信号:它意味着没有断言点,于是任何改动都无法判断"是否等价"。这种代码往往在一次"小改动"后悄悄改变了行为。
把分支搬到代码里,SQL 就退回"可测的文本",而代码部分可用纯单测覆盖。
5. 让 DAO 可测的四个重构
| 重构 | 效果 |
|---|---|
| 条件组装抽成方法 | 可单测 |
| 结果映射改成显式别名 | 列名错立刻报错 |
| 分页参数封装 | 边界可测 |
| 批量操作拆批次 | 可注入小批量测试 |
第四项(批量拆批次)的测试价值在于边界:批次大小 1、恰好整除、余一行 这三种情况最容易出错,而它们用真实数据库可以很快验证。
第二项的价值被低估:SELECT * 加位置映射时,加一列就会错位;改成显式别名(AS order_id)后,列名不符会在启动或首次查询时报错,而不是静默错位。
-- 反例:位置映射,加一列就错位
SELECT * FROM orders WHERE id = ?
-- 正例:显式别名,列名不符立刻报错
SELECT id AS orderId, amount AS amount, created_at AS createdAt FROM orders WHERE id = ?
还有一层"数据"层面的可测性:测试数据的构造方式决定了测试是否稳定。用"每次插入一批随机数据"的测试会很脆(顺序、ID 不可控),而用"固定三行、固定 ID"的测试可以断言精确结果。
-- 稳定的测试数据:固定 ID,明确定义期望
INSERT INTO orders(id, status, amount, deleted) VALUES
(1, 'PAID', 100.00, 0),
(2, 'PAID', 200.00, 0),
(3, 'CLOSED', 300.00, 1); -- 软删除行,用于验证过滤条件
-- 期望:查询 status=PAID 且 deleted=0 → 2 条
## 6. 排错:五种"DAO 测试写不下去"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 测试必须连生产 | 无测试库 | 容器化数据库 |
| H2 通过、MySQL 报错 | 语义差异 | 换真实数据库 |
| 断言很难写 | 只断言结果不断言 SQL | 加 BoundSql 断言 |
| 每次跑很慢 | 未复用容器 | 容器复用 |
| 改动后测试全绿但线上错 | 只测了 SQL 文本没测数据 | 加关键查询的集成测试 |
第五项要分清:**SQL 文本测试防"条件写错",集成测试防"语义不对"**,两者不能互相替代。第一项(必须连生产)不是"测试问题"而是"环境问题":**没有可用的测试数据库,所有 DAO 测试都会退化成手工验证**。所以这一步的投入应当先做,否则后面写多少测试都跑不起来。
最后一个实践点:**DAO 测试的执行时间应当可控**。若一个 DAO 测试套件要跑五分钟,它就会被人跳过——于是"有测试"等于"没有测试"。所以划分要清楚:SQL 文本测试进主测试套件(秒级),集成测试单独一组(分钟级,CI 上跑)。
```text
// 分组策略(按执行时间与依赖)
fast(< 10s,无容器):SQL 文本、映射、参数绑定
slow(< 3min,容器):关键查询的集成测试
// CI:每次 PR 跑 fast;每夜跑 slow
// 本地:默认只跑 fast,需要时手动跑 slow
7. 边界:什么情况不必测 DAO
| 场景 | 为什么 | 做法 |
|---|---|---|
| 极简单的单表 CRUD | 框架已保证 | 不测 |
| 一次性数据修复脚本 | 不长期 | 不测 |
| 只读报表查询 | 结果易核对 | 手工验证 |
| 已用成熟的 ORM 且无动态 SQL | 风险低 | 不测 |
最后一条与"成本"有关的边界:测试 SQL 的性价比高于测试数据库。因为前者秒级、稳定、无环境依赖;而后者慢且易环境故障。所以顺序应当是"先把 SQL 文本测试铺开,再挑关键查询做集成测试"。
判断口径一句话:这段 SQL 会不会因为"条件写错"而静默返回错的数据。会(动态条件、软删除、权限过滤),就必须测;不会(点查单条),不测也行。
8. 自测
- DAO 难测的根因是什么?为什么"多写测试"不是正确思路?
- 三层可测性里,哪一层最被忽略?动态 SQL 为什么可以完全不连数据库就测?
- SQL 快照测试为什么"不需要人工写期望值"?它能抓住哪类回归?
- 为什么用 H2 测过的 SQL 在 MySQL 上可能出问题?稳妥做法是什么?
- "显式别名"为什么能防止列名错位?
SELECT *加位置映射的风险是什么? - 判断"是否需要测 DAO"的那个问题是什么?