🧪 测试与工程化
Go 单元测试、表格驱动测试、Mock、集成测试、基准测试、代码覆盖率、CI/CD 最佳实践——让代码质量有保障地走向生产。
1. Go 测试基础
testing 包结构与命名规范
Go 内置测试框架位于标准库 testing 包中,无需额外依赖。测试文件必须以 _test.go 结尾,测试函数名必须以 Test 开头,且接收 *testing.T 参数。
t.Error vs t.Fatal
t.Error()— 标记失败,继续执行后续测试代码t.Fatal()— 标记失败,立即停止当前测试函数t.Errorf()— 格式化版的 t.Errort.Fatalf()— 格式化版的 t.Fatalt.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
| 列名 | 含义 | 说明 |
|---|---|---|
| -8 | GOMAXPROCS=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 层的测试接近单元测试的速度,可以放心大量编写。