
本文介绍一种基于接口抽象的轻量级测试策略,无需引入大型 mock 库,即可高效单元测试 sql.Rows 扫描逻辑,保持对标准库 database/sql 的完全兼容。
本文介绍一种基于接口抽象的轻量级测试策略,无需引入大型 mock 库,即可高效单元测试 `sql.rows` 扫描逻辑,保持对标准库 `database/sql` 的完全兼容。
在 Go 开发中,直接依赖 *sql.Rows 会导致测试困难——因为它是一个具体类型,且内部状态(如游标位置、错误状态)难以可控模拟。硬编码依赖 *sql.Rows 会将测试与数据库驱动细节耦合,违背“依赖抽象而非实现”的设计原则。
核心思路:面向接口编程,解耦扫描行为
与其让 parseUsers 接收 *sql.Rows,不如定义一个最小化接口,仅封装 Next() 和 Scan() 两个关键行为:
type RowScanner interface {
Next() bool
Scan(dest ...any) error
}
注意:Scan 方法签名需与 sql.Rows.Scan 一致(接收 ...any),以确保兼容性;Next() 返回 bool(而非 error)更符合实际语义(sql.Rows.Next() 返回 bool,错误需通过 Err() 获取)。
重构后的函数如下:
func ParseUsers(s RowScanner) ([]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)
}
if err := s.Err(); err != nil {
return nil, err
}
return users, nil
}
编写可测试的 Mock Scanner
测试时,只需实现 RowScanner 接口,控制 Next() 的返回序列和 Scan() 的行为即可:
type mockScanner struct {
nextCalls int
nextValues []bool
scanFunc func(...any) error
err error
}
func (m *mockScanner) Next() bool {
if m.nextCalls <p><strong>完整测试示例</strong></p><pre class="brush:php;toolbar:false;">func TestParseUsers(t *testing.T) {
tests := []struct {
name string
scanner RowScanner
wantLen int
wantErr bool
}{
{
name: "two valid rows",
scanner: &mockScanner{
nextValues: []bool{true, true, false},
scanFunc: func(dest ...any) error {
// 模拟第一行扫描
if len(dest) == 5 && *(dest[0].(*string)) == "" {
*(dest[0].(*string)) = "alice"
*(dest[1].(*string)) = "Alice Smith"
*(dest[2].(*string)) = "alice@example.com"
*(dest[3].(*bool)) = true
*(dest[4].(*string)) = "+123"
return nil
}
// 模拟第二行扫描
*(dest[0].(*string)) = "bob"
*(dest[1].(*string)) = "Bob Jones"
*(dest[2].(*string)) = "bob@example.com"
*(dest[3].(*bool)) = false
*(dest[4].(*string)) = "+456"
return nil
},
},
wantLen: 2,
},
{
name: "scan error on second row",
scanner: &mockScanner{
nextValues: []bool{true, true, false},
scanFunc: func(dest ...any) error {
if len(dest) == 5 && *(dest[0].(*string)) == "" {
return nil // first row OK
}
return errors.New("scan failed")
},
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseUsers(tt.scanner)
if tt.wantErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Len(t, got, tt.wantLen)
})
}
}注意事项与最佳实践
- ✅ 最小接口原则:
RowScanner仅暴露Next()、Scan()和Err()(若需检查扫描后错误),避免过度设计; - ✅ 保持标准库兼容性:
*sql.Rows天然满足该接口,无需修改现有调用代码(只需类型转换:ParseUsers(rows)→ParseUsers(rows),因*sql.Rows实现了RowScanner); - ⚠️ 避免误用
sql.Rows的非公开字段:切勿通过反射或包内访问绕过接口,破坏封装性; - ? 进阶建议:对于复杂场景(如动态列、NULL 处理),可扩展接口增加
Columns()或使用泛型封装扫描器,但本方案已覆盖 90% 的 CRUD 解析需求。
该方法零外部依赖、无构建开销、语义清晰,是 Go 生态中测试数据库扫描逻辑的推荐实践。











