1. Go 测试基础

testing 包结构与命名规范

Go 内置测试框架位于标准库 testing 包中,无需额外依赖。测试文件必须以 _test.go 结尾,测试函数名必须以 Test 开头,且接收 *testing.T 参数。

t.Error vs t.Fatal

  • t.Error() — 标记失败,继续执行后续测试代码
  • t.Fatal() — 标记失败,立即停止当前测试函数
  • t.Errorf() — 格式化版的 t.Error
  • t.Fatalf() — 格式化版的 t.Fatal
  • t.Log() — 输出日志(仅 -v 模式或失败时显示)
  • t.Skip() — 跳过当前测试(如依赖未满足时)

常用运行命令

  • go test ./... — 递归运行所有包的测试
  • go test -v ./... — 显示详细输出
  • go test -run TestXxx — 只运行匹配的测试
  • go test -run TestXxx/subtest — 运行子测试
  • go test -count=1 ./... — 禁用测试缓存
  • go test -timeout 30s ./... — 设置超时

下面是一个完整的 HTTP Handler 测试示例,涵盖了典型的测试写法:

// handler.go
package api

import (
    "encoding/json"
    "net/http"
    "strconv"
)

type UserHandler struct {
    service UserService
}

type UserService interface {
    GetByID(id int64) (*User, error)
}

type User struct {
    ID    int64  `json:"id"`
    Name  string `json:"name"`
    Email string `json:"email"`
}

func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
    idStr := r.URL.Query().Get("id")
    id, err := strconv.ParseInt(idStr, 10, 64)
    if err != nil {
        http.Error(w, "invalid id", http.StatusBadRequest)
        return
    }

    user, err := h.service.GetByID(id)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    if user == nil {
        http.Error(w, "not found", http.StatusNotFound)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(user)
}

// handler_test.go
package api

import (
    "encoding/json"
    "errors"
    "net/http"
    "net/http/httptest"
    "testing"
)

// fakeUserService 是手写的 mock
type fakeUserService struct {
    user *User
    err  error
}

func (f *fakeUserService) GetByID(id int64) (*User, error) {
    return f.user, f.err
}

func TestUserHandler_GetUser(t *testing.T) {
    handler := &UserHandler{
        service: &fakeUserService{
            user: &User{ID: 1, Name: "Alice", Email: "alice@example.com"},
        },
    }

    req := httptest.NewRequest(http.MethodGet, "/user?id=1", nil)
    rec := httptest.NewRecorder()

    handler.GetUser(rec, req)

    // 验证状态码
    if rec.Code != http.StatusOK {
        t.Fatalf("期望状态码 200,得到 %d", rec.Code)
    }

    // 验证响应体
    var got User
    if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
        t.Fatal("解析响应体失败:", err)
    }
    if got.Name != "Alice" {
        t.Errorf("期望 Name=Alice,得到 %q", got.Name)
    }
}

func TestUserHandler_GetUser_InvalidID(t *testing.T) {
    handler := &UserHandler{service: &fakeUserService{}}

    req := httptest.NewRequest(http.MethodGet, "/user?id=abc", nil)
    rec := httptest.NewRecorder()

    handler.GetUser(rec, req)

    if rec.Code != http.StatusBadRequest {
        t.Errorf("期望状态码 400,得到 %d", rec.Code)
    }
}

func TestUserHandler_GetUser_ServiceError(t *testing.T) {
    handler := &UserHandler{
        service: &fakeUserService{err: errors.New("db error")},
    }

    req := httptest.NewRequest(http.MethodGet, "/user?id=1", nil)
    rec := httptest.NewRecorder()

    handler.GetUser(rec, req)

    if rec.Code != http.StatusInternalServerError {
        t.Errorf("期望状态码 500,得到 %d", rec.Code)
    }
}
ℹ️

httptest 包的价值

httptest.NewRecorder() 返回一个实现了 http.ResponseWriter 的记录器,让你在不启动真实 HTTP 服务器的情况下测试 Handler。httptest.NewRequest() 构造标准 *http.Request,比 http.NewRequest 更简洁(忽略 error)。

2. 表格驱动测试

为什么使用表格驱动测试

表格驱动测试(Table-Driven Tests)是 Go 社区的最佳实践,由 Go 核心团队在官方博客中推荐。其优势在于:同一段测试逻辑,通过数据驱动覆盖大量边界情况;新增测试用例只需在 slice 中添加一行,不需要写新函数。

package pricing

import (
    "testing"
)

// 被测函数:计算订单折扣价
func CalcDiscount(price float64, userLevel string, quantity int) float64 {
    discount := 1.0
    switch userLevel {
    case "vip":
        discount = 0.9
    case "svip":
        discount = 0.8
    }
    if quantity >= 10 {
        discount -= 0.05 // 批量额外折扣
    }
    if discount < 0.7 {
        discount = 0.7 // 最低折扣保底
    }
    return price * float64(quantity) * discount
}

func TestCalcDiscount(t *testing.T) {
    // 表格:每行是一个测试用例
    tests := []struct {
        name      string  // 子测试名称,方便定位失败
        price     float64
        userLevel string
        quantity  int
        want      float64
    }{
        {
            name:      "普通用户少量购买",
            price:     100,
            userLevel: "normal",
            quantity:  1,
            want:      100,
        },
        {
            name:      "VIP用户少量购买",
            price:     100,
            userLevel: "vip",
            quantity:  1,
            want:      90, // 90% 折扣
        },
        {
            name:      "SVIP用户批量购买",
            price:     100,
            userLevel: "svip",
            quantity:  10,
            want:      750, // (0.8 - 0.05) = 0.75,100 * 10 * 0.75
        },
        {
            name:      "折扣保底不低于7折",
            price:     100,
            userLevel: "svip",
            quantity:  100,
            want:      7000, // 保底 0.7,100 * 100 * 0.7
        },
        {
            name:      "普通用户批量购买",
            price:     50,
            userLevel: "normal",
            quantity:  10,
            want:      2375, // (1.0 - 0.05) = 0.95,50 * 10 * 0.95
        },
    }

    for _, tt := range tests {
        // t.Run 创建子测试,失败信息形如:TestCalcDiscount/VIP用户少量购买
        t.Run(tt.name, func(t *testing.T) {
            got := CalcDiscount(tt.price, tt.userLevel, tt.quantity)
            if got != tt.want {
                t.Errorf("CalcDiscount(%.2f, %q, %d) = %.2f,期望 %.2f",
                    tt.price, tt.userLevel, tt.quantity, got, tt.want)
            }
        })
    }
}

// 子测试可以并行运行(适合无状态测试)
func TestCalcDiscount_Parallel(t *testing.T) {
    tests := []struct {
        name  string
        price float64
        want  float64
    }{
        {"价格为零", 0, 0},
        {"小数价格", 99.9, 99.9},
    }

    for _, tt := range tests {
        tt := tt // 关键:避免闭包捕获循环变量(Go 1.22+ 已自动修复)
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel() // 标记为可并行
            got := CalcDiscount(tt.price, "normal", 1)
            if got != tt.want {
                t.Errorf("got %.2f, want %.2f", got, tt.want)
            }
        })
    }
}

Go 1.22 的循环变量修复

Go 1.22 起,for range 循环的迭代变量在每次迭代时创建新的变量实例,不再需要 tt := tt 这样的"复制技巧"。但在并行子测试中保留这个习惯仍然有助于代码可读性。

3. Mock 与依赖注入

接口驱动设计让测试变得容易

Go 中的最佳实践是依赖接口而非具体类型。一个依赖具体数据库连接的函数极难测试,但依赖接口的函数可以轻松地注入 mock。

package order

import (
    "context"
    "errors"
    "time"
)

// ─── 接口定义(生产代码依赖这些接口)────────────────────────────

type OrderRepository interface {
    Create(ctx context.Context, order *Order) error
    FindByID(ctx context.Context, id int64) (*Order, error)
}

type StockService interface {
    Reserve(ctx context.Context, productID int64, qty int) error
    Release(ctx context.Context, productID int64, qty int) error
}

type NotifyService interface {
    SendEmail(ctx context.Context, to, subject, body string) error
}

// ─── 业务 Service ──────────────────────────────────────────────

type Order struct {
    ID        int64
    UserID    int64
    ProductID int64
    Quantity  int
    Total     float64
    CreatedAt time.Time
}

type OrderService struct {
    repo     OrderRepository
    stock    StockService
    notifier NotifyService
}

func NewOrderService(repo OrderRepository, stock StockService, n NotifyService) *OrderService {
    return &OrderService{repo: repo, stock: stock, notifier: n}
}

var ErrInsufficientStock = errors.New("库存不足")

func (s *OrderService) PlaceOrder(ctx context.Context, userID, productID int64, qty int, price float64) (*Order, error) {
    // 预占库存
    if err := s.stock.Reserve(ctx, productID, qty); err != nil {
        return nil, ErrInsufficientStock
    }

    order := &Order{
        UserID:    userID,
        ProductID: productID,
        Quantity:  qty,
        Total:     price * float64(qty),
        CreatedAt: time.Now(),
    }

    if err := s.repo.Create(ctx, order); err != nil {
        // 创建失败,回滚库存
        _ = s.stock.Release(ctx, productID, qty)
        return nil, err
    }

    // 发送通知(失败不影响主流程)
    _ = s.notifier.SendEmail(ctx, "user@example.com", "订单确认", "您的订单已创建")
    return order, nil
}

手写 Mock 与 testify/mock 对比

// order_service_test.go
package order

import (
    "context"
    "errors"
    "testing"

    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/mock"
)

// ─── 手写 Mock ────────────────────────────────────────────────

type mockStock struct {
    reserveErr error
    released   bool
}

func (m *mockStock) Reserve(_ context.Context, _ int64, _ int) error {
    return m.reserveErr
}

func (m *mockStock) Release(_ context.Context, _ int64, _ int) error {
    m.released = true
    return nil
}

type mockRepo struct {
    createErr error
    order     *Order
}

func (m *mockRepo) Create(_ context.Context, o *Order) error {
    if m.createErr != nil {
        return m.createErr
    }
    o.ID = 100 // 模拟数据库写入后返回 ID
    m.order = o
    return nil
}

func (m *mockRepo) FindByID(_ context.Context, id int64) (*Order, error) {
    return m.order, nil
}

type mockNotify struct{}

func (m *mockNotify) SendEmail(_ context.Context, to, subject, body string) error { return nil }

// ─── 测试用例 ─────────────────────────────────────────────────

func TestOrderService_PlaceOrder_Success(t *testing.T) {
    stock := &mockStock{}
    repo := &mockRepo{}
    notifier := &mockNotify{}

    svc := NewOrderService(repo, stock, notifier)

    order, err := svc.PlaceOrder(context.Background(), 1, 10, 2, 99.0)
    assert.NoError(t, err)
    assert.NotNil(t, order)
    assert.Equal(t, int64(100), order.ID)
    assert.Equal(t, 198.0, order.Total) // 99 * 2
}

func TestOrderService_PlaceOrder_StockFail(t *testing.T) {
    stock := &mockStock{reserveErr: errors.New("out of stock")}
    repo := &mockRepo{}
    notifier := &mockNotify{}

    svc := NewOrderService(repo, stock, notifier)

    _, err := svc.PlaceOrder(context.Background(), 1, 10, 2, 99.0)
    assert.ErrorIs(t, err, ErrInsufficientStock)
}

func TestOrderService_PlaceOrder_DBFail_ReleasesStock(t *testing.T) {
    stock := &mockStock{}
    repo := &mockRepo{createErr: errors.New("db error")}
    notifier := &mockNotify{}

    svc := NewOrderService(repo, stock, notifier)

    _, err := svc.PlaceOrder(context.Background(), 1, 10, 2, 99.0)
    assert.Error(t, err)
    // 关键断言:DB 失败后库存必须被回滚
    assert.True(t, stock.released, "库存应当被释放")
}

// ─── testify/mock 示例(更强大的动态 Mock)──────────────────

type MockStockService struct {
    mock.Mock
}

func (m *MockStockService) Reserve(ctx context.Context, productID int64, qty int) error {
    args := m.Called(ctx, productID, qty)
    return args.Error(0)
}

func (m *MockStockService) Release(ctx context.Context, productID int64, qty int) error {
    args := m.Called(ctx, productID, qty)
    return args.Error(0)
}

func TestOrderService_WithTestifyMock(t *testing.T) {
    stockMock := new(MockStockService)

    // 设置期望:Reserve 被调用时返回 nil(成功)
    stockMock.On("Reserve", mock.Anything, int64(10), 2).Return(nil)

    repo := &mockRepo{}
    svc := NewOrderService(repo, stockMock, &mockNotify{})

    _, err := svc.PlaceOrder(context.Background(), 1, 10, 2, 99.0)
    assert.NoError(t, err)

    // 验证 Reserve 确实被调用了一次
    stockMock.AssertNumberOfCalls(t, "Reserve", 1)
    stockMock.AssertExpectations(t)
}

4. 集成测试

TestMain:测试环境统一初始化

TestMain 是一个特殊函数,在整个包的测试运行前后执行,适合启动/关闭数据库连接等资源。

// integration_test.go
package store_test

import (
    "context"
    "database/sql"
    "fmt"
    "log"
    "os"
    "testing"

    _ "github.com/lib/pq"
)

var testDB *sql.DB

// TestMain 控制整个包的测试生命周期
func TestMain(m *testing.M) {
    // 从环境变量读取测试数据库 DSN(CI 中注入)
    dsn := os.Getenv("TEST_DATABASE_URL")
    if dsn == "" {
        dsn = "postgres://postgres:password@localhost:5432/testdb?sslmode=disable"
    }

    var err error
    testDB, err = sql.Open("postgres", dsn)
    if err != nil {
        log.Fatalf("无法连接测试数据库: %v", err)
    }

    if err = testDB.Ping(); err != nil {
        log.Fatalf("数据库 Ping 失败: %v", err)
    }

    // 运行所有测试
    code := m.Run()

    testDB.Close()
    os.Exit(code)
}

// 每个测试使用事务隔离,测试结束后回滚——不污染数据库
func TestUserRepository_Create(t *testing.T) {
    if testing.Short() {
        t.Skip("跳过集成测试(使用 -short 标志)")
    }

    // 开启事务
    tx, err := testDB.BeginTx(context.Background(), nil)
    if err != nil {
        t.Fatal(err)
    }
    // 无论测试成功与否,结束后回滚
    defer tx.Rollback()

    repo := NewUserRepositoryWithTx(tx)

    user, err := repo.Create(context.Background(), &CreateUserInput{
        Name:  "Test User",
        Email: fmt.Sprintf("test-%d@example.com", time.Now().UnixNano()),
    })

    if err != nil {
        t.Fatalf("创建用户失败: %v", err)
    }
    if user.ID == 0 {
        t.Error("期望返回非零 ID")
    }
    if user.Name != "Test User" {
        t.Errorf("Name 不匹配,got %q", user.Name)
    }
}

// ─── testcontainers-go 示例(按需启动真实容器)──────────────

import (
    "github.com/testcontainers/testcontainers-go"
    "github.com/testcontainers/testcontainers-go/modules/postgres"
    "github.com/testcontainers/testcontainers-go/wait"
)

func setupPostgresContainer(t *testing.T) *sql.DB {
    t.Helper()

    ctx := context.Background()

    // 自动拉起 PostgreSQL 容器
    pgContainer, err := postgres.RunContainer(ctx,
        testcontainers.WithImage("postgres:16-alpine"),
        postgres.WithDatabase("testdb"),
        postgres.WithUsername("test"),
        postgres.WithPassword("test"),
        testcontainers.WithWaitStrategy(
            wait.ForLog("database system is ready to accept connections").
                WithOccurrence(2)),
    )
    if err != nil {
        t.Fatalf("启动 postgres 容器失败: %v", err)
    }

    // 测试结束时自动销毁容器
    t.Cleanup(func() { pgContainer.Terminate(ctx) })

    connStr, _ := pgContainer.ConnectionString(ctx, "sslmode=disable")
    db, err := sql.Open("postgres", connStr)
    if err != nil {
        t.Fatal(err)
    }
    return db
}
⚠️

集成测试与单元测试的取舍

集成测试更贴近真实场景,但速度慢、依赖外部资源。推荐策略:日常开发跑单元测试(毫秒级),CI/CD 中运行集成测试。可用 testing.Short() + -short 标志在本地快速跳过集成测试。

5. 基准测试

BenchmarkXxx 写法与解读

基准测试函数以 Benchmark 开头,接收 *testing.B,必须在循环 for i := 0; i < b.N; i++ 内执行被测代码。Go 的测试框架会自动调整 b.N 直到结果稳定。

package strutil

import (
    "fmt"
    "strings"
    "testing"
)

// ─── 被测函数:两种字符串拼接方式 ─────────────────────────────

// 方式 A:使用 + 运算符(每次都分配新字符串)
func ConcatPlus(parts []string) string {
    result := ""
    for _, p := range parts {
        result += p
    }
    return result
}

// 方式 B:使用 strings.Builder(预分配,零复制)
func ConcatBuilder(parts []string) string {
    var sb strings.Builder
    for _, p := range parts {
        sb.WriteString(p)
    }
    return sb.String()
}

// 方式 C:strings.Join(标准库实现,通常最优)
func ConcatJoin(parts []string) string {
    return strings.Join(parts, "")
}

// ─── 基准测试 ─────────────────────────────────────────────────

var benchParts = func() []string {
    parts := make([]string, 100)
    for i := range parts {
        parts[i] = fmt.Sprintf("segment-%d-", i)
    }
    return parts
}()

func BenchmarkConcatPlus(b *testing.B) {
    b.ResetTimer() // 重置计时器,排除初始化耗时
    for i := 0; i < b.N; i++ {
        ConcatPlus(benchParts)
    }
}

func BenchmarkConcatBuilder(b *testing.B) {
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        ConcatBuilder(benchParts)
    }
}

func BenchmarkConcatJoin(b *testing.B) {
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        ConcatJoin(benchParts)
    }
}

// 并行基准测试:模拟多 goroutine 并发场景
func BenchmarkConcatBuilder_Parallel(b *testing.B) {
    b.ResetTimer()
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            ConcatBuilder(benchParts)
        }
    })
}

// 不同规模的子基准测试
func BenchmarkConcatBuilder_Sizes(b *testing.B) {
    sizes := []int{10, 100, 1000}
    for _, n := range sizes {
        parts := make([]string, n)
        for i := range parts {
            parts[i] = "x"
        }
        b.Run(fmt.Sprintf("n=%d", n), func(b *testing.B) {
            for i := 0; i < b.N; i++ {
                ConcatBuilder(parts)
            }
        })
    }
}

运行命令与输出解读:

# 运行所有基准测试,显示内存分配信息
go test -bench=. -benchmem -benchtime=3s ./...

# 典型输出:
# goos: darwin
# goarch: arm64
# BenchmarkConcatPlus-8         200000   8234 ns/op   45312 B/op   99 allocs/op
# BenchmarkConcatBuilder-8     2000000    612 ns/op    1536 B/op    2 allocs/op
# BenchmarkConcatJoin-8        2000000    598 ns/op    1024 B/op    1 allocs/op
列名含义说明
-8GOMAXPROCS=8使用的 CPU 核心数
200000迭代次数(b.N)框架自动调整直到结果稳定
8234 ns/op每次操作耗时纳秒级,越小越好
45312 B/op每次操作分配字节数-benchmem 开启后显示
99 allocs/op每次操作内存分配次数分配次数越少,GC 压力越小

b.ResetTimer() 使用场景

当测试前有耗时的初始化(如创建大型数据结构、建立数据库连接),在初始化完成后调用 b.ResetTimer() 清除已记录的时间,避免初始化耗时被计入结果。同理,b.StopTimer()b.StartTimer() 可以在循环内暂停/恢复计时。

6. 代码覆盖率

生成与查看覆盖率报告

# 运行测试并生成覆盖率数据
go test -cover -coverprofile=coverage.out ./...

# 终端输出:
# ok  github.com/myapp/api     coverage: 87.3% of statements
# ok  github.com/myapp/store   coverage: 91.6% of statements

# 在浏览器中查看可视化 HTML 报告(红色=未覆盖,绿色=已覆盖)
go tool cover -html=coverage.out -o coverage.html
open coverage.html

# 查看每个函数的覆盖率
go tool cover -func=coverage.out

# 输出示例:
# github.com/myapp/api.GetUser         100.0%
# github.com/myapp/api.CreateUser       85.7%
# github.com/myapp/api.DeleteUser       60.0%
# total:                                87.3%

CI 中设置覆盖率门槛

#!/bin/bash
# scripts/check-coverage.sh

set -e

go test -coverprofile=coverage.out ./...

# 提取总覆盖率数字
COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | tr -d '%')

echo "代码覆盖率: ${COVERAGE}%"

# 设置最低门槛(如 80%)
THRESHOLD=80
if (( $(echo "$COVERAGE < $THRESHOLD" | bc -l) )); then
    echo "覆盖率 ${COVERAGE}% 低于门槛 ${THRESHOLD}%,CI 失败"
    exit 1
fi

echo "覆盖率检查通过 ✓"
ℹ️

覆盖率的正确认知

100% 覆盖率不代表没有 bug,只能说明每行代码至少被执行过一次。更重要的是测试的质量——断言是否准确、边界情况是否覆盖、错误路径是否测试。通常 80% 是合理目标,核心业务逻辑应尽量达到 90%+。

7. CI/CD 最佳实践

GitHub Actions 完整 Workflow

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main, dev]
  pull_request:
    branches: [main]

jobs:
  lint:
    name: Lint
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-go@v5
        with:
          go-version: '1.22'
          cache: true  # 缓存 Go 模块,加速构建

      - name: golangci-lint
        uses: golangci/golangci-lint-action@v4
        with:
          version: latest
          args: --timeout=5m

  test:
    name: Test
    runs-on: ubuntu-latest
    services:
      # CI 中自动启动 PostgreSQL 服务
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_USER: test
          POSTGRES_PASSWORD: test
          POSTGRES_DB: testdb
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-go@v5
        with:
          go-version: '1.22'
          cache: true

      - name: 运行单元测试
        run: go test -v -race -short ./...

      - name: 运行集成测试
        env:
          TEST_DATABASE_URL: postgres://test:test@localhost:5432/testdb?sslmode=disable
        run: go test -v -race ./...

      - name: 检查覆盖率
        run: |
          go test -coverprofile=coverage.out ./...
          go tool cover -func=coverage.out

      - name: 上传覆盖率到 Codecov
        uses: codecov/codecov-action@v4
        with:
          file: coverage.out

  build:
    name: Build
    runs-on: ubuntu-latest
    needs: [lint, test]  # 依赖 lint 和 test 通过
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-go@v5
        with:
          go-version: '1.22'
          cache: true

      - name: 构建二进制
        run: |
          CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o bin/app ./cmd/server

      - name: 构建 Docker 镜像
        run: docker build -t myapp:${{ github.sha }} .

      - name: 推送到 Registry(仅 main 分支)
        if: github.ref == 'refs/heads/main'
        run: |
          echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin
          docker push myapp:${{ github.sha }}

golangci-lint 配置

# .golangci.yml
linters:
  enable:
    - errcheck      # 检查未处理的 error
    - gosimple      # 简化代码建议
    - govet         # go vet 静态分析
    - staticcheck   # 高级静态分析
    - unused        # 未使用的代码
    - gofmt         # 代码格式
    - goimports     # import 排序
    - revive        # golint 替代品
    - gosec         # 安全问题检查
    - misspell      # 英文拼写检查
    - bodyclose     # http.Response.Body 未关闭

linters-settings:
  errcheck:
    check-type-assertions: true  # 检查类型断言
  gosec:
    excludes:
      - G304  # 允许动态文件路径(按需)
  revive:
    rules:
      - name: exported
        arguments:
          - "checkPrivateReceivers"

issues:
  exclude-rules:
    - path: _test\.go
      linters:
        - gosec  # 测试文件不检查安全问题

run:
  timeout: 5m
  go: '1.22'

代码质量工具一览

静态分析

  • go vet — 内置,检查常见错误
  • staticcheck — 高级静态分析
  • golangci-lint — 聚合多种检查器
  • gosec — 安全漏洞扫描
  • errcheck — 未处理的 error

格式化与质量

  • gofmt — 官方格式化工具
  • goimports — 自动管理 import
  • go mod tidy — 清理依赖
  • govulncheck — 依赖漏洞扫描
  • deadcode — 死代码检测

测试金字塔原则

单元测试(快,多)→ 集成测试(中速,适量)→ E2E 测试(慢,少)。推荐比例约为 70% 单元 / 20% 集成 / 10% E2E。Go 的 httptest 包让 Handler 层的测试接近单元测试的速度,可以放心大量编写。