Golang如何在测试中使用内存数据库

测试不用真实数据库而选内存数据库,因其启动快、无外部依赖、状态易重置,保障测试快速、稳定、可并行;sqlite的:memory:模式最常用,需每个测试用独立*sql.DB实例防污染。

为什么测试中不用真实数据库而选内存数据库

真实数据库启动慢、依赖外部服务、状态难重置,会导致测试变慢、不稳定、难以并行。内存数据库(如 sqlite:memory: 模式、bbolt 内存模式、或纯内存实现的 go-sqlmock + sqlmock 驱动)能绕过 I/O 和网络,让单元测试真正“快”和“隔离”。

sqlite:memory: 模式做真实 SQL 测试

这是最常用也最贴近生产环境的做法:用真实 SQL 驱动跑在内存里,既验证 SQL 逻辑,又避免磁盘/连接开销。关键点是每个测试必须用独立的 *sql.DB 实例,否则事务和表结构会互相污染。

  • sqlite3.Open("file::memory:?cache=shared") 是基础写法,但注意 cache=shared 可让多个 *sql.DB 共享同一内存数据库(仅限单 goroutine 场景)
  • 更安全的做法是每个测试用独立 :memory: 实例,并在测试开始时执行建表语句(例如用 db.Exec("CREATE TABLE users(...)")
  • 不要复用全局 *sql.DB,否则 TestA 创建的表可能被 TestB 误读——Go 的 testing.T.Parallel() 下尤其危险
func TestUserCreate(t *testing.T) {
	db, err := sql.Open("sqlite3", "file::memory:")
	if err != nil {
		t.Fatal(err)
	}
	defer db.Close()

	_, err = db.Exec(`CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)`)
	if err != nil {
		t.Fatal(err)
	}

	// 真实业务逻辑调用
	err = CreateUser(db, "alice")
	if err != nil {
		t.Fatal(err)
	}

	var count int
	err = db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count)
	if err != nil || count != 1 {
		t.Errorf("expected 1 user, got %d", count)
	}
}

sqlmock 模拟数据库行为(不执行真实 SQL)

适合验证 DAO 层是否发出了预期 SQL,但不关心 SQL 是否真能运行。它不连接任何数据库,纯 mock,因此无法捕获语法错误或约束冲突。

  • 必须用 sqlmock.New() 创建 *sql.DB,且不能传给 sql.Open
  • 每条期望 SQL 都要显式调用 mock.ExpectQuery()mock.ExpectExec(),否则测试会 panic
  • 调用 mock.ExpectationsWereMet() 必须放在 defer 或结尾,否则未触发的期望不会报错
func TestUserCreateWithMock(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatal(err)
	}
	defer db.Close()

	mock.ExpectExec(`INSERT INTO users`).WithArgs("alice").WillReturnResult(sqlmock.NewResult(1, 1))

	err = CreateUser(db, "alice")
	if err != nil {
		t.Fatal(err)
	}

	if err := mock.ExpectationsWereMet(); err != nil {
		t.Error(err)
	}
}

常见踩坑:事务没回滚、连接池干扰、驱动注册遗漏

内存数据库不是“自动干净”的魔法盒。很多问题源于 Go 的 database/sql 默认行为和 SQLite 驱动细节。

  • SQLite 的 :memory: 数据库在 *sql.DB 关闭后即销毁,但若代码中用了 db.Begin() 却没 tx.Commit()tx.Rollback(),事务会一直挂起,导致后续操作卡住或报 database is locked
  • sql.Open 不建立连接,首次 db.Query 才真正初始化;如果测试中只 sql.Open 但没执行任何语句,db 实际未生效,容易误判“测试通过”
  • 忘记 import _ "github.com/mattn/go-sqlite3" 会导致 sql.Open("sqlite3", ...) 报错 sql: unknown driver "sqlite3"
  • 使用 github.com/glebarez/sqlite(纯 Go 实现)替代 cgo 驱动时,路径协议要写成 sqlite://:memory:,且不支持 cache=shared

最易忽略的是:SQLite 的 :memory: 数据库默认是“连接级私有”,哪怕你用同一个 DSN,两个 sql.Open 返回的 *sql.DB 看不到彼此的表——这不是 bug,是设计。需要共享就得显式加 cache=shared,但要注意它不适用于并发测试。