
本文介绍一种轻量、符合 Go 习惯的测试策略:通过定义精简接口(如 Scanner)解耦 parseUsers 等扫描逻辑与具体 *sql.Rows 实现,从而避免引入 heavy mock 库,实现可读性强、零外部依赖的单元测试。
本文介绍一种轻量、符合 go 习惯的测试策略:通过定义精简接口(如 `scanner`)解耦 `parseusers` 等扫描逻辑与具体 `*sql.rows` 实现,从而避免引入 heavy mock 库,实现可读性强、零外部依赖的单元测试。
在 Go 的数据库开发中,直接操作 *sql.Rows 并调用 Scan 解析结果是常见模式。但这类函数(如 parseUsers)若紧耦合 *sql.Rows 类型,会导致单元测试困难——因为 sql.Rows 是未导出字段封装的结构体,无法直接实例化或安全 Mock。
核心思想:面向接口编程,而非具体类型。
与其让函数接收 *sql.Rows,不如抽象出它真正依赖的行为:Next() 判断是否有下一行,以及 Scan(...interface{}) error 执行值绑定。我们只需定义一个最小接口:
type Scanner interface {
Next() bool
Scan(dest ...interface{}) error
}注意:这里使用标准 sql.Scanner 的 Scan 签名(...interface{}),而非自定义结构体参数,以保持与 *sql.Rows 完全兼容,并支持任意字段组合。
改造后的 parseUsers 函数如下:
func parseUsers(s Scanner) ([]User, error) {
var users []User
for s.Next() {
var u User
if err := s.Scan(&u.Username, &u.DisplayName, &u.EmailAddress, &u.IsEnabled, &u.PhoneNumber); err != nil {
return nil, err
}
users = append(users, u)
}
return users, s.Err() // 注意:sql.Rows.Err() 需额外暴露,见下文处理
}⚠️ 关键细节:*sql.Rows 提供 Err() 方法用于检查迭代结束后的潜在错误(如查询执行失败),但我们的 Scanner 接口未包含它。解决方案有两种:
-
推荐:将
Err()也纳入接口(Scanner interface { Next() bool; Scan(...); Err() error }),并在 mock 中可控返回; -
更轻量:保持当前接口,由调用方(如 DAO 层)负责检查
*sql.Rows.Err(),而parseUsers仅专注扫描逻辑——这更符合单一职责原则。
下面是一个简洁、无依赖的测试 Mock 实现:
type mockScanner struct {
nextCalls int
nextRet []bool // 控制 Next() 返回序列,如 [true, true, false]
scanFunc func(...interface{}) error
errRet error
}
func (m *mockScanner) Next() bool {
if m.nextCalls >= len(m.nextRet) {
return false
}
ret := m.nextRet[m.nextCalls]
m.nextCalls++
return ret
}
func (m *mockScanner) Scan(dest ...interface{}) error {
if m.scanFunc != nil {
return m.scanFunc(dest...)
}
// 默认行为:模拟成功填充前几个字段(需确保 dest 长度匹配)
if len(dest) >= 5 {
*dest[0].(*string) = "alice"
*dest[1].(*string) = "Alice Smith"
*dest[2].(*string) = "alice@example.com"
*dest[3].(*bool) = true
*dest[4].(*string) = "+1234567890"
}
return nil
}
func (m *mockScanner) Err() error { return m.errRet }测试用例示例如下:
func TestParseUsers(t *testing.T) {
tests := []struct {
name string
scanner Scanner
wantLen int
wantErr bool
}{
{
name: "two valid rows",
scanner: &mockScanner{
nextRet: []bool{true, true, false},
},
wantLen: 2,
},
{
name: "scan error on second row",
scanner: &mockScanner{
nextRet: []bool{true, true, false},
scanFunc: func(dest ...interface{}) error {
if len(dest) > 0 && len(dest[0].(*string)) == 0 {
return errors.New("scan failed")
}
return nil
},
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseUsers(tt.scanner)
if (err != nil) != tt.wantErr {
t.Errorf("parseUsers() error = %v, wantErr %v", err, tt.wantErr)
return
}
if len(got) != tt.wantLen {
t.Errorf("parseUsers() len = %d, want %d", len(got), tt.wantLen)
}
})
}
}✅ 优势总结:
-
零第三方依赖:不引入
sqlmock或sqlx,纯标准库 + 接口抽象; -
高内聚低耦合:
parseUsers只关心“能否取下一行”和“能否扫描”,与数据库驱动完全解耦; - 易测易维护:Mock 行为清晰可控,覆盖边界场景(空结果、扫描失败、多次调用)成本极低;
- 符合 Go 习惯:遵循 “Accept interfaces, return structs” 原则,是地道的 Go 设计实践。
最后提醒:该模式不仅适用于 sql.Rows,还可推广至 io.Reader、http.ResponseWriter 等所有具备明确行为契约的类型——抽象接口,是 Go 测试友好性的基石。


















