服务端阅读 05月27日 15:23
Gin 框架怎么做单元测试和集成测试?
为什么要认真对待 Gin 的测试很多人写 Gin 项目的时候,测试要么不写,要么写个寂寞——跑一下 200 就算过了。但实际项目中,接口逻辑一旦复杂起来(鉴权、参数校验、数据库操作),没测试的代码改一个地方就可能牵连一片。Gin 本身对测试的支持其实很好,httptest 包配合 testify 基本能覆盖日常需求,关键是要用对方法。单元测试:从最简单的 Handler 开始Gin 的 Handler 本质上就是接收 *gin.Context 的函数,测试的核心思路是用 httptest.NewRecorder() 模拟 ResponseWriter,用 http.NewRequest() 构造请求,然后让路由处理这个请求。func TestGetUser(t *testing.T) { gin.SetMode(gin.TestMode) router := gin.New() router.GET("/users/:id", GetUser) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/users/1", nil) router.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) assert.Contains(t, w.Body.String(), "user")}几个要注意的点:gin.SetMode(gin.TestMode) 一定要加,不然 Gin 会输出一堆调试日志,干扰测试输出不要在测试里起真实的 HTTP 服务器,ServeHTTP 直接调用就够了,速度快也不占端口路由注册可以抽成一个函数复用,避免每个测试都写一遍中间件怎么测中间件测试的关键在于构造不同的请求条件。比如测鉴权中间件,需要分别模拟「没带 Token」和「带了合法 Token」两种情况:func TestAuthMiddleware(t *testing.T) { gin.SetMode(gin.TestMode) router := gin.New() router.Use(AuthMiddleware()) router.GET("/protected", func(c *gin.Context) { c.JSON(200, gin.H{"message": "ok"}) }) // 没有 Token w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/protected", nil) router.ServeHTTP(w, req) assert.Equal(t, 401, w.Code) // 带上合法 Token w = httptest.NewRecorder() req, _ = http.NewRequest("GET", "/protected", nil) req.Header.Set("Authorization", "Bearer valid-token") router.ServeHTTP(w, req) assert.Equal(t, 200, w.Code)}中间件测试最常犯的错是只测正常路径,忘了测边界条件。Token 过期、格式错误、权限不足这些场景都要覆盖到。表驱动测试:批量验证输入输出Go 的表驱动测试写起来很顺手,特别适合参数校验这类输入组合多的场景:func TestUserValidation(t *testing.T) { tests := []struct { name string input User wantCode int }{ {"正常用户", User{Username: "test", Email: "test@example.com"}, 201}, {"缺少用户名", User{Email: "test@example.com"}, 400}, {"邮箱格式错误", User{Username: "test", Email: "bad"}, 400}, {"用户名太短", User{Username: "ab", Email: "test@example.com"}, 400}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { gin.SetMode(gin.TestMode) router := gin.New() router.POST("/users", CreateUser) body, _ := json.Marshal(tt.input) w := httptest.NewRecorder() req, _ := http.NewRequest("POST", "/users", bytes.NewBuffer(body)) req.Header.Set("Content-Type", "application/json") router.ServeHTTP(w, req) assert.Equal(t, tt.wantCode, w.Code) }) }}用中文命名 test case 比 test_case_1 直观得多,出错了看报告一眼就知道是哪个场景挂了。Mock:别让外部依赖拖慢你的测试Handler 里如果直接操作数据库,测试会变得又慢又不稳定。正确的做法是把数据访问抽象成接口,测试时用 Mock 替换:type UserRepository interface { FindByID(id uint) (*User, error) Create(user *User) error}type MockUserRepository struct { users map[uint]*User}func (m *MockUserRepository) FindByID(id uint) (*User, error) { if u, ok := m.users[id]; ok { return u, nil } return nil, errors.New("user not found")}func (m *MockUserRepository) Create(user *User) error { m.users[user.ID] = user return nil}手动写 Mock 对简单场景够用,但项目大了推荐用 gomock 或 mockery 自动生成。mockery 配合接口注释 //go:generate mockery --name=UserRepository 一行命令就能生成完整的 Mock 实现,省心很多。func TestGetUserWithMock(t *testing.T) { gin.SetMode(gin.TestMode) mockRepo := &MockUserRepository{ users: map[uint]*User{1: {ID: 1, Username: "test"}}, } handler := NewUserHandler(mockRepo) router := gin.New() router.GET("/users/:id", handler.GetUser) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/users/1", nil) router.ServeHTTP(w, req) assert.Equal(t, 200, w.Code)}集成测试:多个组件协作时的验证单元测试保证单个函数没问题,但模块拼在一起可能出岔子。集成测试就是要验证「注册完能登录」这类完整流程。func TestUserRegisterAndLogin(t *testing.T) { gin.SetMode(gin.TestMode) db := setupTestDB() defer db.Close() app := setupApp(db) // 注册 regBody := `{"username":"testuser","password":"pass123"}` w := httptest.NewRecorder() req, _ := http.NewRequest("POST", "/api/register", strings.NewReader(regBody)) req.Header.Set("Content-Type", "application/json") app.ServeHTTP(w, req) assert.Equal(t, 201, w.Code) // 用刚注册的账号登录 loginBody := `{"username":"testuser","password":"pass123"}` w = httptest.NewRecorder() req, _ = http.NewRequest("POST", "/api/login", strings.NewReader(loginBody)) req.Header.Set("Content-Type", "application/json") app.ServeHTTP(w, req) assert.Equal(t, 200, w.Code)}集成测试中数据库的处理有几个常见方案:SQLite 内存库:最轻量,但要注意和线上 MySQL/PostgreSQL 的语法差异Docker + Testcontainers:起一个真实的数据库容器,测试完自动销毁,最接近生产环境事务回滚:每个测试用事务包裹,测完回滚,数据库始终干净推荐用 Testcontainers,写法如下:func setupTestDB(t *testing.T) *gorm.DB { ctx := context.Background() req := testcontainers.ContainerRequest{ Image: "postgres:15-alpine", ExposedPorts: []string{"5432/tcp"}, Env: map[string]string{"POSTGRES_DB": "test", "POSTGRES_USER": "test", "POSTGRES_PASSWORD": "test"}, WaitingFor: wait.ForListeningPort("5432/tcp"), } postgresC, _ := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ ContainerRequest: req, Started: true, }) t.Cleanup(func() { postgresC.Terminate(ctx) }) // 连接并返回 *gorm.DB ... return db}性能基准测试关键接口有必要做基准测试,防止某次改动引入性能退化:func BenchmarkGetUser(b *testing.B) { gin.SetMode(gin.TestMode) router := gin.New() router.GET("/users/:id", GetUser) req, _ := http.NewRequest("GET", "/users/1", nil) b.ResetTimer() for i := 0; i < b.N; i++ { w := httptest.NewRecorder() router.ServeHTTP(w, req) }}跑一下 go test -bench=. -benchmem,关注 ns/op 和 allocs/op 两个指标。如果某次提交这两个数字突然变大,就要查查是不是引入了不必要的内存分配。减少重复代码的辅助函数测试写多了会发现构造请求、解析响应的代码大量重复,抽成工具函数能省不少事:func makeRequest(method, path string, body interface{}) (*httptest.ResponseRecorder, *http.Request) { var buf bytes.Buffer if body != nil { json.NewEncoder(&buf).Encode(body) } req, _ := http.NewRequest(method, path, &buf) req.Header.Set("Content-Type", "application/json") return httptest.NewRecorder(), req}func parseResponse(w *httptest.ResponseRecorder, v interface{}) error { return json.Unmarshal(w.Body.Bytes(), v)}如果项目规模更大,可以考虑用 testify/suite 把 setup/teardown 逻辑组织成测试套件,比裸写 TestMain 更清晰。测试覆盖率怎么看go test -coverprofile=coverage.out ./...go tool cover -func=coverage.out # 终端看每个函数的覆盖率go tool cover -html=coverage.out # 浏览器看可视化报告覆盖率不是越高越好,核心业务逻辑建议 80% 以上,简单的 CRUD Handler 60% 就够了。盲目追求 100% 反而会让测试变得脆弱,改一点业务逻辑就挂一片测试用例。几条实战经验每个测试必须独立:不要让 TestA 的数据影响 TestB,用 t.Cleanup() 或 defer 清理状态测试文件跟着源文件走:user.go 对应 user_test.go,别把所有测试塞到一个文件里先写失败的测试,再改代码让它通过:TDD 不是教条,但这个习惯能帮你理清接口设计CI 里一定要跑测试:go test ./... 写进 pipeline,覆盖率低于阈值直接拦截合并别 Mock 你不拥有的类型:Mock 第三方库的行为很危险,它更新了你也不知道,用接口隔离才是正道写测试这件事,刚开始觉得烦,但项目过万行之后你会发现:有测试的代码敢重构,没测试的代码只敢加 if-else。Gin 的测试并不难,把 httptest 用熟、把 Mock 做好、把 CI 跑起来,基本上就够了。