第 14 章 · 单元测试与集成测试
本章目标:为 api-go-demo Service 编写表驱动单元测试;用 httptest + Gin 测 Handler;使用 testify 断言;理解 sqlmock 与 testcontainers 概念;集成测试覆盖 MySQL 真实读写 slug;Mock Repository;对照 go-dev ch13 与 fastapi-web pytest。
学时建议:4~5 小时(含 2 小时跟练)
前置:完成 gin-web ch13;了解 ch05 分层。
14.1 场景说明:回归保护
重构分层、改校验或改 price 分 字段时,测试防止 silent break:
| 层级 | 测什么 |
|---|---|
| Service | slug 重复、负价格、404 映射、缓存逻辑 |
| Handler | HTTP 状态码、envelope、401/403 |
| Integration | GORM + MySQL 读写 slug + is_published |
| sqlmock | Repository SQL 形状(无真实 DB) |
internal/service/product_test.go # 单元 + mock repo
internal/handler/product_test.go # httptest
internal/repository/product_sqlmock_test.go # 选修
tests/integration/product_test.go # Docker MySQL
ch16 验收:go test ./... 全绿,建议 ≥5 个 Test 函数。
14.2 依赖
go get github.com/stretchr/testify@v1.9.0
go get github.com/DATA-DOG/go-sqlmock@v1.5.2 # 选修
go get github.com/testcontainers/testcontainers-go@v0.33.0 # 集成选修
14.3 Service 单元测试(表驱动)
// internal/service/product_test.go
package service_test
import (
"context"
"testing"
"example.com/api-go-demo/internal/model"
"example.com/api-go-demo/internal/service"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
type mockProductRepo struct {
bySlug map[string]model.Product
createErr error
}
func (m *mockProductRepo) GetBySlug(ctx context.Context, slug string, pub bool) (*model.Product, error) {
p, ok := m.bySlug[slug]
if !ok {
return nil, gorm.ErrRecordNotFound
}
if pub && !p.IsPublished {
return nil, gorm.ErrRecordNotFound
}
return &p, nil
}
func (m *mockProductRepo) Create(ctx context.Context, p *model.Product) error {
if m.createErr != nil {
return m.createErr
}
if _, dup := m.bySlug[p.Slug]; dup {
return gorm.ErrDuplicatedKey
}
m.bySlug[p.Slug] = *p
return nil
}
func TestGetPublished_OK(t *testing.T) {
repo := &mockProductRepo{bySlug: map[string]model.Product{
"go-handbook": {Slug: "go-handbook", Name: "Go", Price: 6800, IsPublished: true},
}}
svc := service.NewProductService(repo, nil, nil, nil)
p, err := svc.GetPublished(context.Background(), "go-handbook")
require.NoError(t, err)
require.Equal(t, int64(6800), p.Price)
require.True(t, p.IsPublished)
}
func TestGetPublished_NotFound(t *testing.T) {
svc := service.NewProductService(&mockProductRepo{bySlug: map[string]model.Product{}}, nil, nil, nil)
_, err := svc.GetPublished(context.Background(), "missing")
require.ErrorIs(t, err, service.ErrProductNotFound)
}
func TestGetPublished_UnpublishedHidden(t *testing.T) {
repo := &mockProductRepo{bySlug: map[string]model.Product{
"draft": {Slug: "draft", IsPublished: false, Price: 100},
}}
svc := service.NewProductService(repo, nil, nil, nil)
_, err := svc.GetPublished(context.Background(), "draft")
require.ErrorIs(t, err, service.ErrProductNotFound)
}
func TestCreateProduct_PriceInCents(t *testing.T) {
tests := []struct {
name string
price int64
wantErr bool
}{
{"valid", 6800, false},
{"zero", 0, true},
{"negative", -1, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
repo := &mockProductRepo{bySlug: map[string]model.Product{}}
svc := service.NewProductService(repo, nil, nil, nil)
err := svc.Create(context.Background(), &model.Product{
Slug: "x", Name: "n", Price: tt.price, IsPublished: false,
})
if tt.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
}
})
}
}
14.4 Handler httptest(完整)
// internal/handler/product_test.go
package handler_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"example.com/api-go-demo/internal/dto"
"example.com/api-go-demo/internal/handler"
"example.com/api-go-demo/internal/response"
"example.com/api-go-demo/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
type stubProductSvc struct {
getFn func(slug string) (*dto.ProductResp, error)
}
func (s *stubProductSvc) GetPublishedCached(ctx context.Context, slug string) (*model.Product, error) {
// 简化:直接调 getFn
return nil, service.ErrProductNotFound
}
func setupProductRouter(h *handler.ProductHandler) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
v1 := r.Group("/api/v1")
v1.GET("/products/:slug", h.GetProduct)
v1.POST("/products", h.CreateProduct)
return r
}
func TestGetProduct_OK(t *testing.T) {
// 使用 mock service 注入 — 实际项目定义 Service interface
h := handler.NewProductHandler(mockSvcWithProduct("go-handbook", 6800))
r := setupProductRouter(h)
req := httptest.NewRequest(http.MethodGet, "/api/v1/products/go-handbook", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var body response.Envelope
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body))
require.Equal(t, "OK", body.Code)
data, _ := json.Marshal(body.Data)
var p dto.ProductResp
require.NoError(t, json.Unmarshal(data, &p))
require.Equal(t, "go-handbook", p.Slug)
require.Equal(t, int64(6800), p.Price)
}
func TestGetProduct_NotFound(t *testing.T) {
h := handler.NewProductHandler(mockSvcEmpty())
r := setupProductRouter(h)
req := httptest.NewRequest(http.MethodGet, "/api/v1/products/nope", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
require.Equal(t, http.StatusNotFound, rec.Code)
}
func TestCreateProduct_ValidationError(t *testing.T) {
h := handler.NewProductHandler(mockSvcEmpty())
r := setupProductRouter(h)
body := bytes.NewBufferString(`{"name":"x"}`) // 缺 slug
req := httptest.NewRequest(http.MethodPost, "/api/v1/products", body)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
require.Equal(t, http.StatusBadRequest, rec.Code)
}
要点:gin.SetMode(gin.TestMode) 抑制 debug 路由日志。
14.5 testify 常用断言
| 函数 | 用途 |
|---|---|
require.NoError | 失败即停 |
require.Equal | 值相等 |
require.ErrorIs | 错误链 |
require.HTTPStatusCode | HTTP 码 |
assert.* | 失败继续(多用 require) |
require.JSONEq(t, `{"code":"OK"}`, rec.Body.String()) // 慎用,字段多时不便