Go 企业级工程能力实战(4):用 Mock + 接口写出 50 毫秒级单元测试

一、开篇引入:一个让我重构了三个月的测试教训

我曾经在一个项目里写过这样的"单元测试":

go 复制代码
func TestGetUser(t *testing.T) {
    db, _ := sql.Open("mysql", "root:123456@tcp(127.0.0.1:3306)/test")
    defer db.Close()

    // 先插入一条数据
    db.Exec("INSERT INTO user (id, name) VALUES (1, 'bobby')")

    // 调用被测函数
    user, err := GetUserFromDB(db, 1)

    // 断言
    assert.NoError(t, err)
    assert.Equal(t, "bobby", user.Name)

    // 清理
    db.Exec("DELETE FROM user WHERE id = 1")
}

这个测试跑一次需要 800 毫秒------大部分时间花在等待 MySQL 的 TCP 握手和磁盘 IO 上。项目有 200 个测试用例,跑一次需要 3 分钟。CI 流水线每天因为这个等待多花了 2 个小时的机器时间。

更糟糕的是:这些测试不稳定 。数据库偶尔慢一点,测试就超时失败。这就是所谓的 flaky test(不稳定测试),它对团队士气的打击比没有测试还大。

真正的单元测试应该有多快? 答案是:50 毫秒以内。不需要数据库、不需要网络、不需要文件系统。


二、概念铺垫:什么是单元测试?什么是 Mock?

2.1 单元测试的"单元"

单元测试(Unit Test)测试的是最小可测试单元。在 Go 中,这个"单元"通常是一个函数或一个方法。

单元测试 ≠ 集成测试

单元测试 集成测试
测试范围 单个函数/方法 多个组件协作
外部依赖 全部 Mock 真实数据库/Redis/HTTP
执行速度 < 50ms > 500ms
运行频率 每次保存都跑 CI 流水线触发
目的 验证逻辑正确性 验证组件集成正常

2.2 什么是 Mock?

Mock 是一个"替身"。就像电影里的特技替身一样:

  • 真实的 UserDao:需要 MySQL 连接、网络、磁盘 IO
  • MockUserDao:用一个内存 map 代替 MySQL 表,没有网络和 IO

生活类比:驾照考试不让你开真车上路(风险太大),而是用模拟器。Mock 就是你的测试模拟器。

2.3 Go 测试的核心工具

本文涉及的核心测试工具:

工具 作用
gin.CreateTestContext(w) 创建一个模拟的 Gin 上下文,不需真实 HTTP 请求
httptest.NewRecorder() 模拟 HTTP 响应写入器,捕获响应结果
httptest.NewRequest() 创建一个模拟的 HTTP 请求
Mock 结构体 替代真实 DAO/Logger 等依赖

三、循序渐进:从集成测试到真正的单元测试

阶段 1:需要真实数据库的"单元测试"(反模式)

go 复制代码
func TestGetUser(t *testing.T) {
    db := connectToRealMySQL()  // 需要 MySQL 运行
    dao := dao.NewUserDao(db, realLogger)
    user, err := dao.FindUser(context.Background(), 1)
    assert.NoError(t, err)
}

问题:慢、不稳定、需要环境准备、不适合 CI。

阶段 2:Mock 外部依赖(正确做法)

go 复制代码
func TestGetUser(t *testing.T) {
    mockDao := NewMockUserDao()          // 内存 mock
    mockDao.Users[1] = &model.User{...}  // 预设数据
    svc := service.NewService(mockLogger, mockDao, ...)
    // 测试 Service 的 GetUser 方法
}

优点:毫秒级、稳定、不需要任何环境、CI 友好。

阶段 3:完整测试套件(本项目实践)

覆盖 5 种必须的测试场景:

  1. 正常路径(Happy Path)
  2. 缺失参数(Missing Parameter)
  3. 无效参数(Invalid Parameter)
  4. 资源不存在(Not Found)
  5. DAO 层错误(DAO Error)

四、代码实战:测试框架完整解析

4.1 Mock 实现:替代一切外部依赖

项目在 test/mock_test.go 中实现了所有需要的 Mock。

MockLogger ------ test/mock_test.go:21-32

go 复制代码
type MockLogger struct{}

func (m *MockLogger) Info(args ...interface{})                  {}
func (m *MockLogger) Infof(format string, args ...interface{})  {}
func (m *MockLogger) Error(args ...interface{})                 {}
func (m *MockLogger) Errorf(format string, args ...interface{}) {}
func (m *MockLogger) Debug(args ...interface{})                 {}
func (m *MockLogger) Debugf(format string, args ...interface{}) {}
func (m *MockLogger) Warn(args ...interface{})                  {}
func (m *MockLogger) Warnf(format string, args ...interface{})  {}

var _ logger.Logger = (*MockLogger)(nil)

设计要点

  1. 所有方法都是空实现------测试中不需要验证日志输出(那是集成测试的事)
  2. var _ logger.Logger = (*MockLogger)(nil) ------ 编译期接口检查

MockUserDao ------ test/mock_test.go:34-144

go 复制代码
type MockUserDao struct {
    Users  map[int]*model.User  // 用内存 map 模拟数据库表
    nextID int                   // 自增 ID

    // 错误注入字段:测试时可以设置这些字段来模拟各种错误
    CreateUserErr  error
    FindUserErr    error
    FindByNameErr  error
    FindByEmailErr error
    DeleteUserErr  error
    UpdateUserErr  error
}

MockUserDao 的巧妙之处------错误注入:

go 复制代码
func (m *MockUserDao) FindUser(ctx context.Context, id int) (*model.User, error) {
    if m.FindUserErr != nil {    // 如果设置了错误,直接返回错误
        return nil, m.FindUserErr
    }
    user, ok := m.Users[id]      // 否则从内存中查找
    if !ok {
        return nil, errors.New("record not found")
    }
    return user, nil
}

这种设计的优势是:你可以在测试中任意控制 Mock 的行为。要模拟"数据库宕机",只需要:

go 复制代码
userDao.FindUserErr = errors.New("connection refused")

同理,test/mock_test.go 中还有 MockFriendsDao(146 行)、MockFriendRequestDao(268 行)、MockBlacklistDao(408 行)、MockPasswordResetDao(485 行)。它们都遵循相同的模式:内存存储 + 错误注入。

4.2 工厂函数:newTestService()

test/GetUser_test.go:21-32,项目定义了一个工厂函数来创建测试用的 Service:

go 复制代码
func newTestService() (*service.Service, *MockUserDao, *MockFriendsDao, *MockFriendRequestDao, *MockBlacklistDao, *MockPasswordResetDao) {
    log := &MockLogger{}
    userDao := NewMockUserDao()
    friendsDao := NewMockFriendsDao()
    friendReqDao := NewMockFriendRequestDao()
    blacklistDao := NewMockBlacklistDao()
    passwordResetDao := NewMockPasswordResetDao()
    mailerInst := &mailer.DevMailer{}
    rl := ratelimit.NewMemoryLimiter(10, time.Minute)
    svc := service.NewService(log, userDao, friendsDao, friendReqDao, blacklistDao, passwordResetDao, mailerInst, rl)
    return svc, userDao, friendsDao, friendReqDao, blacklistDao, passwordResetDao
}

注意返回值的设计 :它不仅返回 *service.Service,还返回所有 Mock DAO 的指针。这样测试用例可以:

  1. 在创建 Service 之前预设 Mock 数据(userDao.Users[1] = ...
  2. 在创建 Service 之后注入错误(friendsDao.AddFriendErr = errors.New("db error")
  3. 在测试完成后检查 Mock 的状态(验证 friendsDao.Friends 的内容)

还有一处细节ratelimit.NewMemoryLimiter(10, time.Minute) 使用了内存限流器而非 Redis------测试环境中不需要 Redis。

4.3 测试用例全集:5 种必测场景

以下是 test/GetUser_test.go 中的全部测试用例。

场景 1:标准成功路径(Happy Path)------ TestGetUser_Success

test/GetUser_test.go:34-56

go 复制代码
func TestGetUser_Success(t *testing.T) {
    svc, userDao, _, _, _, _ := newTestService()
    userDao.Users[1] = &model.User{Id: 1, Name: "bobby", Address: "shenzhen"}

    w := httptest.NewRecorder()
    c, _ := gin.CreateTestContext(w)
    c.Request = httptest.NewRequest("GET", "/user/1", nil)
    c.Params = gin.Params{{Key: "uid", Value: "1"}}
    authContextSet(c, 1)

    svc.GetUser(c)

    var resp map[string]interface{}
    json.Unmarshal(w.Body.Bytes(), &resp)
    code := int(resp["code"].(float64))
    if code != 0 {
        t.Fatalf("expected code=0, got %d: %s", code, resp["msg"])
    }
    data := resp["data"].(map[string]interface{})
    if int(data["id"].(float64)) != 1 || data["name"] != "bobby" {
        t.Errorf("got %+v, want id=1 name=bobby", data)
    }
}

逐步解析

  1. 第 21 行newTestService() 创建 Service(所有依赖都是 Mock,零 IO)
  2. 第 22 行userDao.Users[1] = ... ------ 在内存中预设一条用户数据
  3. 第 24 行gin.CreateTestContext(w) ------ 创建一个模拟的 Gin 上下文
  4. 第 26 行c.Params = gin.Params{``{Key: "uid", Value: "1"}} ------ 模拟 URL 路径参数
  5. 第 27 行authContextSet(c, 1) ------ 模拟 JWT 认证后的用户 ID 设置
  6. 第 29 行svc.GetUser(c) ------ 执行被测试的方法
  7. 第 31-33 行:解析 JSON 响应
  8. 第 34-37 行:断言 code == 0(成功)
  9. 第 39-41 行:断言返回的用户名和 ID 正确
场景 2:缺失参数(Missing Parameter)------ TestGetUser_MissingUID

test/GetUser_test.go:58-70

go 复制代码
func TestGetUser_MissingUID(t *testing.T) {
    svc, _, _, _, _, _ := newTestService()
    w := httptest.NewRecorder()
    c, _ := gin.CreateTestContext(w)
    c.Request = httptest.NewRequest("GET", "/user/", nil)

    svc.GetUser(c)

    var resp map[string]interface{}
    json.Unmarshal(w.Body.Bytes(), &resp)
    if int(resp["code"].(float64)) != -1 {
        t.Errorf("expected code=-1, got code=%v", resp["code"])
    }
}

测试目的 :确保当 URL 中缺少 uid 参数时,返回参数错误(code = -1)。

场景 3:无效参数(Invalid Parameter)------ TestGetUser_InvalidUID

test/GetUser_test.go:73-87

go 复制代码
func TestGetUser_InvalidUID(t *testing.T) {
    svc, _, _, _, _, _ := newTestService()
    w := httptest.NewRecorder()
    c, _ := gin.CreateTestContext(w)
    c.Request = httptest.NewRequest("GET", "/user/abc", nil)
    c.Params = gin.Params{{Key: "uid", Value: "abc"}}

    svc.GetUser(c)

    var resp map[string]interface{}
    json.Unmarshal(w.Body.Bytes(), &resp)
    if int(resp["code"].(float64)) == 0 {
        t.Fatal("expected error, got success")
    }
}

测试目的 :确保当 uid 参数不是数字(如 "abc")时,返回错误而不是崩溃。

场景 4:资源不存在(Not Found)------ TestGetUser_NotFound

test/GetUser_test.go:89-107

go 复制代码
func TestGetUser_NotFound(t *testing.T) {
    svc, _, _, _, _, _ := newTestService()
    w := httptest.NewRecorder()
    c, _ := gin.CreateTestContext(w)
    c.Request = httptest.NewRequest("GET", "/user/999", nil)
    c.Params = gin.Params{{Key: "uid", Value: "999"}}

    svc.GetUser(c)

    var resp map[string]interface{}
    json.Unmarshal(w.Body.Bytes(), &resp)
    code := int(resp["code"].(float64))
    if code != constant.ERROR_PERMISSION_DENIED {
        t.Errorf("expected code=%d, got %d", constant.ERROR_PERMISSION_DENIED, code)
    }
    if resp["msg"] != "user not found" {
        t.Errorf("expected 'user not found', got '%v'", resp["msg"])
    }
}

测试目的:不存在的用户 ID 应返回权限拒绝错误。

4.4 好友添加测试:完整边界覆盖

test/AddFriends_test.go 中有 6 个测试用例,覆盖了添加好友的完整边界。

场景 5:DAO 层错误------ TestAddFriend_DAOAddError

test/AddFriends_test.go:128-151

go 复制代码
func TestAddFriend_DAOAddError(t *testing.T) {
    svc, userDao, friendsDao, _, _, _ := newTestService()
    userDao.Users[1] = &model.User{Id: 1, Name: "alice"}
    userDao.Users[2] = &model.User{Id: 2, Name: "bob"}
    friendsDao.AddFriendErr = errors.New("db error")  // 注入错误!

    body := `{"uid":1,"fri":2}`
    w := httptest.NewRecorder()
    c, _ := gin.CreateTestContext(w)
    c.Request = httptest.NewRequest("POST", "/friends", strings.NewReader(body))
    authContextSet(c, 1)

    svc.AddFriend(c)

    var resp map[string]interface{}
    json.Unmarshal(w.Body.Bytes(), &resp)
    if int(resp["code"].(float64)) == 0 {
        t.Fatal("expected error, got success")
    }
    if resp["msg"] != "internal error" {
        t.Errorf("expected 'internal error', got '%v'", resp["msg"])
    }
}

关键设计 :第 25 行 friendsDao.AddFriendErr = errors.New("db error") 直接给 Mock 的 AddFriendErr 字段赋值,模拟数据库写入失败。这验证了 sanitizeErr 函数能将底层错误转换为 "internal error"

4.5 认证中间件测试:5 种边界场景

test/AuthMiddleware_test.go 对 JWT 认证中间件进行了 5 个测试:

测试用例 场景 验证点
TestAuthRequired_MissingHeader 缺失 Authorization 头 返回 -3 错误码
TestAuthRequired_InvalidToken 无效 Token 返回 -3 + "invalid token"
TestAuthRequired_InvalidScheme 用了 Basic 而非 Bearer 返回 -3 + "authorization required"
TestAuthRequired_EmptyBearerToken Bearer 后无 Token 返回 -3
TestAuthRequired_SetsUserIDAndRole 有效 Token user_id=42, role=user

TestAuthRequired_SetsUserIDAndRole 为例(test/AuthMiddleware_test.go:120-149):

go 复制代码
func TestAuthRequired_SetsUserIDAndRole(t *testing.T) {
    svc, _, _, _, _, _ := newTestService()
    token := testToken(42)  // 生成一个有效的测试 Token

    w := httptest.NewRecorder()
    c, _ := gin.CreateTestContext(w)
    c.Request = httptest.NewRequest("GET", "/user/1", nil)
    c.Request.Header.Set("Authorization", "Bearer "+token)

    handler := svc.AuthRequired()
    handler(c)

    if c.IsAborted() {
        t.Fatal("expected request to proceed, got aborted")
    }
    uid, exists := c.Get("user_id")
    if !exists {
        t.Fatal("expected user_id in context")
    }
    if uid.(int) != 42 {
        t.Errorf("expected user_id=42, got %v", uid)
    }
}

测试关键点 :验证中间件不仅没有拒绝请求,还正确地将 user_idrole 设置到了 Gin 上下文中。

4.6 登录测试:集成 bcrypt 的完整流程

test/Login_test.go:14-39 展示了如何在测试中用真实的 bcrypt 而非 Mock:

go 复制代码
func TestLogin_Success(t *testing.T) {
    svc, userDao, _, _, _, _ := newTestService()
    hash, _ := bcrypt.GenerateFromPassword([]byte("testpass"), bcrypt.DefaultCost)
    userDao.Users[1] = &model.User{Id: 1, Name: "testuser", Password: string(hash)}

    body := `{"name":"testuser","password":"testpass"}`
    w := httptest.NewRecorder()
    c, _ := gin.CreateTestContext(w)
    c.Request = httptest.NewRequest("POST", "/auth/login", strings.NewReader(body))
    c.Request.Header.Set("Content-Type", "application/json")

    svc.Login(c)

    var resp map[string]interface{}
    json.Unmarshal(w.Body.Bytes(), &resp)
    // 验证 token 不为空且 user_id 正确
}

设计思考 :这里 bcrypt 没有被 Mock,因为 hashPasswordcheckPassword 是纯函数(没有外部依赖),在测试中运行 bcrypt 完全没问题(每条约 100ms,仍在可接受范围内)。

4.7 验证错误信息脱敏

test/AddFriends_test.go:85-104TestAddFriend_UserNotFound 测试:

go 复制代码
func TestAddFriend_UserNotFound(t *testing.T) {
    svc, _, _, _, _, _ := newTestService()
    body := `{"uid":999,"fri":2}`
    // ...
    svc.AddFriend(c)
    // 错误信息是 "invalid friend request",而不是 "user 999 not found"
    if resp["msg"] != "invalid friend request" {
        t.Errorf("expected 'invalid friend request', got '%v'", resp["msg"])
    }
}

这个测试验证了一个安全原则:不应该透露"哪个用户不存在"的具体信息。如果返回"用户 999 不存在",攻击者就可以枚举有效用户 ID。


五、进阶话题

5.1 覆盖率不等于质量

很多团队把"代码覆盖率 > 80%"作为硬性指标。这可能导致:

go 复制代码
// 这种测试毫无意义,只为刷覆盖率
func TestModelUser(t *testing.T) {
    u := model.User{Id: 1, Name: "test"}
    assert.Equal(t, 1, u.Id)        // 测试 Go 的赋值语法?
    assert.Equal(t, "test", u.Name)  // 测试 Go 的字符串?
}

有意义的测试应该验证

  • 边界条件(空参数、零值、负数、超长字符串)
  • 错误处理路径(数据库错误、网络超时、权限不足)
  • 安全要求(信息脱敏、权限校验)
  • 业务规则(不能自己加自己为好友、不能重复添加好友)

5.2 Table-Driven Tests:测试不应该只有一种方式

Go 社区推荐的另一种测试模式是 Table-Driven Tests(表驱动测试):

go 复制代码
func TestGetUser_TableDriven(t *testing.T) {
    tests := []struct {
        name    string
        uid     string
        wantCode int
        wantMsg string
    }{
        {"success", "1", 0, ""},
        {"missing uid", "", -1, "param uid not set"},
        {"invalid uid", "abc", -1, ""},
        {"not found", "999", -4, "user not found"},
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            // 测试逻辑
        })
    }
}

表驱动测试的优点是减少重复代码,让新增测试场景只需加一行数据。

5.3 测试初始化:init() 函数

test/GetUser_test.go:17-19

go 复制代码
func init() {
    gin.SetMode(gin.TestMode)
}

这行代码在所有测试之前将 Gin 设置为测试模式。测试模式下 Gin 会禁用一些生产特性(如彩色日志输出),输出更简洁的日志,减少干扰。


六、总结

回顾本项目的测试体系:

测试对象 Mock 了什么 测试数量 执行时间
GetUser UserDao, BlacklistDao, Logger 4 ~20ms
AddFriend UserDao, FriendsDao, Logger 6 ~30ms
Login UserDao, Logger 5 ~400ms (含 bcrypt)
AuthMiddleware 全部 DAO, Logger 5 ~30ms
CORS 无(纯中间件) 3 ~5ms
RateLimiter 无(纯算法) 5 ~100ms
SecurityHeaders 无(纯中间件) 2 ~5ms

核心理念

  1. Mock 一切外部依赖:数据库、Redis、网络全部 Mock,只测试纯逻辑
  2. 5 种必测场景:正常、缺失参数、无效参数、不存在、DAO 错误
  3. 错误注入:通过设置 Mock 的 Error 字段来模拟各种异常
  4. 安全意识:测试也应该覆盖错误信息脱敏等安全要求
  5. 覆盖率不是目标:有意义的断言比覆盖率数字重要

下一篇文章,我们将深入可观测性三件套------日志、指标、链路追踪,让你的服务不仅跑得快,而且看得清。


完整代码

本文所有示例代码来自开源项目 user-service,一个基于 Go + Gin + GORM 构建的企业级用户管理与社交关系 REST API 微服务。

项目地址:https://github.com/binbin3828/user

本系列 14 篇完整目录:

① 从面条代码到三层架构 ② API 安全洋葱模型 ③ 配置管理与密钥保护 ④ 单元测试 ⑤ 可观测性

⑥ 部署进化 ⑦ 好友请求状态机 ⑧ Redis 实战 ⑨ 中间件链 ⑩ Geohash

⑪ API 响应设计 ⑫ 优雅关闭 ⑬ GORM 避坑 ⑭ Makefile