因为sql.db是不可导出字段的高度封装结构体,无法直接赋值mock实例;正确做法是通过sqlmock.new()创建符合database/sql语义的fake sql.db并注入业务代码。

为什么直接 mock *sql.DB 会失败?
Go 标准库的 *sql.DB 是一个高度封装的结构体,它不导出内部字段,也没有实现可被接口替换的“行为抽象”。你不能直接对 *sql.DB 做类型断言或赋值 mock 实例——编译会报错:cannot assign *mockDB to db (type *sql.DB)。真正该 mock 的是它的行为载体:数据库操作所依赖的接口,比如 sql.Queryer、sql.Execer,但更推荐的方式是定义自己的仓储接口(repository interface),然后只 mock 那个接口。
用接口隔离 + sqlmock 模拟查询和事务
sqlmock 是目前最主流的方案,但它不是 mock *sql.DB 本身,而是 mock 它背后的 driver.Conn 行为,并通过 sqlmock.New() 返回一个符合 database/sql 语义的 fake *sql.DB。关键点在于:你必须把业务逻辑从直接调用 db.Query 解耦到接口方法中,再让测试传入 sqlmock 创建的 DB 实例。
- 定义仓储接口,例如:
type UserRepo interface { GetUserByID(id int) (*User, error) } - 实现时接收
*sql.DB作为依赖,内部调用db.QueryRow等 - 测试中用
sqlmock.New()创建 mock DB,再构造 mock 实现(或直接注入到被测对象) - 预设期望:
mock.ExpectQuery("SELECT id FROM users").WithArgs(123).WillReturnRows(rows) - 执行业务逻辑后,务必调用
mock.AssertExpectations(t),否则未触发的期望不会报错
sqlmock 对事务的支持很脆弱
事务在 sqlmock 中需要显式开启并跟踪状态。如果你的代码里用了 tx, _ := db.Begin(),那么 mock 必须用 mock.ExpectBegin();后续所有查询/执行都得在 tx 上调用,且需对应 ExpectQuery 或 ExpectExec。稍有不匹配(比如忘了 ExpectCommit() 或用了 db.Query 而非 tx.Query),就会 panic 或静默失败。
- 避免在测试中混合使用
db和tx—— 统一用事务对象操作 -
mock.ExpectCommit().WillReturnError(nil)是常见写法,但若业务中可能回滚,就得补上ExpectRollback() - 注意:事务内嵌套事务(如
Savepoint)不被sqlmock支持,遇到就只能绕过或改用内存 SQLite
什么时候该换用 sqlite 内存模式?
当你的 SQL 太动态(比如拼接列名、复杂 CTE)、涉及触发器、或需要真实约束校验(NOT NULL、外键)时,sqlmock 会越来越难维护。这时用 sqlite://:memory: 是更务实的选择——它启动快、无依赖、支持绝大多数 SQL92 语法。
- 初始化:
sql.Open("sqlite3", ":memory:"),然后执行CREATE TABLE和INSERT初始化数据 - 每个测试用独立 DB 实例(或
db.Exec("DELETE FROM ...")清理),避免干扰 - 注意驱动注册:确保已导入
_ "github.com/mattn/go-sqlite3" - 性能上比
sqlmock略慢,但可读性和稳定性高很多,尤其适合集成风格的单元测试
接口抽象这一步没法跳过——哪怕用 sqlite,你也得通过接口调用,否则测试时无法替换数据源。mock 的本质不是伪造对象,而是控制依赖的输入输出边界;没这层隔离,任何“mock”都会在重构时最先崩塌。











