测试与交付

实现完成不等于可交付。本章为服务补齐三层保障:单元与接口测试、容器化部署、CI 流水线。

测试落地

单元层:短码生成(表驱动)

package service

import (
    "regexp"
    "testing"
)

func TestNewSlug(t *testing.T) {
    t.Parallel()
    pattern := regexp.MustCompile(`^[0-9a-zA-Z_-]{7}$`)

    seen := make(map[string]bool)
    for range 100 {
        slug, err := NewSlug()
        if err != nil {
            t.Fatalf("NewSlug() error = %v", err)
        }
        if !pattern.MatchString(slug) {
            t.Fatalf("NewSlug() = %q, 不符合 Base62/7 位格式", slug)
        }
        seen[slug] = true
    }
    // 100 次生成几乎不可能重复;重复则说明随机源有问题
    if len(seen) != 100 {
        t.Errorf("生成 100 次出现重复,仅 %d 个唯一值", len(seen))
    }
}

单元层:业务规则(fake 替身)

验证自定义短码校验与冲突重试,不触碰数据库:

package service

import (
    "context"
    "errors"
    "sync"
    "testing"

    "example.com/shortener/internal/store"
)

// fake:内存实现,可注入预设行为
type fakeStore struct {
    mu       sync.Mutex
    links    map[string]*store.Link
    failOn   map[string]error // code → 首次创建时返回的错误
    failOnce map[string]*sync.Once
}

func newFakeStore() *fakeStore {
    return &fakeStore{
        links:    map[string]*store.Link{},
        failOn:   map[string]error{},
        failOnce: map[string]*sync.Once{},
    }
}

func (f *fakeStore) CreateLink(_ context.Context, l *store.Link) error {
    f.mu.Lock()
    defer f.mu.Unlock()
    if err, ok := f.failOn[l.Code]; ok { // 模拟"首次冲突"
        once := f.failOnce[l.Code]
        if once == nil {
            once = &sync.Once{}
            f.failOnce[l.Code] = once
        }
        once.Do(func() {})
        if _, exists := f.links[l.Code]; !exists {
            return err
        }
    }
    if _, exists := f.links[l.Code]; exists {
        return store.ErrAlreadyExists
    }
    f.links[l.Code] = l
    return nil
}

func (f *fakeStore) GetLinkByCode(_ context.Context, code string) (*store.Link, error) {
    f.mu.Lock()
    defer f.mu.Unlock()
    if l, ok := f.links[code]; ok {
        return l, nil
    }
    return nil, store.ErrNotFound
}

func (f *fakeStore) IncrementClicks(_ context.Context, code string) error { return nil }

func TestCreateInvalidCustomCode(t *testing.T) {
    t.Parallel()
    svc := NewLinkService(newFakeStore())

    _, err := svc.Create(context.Background(), "https://go.dev", "非法!!", 1)
    if !errors.Is(err, ErrInvalidCode) {
        t.Fatalf("err = %v, want ErrInvalidCode", err)
    }
}

func TestCreateRandomCodeRetriesOnConflict(t *testing.T) {
    t.Parallel()
    fs := newFakeStore()
    // 预设:随机短码必然冲突一次,验证重试后成功
    // 实测技巧:用一个会返回 ErrAlreadyExists 的包装 store 精确控制冲突次数
    svc := NewLinkService(&conflictOnceStore{Store: fs})

    link, err := svc.Create(context.Background(), "https://go.dev", "", 1)
    if err != nil {
        t.Fatalf("Create() error = %v", err)
    }
    if link.Code == "" {
        t.Fatal("expected non-empty code")
    }
}

// conflictOnceStore:首次创建任意短码时返回冲突,其后透传
type conflictOnceStore struct{ *fakeStore }

func (c *conflictOnceStore) CreateLink(ctx context.Context, l *store.Link) error {
    if !c.failOnce["*"] {
        c.failOnce["*"] = true
        return store.ErrAlreadyExists
    }
    return c.Store.CreateLink(ctx, l)
}

接口层:httptest 验证路由与状态码

package handler

import (
    "context"
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "strings"
    "testing"

    "example.com/shortener/internal/service"
    "example.com/shortener/internal/store"
)

func TestCreateLinkRequiresAuth(t *testing.T) {
    t.Parallel()
    h := New(service.NewLinkService(newFakeLinkStore()),
        service.NewUserService(newFakeUserStore(), "test-secret"), "test-secret")

    mux := http.NewServeMux()
    h.RegisterRoutes(mux)

    req := httptest.NewRequest(http.MethodPost, "/api/links",
        strings.NewReader(`{"url":"https://go.dev"}`))
    rec := httptest.NewRecorder()
    mux.ServeHTTP(rec, req)

    if rec.Code != http.StatusUnauthorized {
        t.Fatalf("status = %d, want 401", rec.Code) // 无令牌必须被拒
    }
}

func TestRedirectNotFound(t *testing.T) {
    t.Parallel()
    h := New(service.NewLinkService(newFakeLinkStore()),
        service.NewUserService(newFakeUserStore(), "test-secret"), "test-secret")

    mux := http.NewServeMux()
    h.RegisterRoutes(mux)

    req := httptest.NewRequest(http.MethodGet, "/no-such-code", nil)
    rec := httptest.NewRecorder()
    mux.ServeHTTP(rec, req)

    if rec.Code != http.StatusNotFound {
        t.Fatalf("status = %d, want 404", rec.Code)
    }
}

func TestRedirectHappyPath(t *testing.T) {
    t.Parallel()
    fs := newFakeLinkStore()
    _ = fs.CreateLink(context.Background(),
        &store.Link{Code: "abc123", URL: "https://go.dev", UserID: 1})

    h := New(service.NewLinkService(fs),
        service.NewUserService(newFakeUserStore(), "test-secret"), "test-secret")
    mux := http.NewServeMux()
    h.RegisterRoutes(mux)

    req := httptest.NewRequest(http.MethodGet, "/abc123", nil)
    rec := httptest.NewRecorder()
    mux.ServeHTTP(rec, req)

    if rec.Code != http.StatusFound {
        t.Fatalf("status = %d, want 302", rec.Code)
    }
    if loc := rec.Header().Get("Location"); loc != "https://go.dev" {
        t.Fatalf("Location = %q, want https://go.dev", loc)
    }
}
Tip

newFakeLinkStore / newFakeUserStore 是存储接口的内存实现(参考测试章的 fake 模式),篇幅原因省略——它们与 internal/service/link_test.go 中的 fake 同构。接口测试不连真实数据库,整套测试在毫秒级完成。

go test -race -cover ./...

Docker Compose 部署

Dockerfile 沿用工程化章的多阶段模板,Compose 负责把应用与数据库编排起来:

services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: shortener
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: shortener
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U shortener"]
      interval: 3s
      timeout: 3s
      retries: 10
    volumes:
      - pgdata:/var/lib/postgresql/data

  app:
    build:
      context: ..
      dockerfile: deployments/Dockerfile
    environment:
      DATABASE_URL: postgres://shortener:secret@db:5432/shortener?sslmode=disable
      JWT_SECRET: change-me-in-production
      ADDR: ":8080"
    ports:
      - "8080:8080"
    depends_on:
      db:
        condition: service_healthy # 等数据库就绪再启动应用

volumes:
  pgdata:
docker compose -f deployments/docker-compose.yml up --build -d

# 验收路径:注册 → 登录 → 建链 → 跳转
curl -s localhost:8080/api/register -d '{"username":"alice","password":"pass1234"}'
TOKEN=$(curl -s localhost:8080/api/login \
  -d '{"username":"alice","password":"pass1234"}' | jq -r .token)
curl -s localhost:8080/api/links \
  -H "Authorization: Bearer $TOKEN" -d '{"url":"https://go.dev"}'
curl -i localhost:8080/abc1234   # 302 Location: https://go.dev
curl -s localhost:8080/metrics | head -5
Tip

Compose 要点:depends_on + healthcheck 保证启动顺序正确(只靠 depends_on 只保证"已启动"而非"已就绪");数据库数据落在命名卷,docker compose down 不丢数据,down -v 才会清空。生产环境的 JWT_SECRET、数据库口令绝不应写在 Compose 文件里,应注入环境或密钥管理服务。

CI 流水线

.github/workflows/ci.yml工程化章模板的直接落地):

name: CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  verify:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version-file: go.mod
          cache: true

      - run: go vet ./...
      - run: go test -race -cover ./...
      - uses: golangci/golangci-lint-action@v7
        with:
          version: latest
      - run: go build ./...

后续可按需扩展:构建成功后推送镜像到镜像仓库(docker buildx + docker/login-action),再自动部署到目标环境。

交付检查清单

上线前逐项自检:

  • go test -race ./... 全绿,覆盖率了解于心
  • golangci-lint run 零告警
  • 密钥全部来自环境变量,代码与仓库中无硬编码
  • /metrics、pprof 等管理端点不会暴露公网
  • 优雅退出验证过(docker compose stop 观察日志)
  • 限流、404、401 等负路径手工验证过
  • 日志输出 JSON 且不含敏感信息

小结

  • 测试分三层落地:表驱动单测 → fake 替身业务测试 → httptest 路由状态码验证,全程毫秒级。
  • Compose 的 healthcheck + depends_on: condition 是多服务启动顺序的正确解法。
  • CI 是工程化的收口:vet → test -race → lint → build,配好一次全程受益。

项目完成。 回顾整个学习路径:从 go mod init 的第一个 Hello World,到并发模型、Web 服务,再到这个可部署的完整服务与 langchaingo 的 LLM 应用——你已经具备了独立开发 Go 生产代码的全部基础。接下来最好的学习方式,是开始写你自己的真实项目。