一、开篇引入:一个让我重构了三个月的测试教训
我曾经在一个项目里写过这样的"单元测试":
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 种必须的测试场景:
- 正常路径(Happy Path)
- 缺失参数(Missing Parameter)
- 无效参数(Invalid Parameter)
- 资源不存在(Not Found)
- 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)
设计要点:
- 所有方法都是空实现------测试中不需要验证日志输出(那是集成测试的事)
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 的指针。这样测试用例可以:
- 在创建 Service 之前预设 Mock 数据(
userDao.Users[1] = ...) - 在创建 Service 之后注入错误(
friendsDao.AddFriendErr = errors.New("db error")) - 在测试完成后检查 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)
}
}
逐步解析:
- 第 21 行 :
newTestService()创建 Service(所有依赖都是 Mock,零 IO) - 第 22 行 :
userDao.Users[1] = ...------ 在内存中预设一条用户数据 - 第 24 行 :
gin.CreateTestContext(w)------ 创建一个模拟的 Gin 上下文 - 第 26 行 :
c.Params = gin.Params{``{Key: "uid", Value: "1"}}------ 模拟 URL 路径参数 - 第 27 行 :
authContextSet(c, 1)------ 模拟 JWT 认证后的用户 ID 设置 - 第 29 行 :
svc.GetUser(c)------ 执行被测试的方法 - 第 31-33 行:解析 JSON 响应
- 第 34-37 行:断言 code == 0(成功)
- 第 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_id 和 role 设置到了 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,因为 hashPassword 和 checkPassword 是纯函数(没有外部依赖),在测试中运行 bcrypt 完全没问题(每条约 100ms,仍在可接受范围内)。
4.7 验证错误信息脱敏
test/AddFriends_test.go:85-104,TestAddFriend_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 |
核心理念:
- Mock 一切外部依赖:数据库、Redis、网络全部 Mock,只测试纯逻辑
- 5 种必测场景:正常、缺失参数、无效参数、不存在、DAO 错误
- 错误注入:通过设置 Mock 的 Error 字段来模拟各种异常
- 安全意识:测试也应该覆盖错误信息脱敏等安全要求
- 覆盖率不是目标:有意义的断言比覆盖率数字重要
下一篇文章,我们将深入可观测性三件套------日志、指标、链路追踪,让你的服务不仅跑得快,而且看得清。
完整代码
本文所有示例代码来自开源项目 user-service,一个基于 Go + Gin + GORM 构建的企业级用户管理与社交关系 REST API 微服务。
项目地址:https://github.com/binbin3828/user
本系列 14 篇完整目录:
① 从面条代码到三层架构 ② API 安全洋葱模型 ③ 配置管理与密钥保护 ④ 单元测试 ⑤ 可观测性
⑥ 部署进化 ⑦ 好友请求状态机 ⑧ Redis 实战 ⑨ 中间件链 ⑩ Geohash
⑪ API 响应设计 ⑫ 优雅关闭 ⑬ GORM 避坑 ⑭ Makefile