下载工作台
Gin Web 开发

单元测试与集成测试

试读上半部分 · 解锁后可读全文

第 14 章 · 单元测试与集成测试

本章目标:为 api-go-demo Service 编写表驱动单元测试;用 httptest + Gin 测 Handler;使用 testify 断言;理解 sqlmocktestcontainers 概念;集成测试覆盖 MySQL 真实读写 slug;Mock Repository;对照 go-dev ch13fastapi-web pytest。

学时建议:4~5 小时(含 2 小时跟练)

前置:完成 gin-web ch13;了解 ch05 分层。


14.1 场景说明:回归保护

重构分层、改校验或改 price 分 字段时,测试防止 silent break:

层级测什么
Serviceslug 重复、负价格、404 映射、缓存逻辑
HandlerHTTP 状态码、envelope、401/403
IntegrationGORM + MySQL 读写 slug + is_published
sqlmockRepository 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.HTTPStatusCodeHTTP 码
assert.*失败继续(多用 require)
require.JSONEq(t, `{"code":"OK"}`, rec.Body.String()) // 慎用,字段多时不便

以下内容需解锁后阅读

试读已结束。解锁本章 ¥5.00,或开通年度会员畅读全部教程。
年度会员 ¥199.00/年; 小紫 AI 工作台有效会员 ¥99.00/年

正文仅在服务端鉴权后下发,未付费无法获取下半部分内容。