2471 字
约 8 分钟
13
S08-A16 · DAO 为什么难测:SQL 的可测试性来自哪里

一段业务逻辑有 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. 自测

  1. DAO 难测的根因是什么?为什么"多写测试"不是正确思路?
  2. 三层可测性里,哪一层最被忽略?动态 SQL 为什么可以完全不连数据库就测?
  3. SQL 快照测试为什么"不需要人工写期望值"?它能抓住哪类回归?
  4. 为什么用 H2 测过的 SQL 在 MySQL 上可能出问题?稳妥做法是什么?
  5. "显式别名"为什么能防止列名错位?SELECT * 加位置映射的风险是什么?
  6. 判断"是否需要测 DAO"的那个问题是什么?
S08-A16 · DAO 为什么难测:SQL 的可测试性来自哪里
https://www.clxhxhhr.top/posts/6497/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。