覆盖率90%的Go项目,线上bug一个没少


《Go工程踩坑实录》第5期。前四期聊了数据库连接池、日志规范、接口设计和配置管理,本期说说我职业生涯里最打脸的一类事故:测试覆盖了,但bug没覆盖。


引子:一片翠绿的CI仪表盘

项目CI仪表盘一片翠绿:单元测试覆盖率92%,全部通过。SonarQube上那个绿色的盾牌晃得人眼晕。你信心满满地点击"发布上线"。

第二天,用户反馈"下单后订单状态没更新"。

你懵了——这个流程明明有测试啊。TestCreateOrder 跑了上百次,每次都是200。翻开测试代码一看,它测了HTTP 200返回,但没测数据库事务是否提交。覆盖率是有了,bug也在线上等你了。

这就是我要说的:覆盖率是骗人的,能测到bug的测试才是真的。

今天不教你怎么写测试,教你怎么写能发现问题的测试。读完你会有一套可落地的测试策略,让CI从"自我安慰"变成"真正的安全网"。


01 覆盖率92%,为什么我还是被Bug打脸?

某电商订单服务,200+单测,覆盖率90%+。上线后连续出现3个生产bug。复盘测试代码后,我发现团队掉进了四个测试幻觉

幻觉1:只测Happy Path

TestCreateOrder 只传合法参数,断言HTTP 200。从来没测过库存不足时是不是返回400,没测过支付失败时订单状态会不会回滚到"待支付"。

TestGetUser 返回了用户数据,但没测"用户不存在"分支——那个分支直接panic了,测试里从没走进去。

只测Happy Path的覆盖率,等于没覆盖。

幻觉2:测了实现,没测契约

测试直接调用私有函数 calculateDiscountInternal,重构后函数改名,测试全挂——但业务逻辑是对的。花了一上午修测试,最后发现是测试耦合了实现细节,不是业务行为变了。

测试应该跟着契约走,不是跟着函数名走。

幻觉3:Mock了一切

数据库Mock了,缓存Mock了,第三方APIMock了。测试跑得像飞,30秒全部通过。上线后SQL语句写错了——SELECT * FROM orders WHERE id = ? 里的字段名拼错,Mock当然不会报错。缓存Key格式变了,从 user:123 变成 user_123,Mock也顺顺当当。

Mock测试的是你的假设,不是现实。

幻觉4:没有并发测试

单线程测试全过。上线后两个用户同时下单,竞态条件导致超卖。map 并发读写panic,测试里从没触发——因为测试都是顺序跑的,一个goroutine都没有。


02 Go项目的测试金字塔,不是三层是五层

传统测试金字塔讲三层:单元、集成、E2E。但在Go微服务实践里,我发现五层更贴合真实场景。

第1层:单元测试——测函数,不是测代码行

目标:验证一个函数在给定输入下,输出和副作用是否正确。

正确姿势:测行为,不测实现。输入→输出+副作用,不关心内部怎么算的。

// 好的单元测试:关注行为
func TestCalculateDiscount(t *testing.T) {
    tests := []struct {
        name     string
        price    float64
        userType string
        want     float64
        wantErr  bool // 显式声明是否预期报错
    }{
        {"普通用户无折扣", 100, "normal", 100, false},
        {"VIP用户9折", 100, "vip", 90, false},
        {"企业用户8折", 100, "enterprise", 80, false},
        {"负数价格报错", -10, "normal", 0, true}, // 边界
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got, err := CalculateDiscount(tt.price, tt.userType)
            if tt.wantErr {
                require.Error(t, err)
                return
            }
            require.NoError(t, err)
            require.Equal(t, tt.want, got)
        })
    }
}

反模式

  • 为了覆盖率测私有函数 → 重构就挂
  • 一个测试测10个场景 → 失败了你不知道是哪段

Go技巧:用 t.Run 做子测试,失败时精确到场景。哪个case挂了,输出里直接标名字。

顺便提一句:文中 require 全来自 testify 包。require 遇到失败会立即终止当前测试,assert 则继续往下跑。对于"没通过就没必要往下走"的前置条件(如 err != nil)用 require;单纯的值比对(如 got == want)也可以用 assert,这样一次运行能看到所有失败的 case。

第2层:集成测试——测组件协作

目标:验证数据库、缓存、消息队列等外部依赖的协作是否真实可用。

正确姿势:用测试容器起真实MySQL/Redis。不测Mock,测真实的SQL执行、事务提交、索引命中。

Go生态首选 testcontainers-go。数据库起来了还不够,Schema迁移(migration)也得跑,否则空数据库测不出真实效果。建议配合 golang-migrate 在测试启动时自动执行迁移脚本:

// 集成测试:测真实数据库交互
func TestOrderRepository_Create(t *testing.T) {
    ctx := context.Background()

    req := testcontainers.ContainerRequest{
        Image:        "mysql:8.0",
        ExposedPorts: []string{"3306/tcp"},
        Env: map[string]string{
            "MYSQL_ROOT_PASSWORD": "secret",
            "MYSQL_DATABASE":      "testdb",
        },
        WaitingFor: wait.ForListeningPort("3306/tcp"),
    }
    mysqlC, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
        ContainerRequest: req,
        Started:          true,
    })
    require.NoError(t, err)
    t.Cleanup(func() {
        mysqlC.Terminate(ctx)
    })

    db := setupTestDB(mysqlC) // setupTestDB 内部通过 mysqlC.Host() 和 mysqlC.MappedPort() 获取真实连接地址
    repo := NewOrderRepository(db)

    // 测试:创建订单后,数据库里真的有这条记录
    order := &Order{UserID: "u1", Amount: 100}
    err = repo.Create(ctx, order)
    require.NoError(t, err)
    require.NotZero(t, order.ID) // 自增ID生成了

    // 验证:查出来和存进去一致
    got, err := repo.GetByID(ctx, order.ID)
    require.NoError(t, err)
    require.Equal(t, order.Amount, got.Amount)
}

关键原则:集成测试慢(秒级),所以数量要少而精——只测"不连DB就不放心"的场景。核心业务流走一遍就够了,别把所有分支都用容器测。

第3层:契约测试——测接口约定

目标:验证服务间的接口契约不被破坏。微服务A调用B的API,B升级后字段类型变了,契约测试第一时间发现。

Go生态用 pact-go

func TestConsumerPact(t *testing.T) {
    pact := dsl.Pact{
        Consumer: "order-service",
        Provider: "user-service",
    }

    pact.AddInteraction().
        Given("user exists").
        UponReceiving("get user by id").
        WithRequest(dsl.Request{
            Method: "GET",
            Path:   dsl.String("/users/123"),
        }).
        WillRespondWith(dsl.Response{
            Status: 200,
            Body: map[string]interface{}{
                "id":   dsl.String("123"),
                "name": dsl.String("Alice"),
            },
        })

    err := pact.Verify(func() error {
        _, err := userClient.GetUser("123")
        return err
    })
    require.NoError(t, err)
}

如果provider把 name 改成 full_name,这个测试会挂——在部署前就发现问题。

第4层:端到端测试——测用户旅程

目标:模拟真实用户操作,验证完整业务流程。

httpexpect 写E2E:

func TestPlaceOrder(t *testing.T) {
    e := httpexpect.New(t, "http://api.example.com")

    // 1. 用户登录
    token := e.POST("/login").
        WithJSON(map[string]string{"user": "alice", "pass": "123"}).
        Expect().Status(http.StatusOK).
        JSON().Object().Value("token").String().Raw()

    // 2. 创建订单
    orderID := e.POST("/orders").
        WithHeader("Authorization", "Bearer "+token).
        WithJSON(map[string]int{"product_id": 1, "qty": 2}).
        Expect().Status(http.StatusOK).
        JSON().Object().Value("order_id").String().Raw()

    // 3. 支付回调
    e.POST("/webhooks/payment").
        WithJSON(map[string]string{
            "order_id": orderID,
            "status":   "paid",
        }).
        Expect().Status(http.StatusOK)

    // 4. 验证订单状态
    e.GET("/orders/"+orderID).
        WithHeader("Authorization", "Bearer "+token).
        Expect().Status(http.StatusOK).
        JSON().Object().Value("status").String().Equal("paid")
}

E2E不是越多越好。只测最核心的用户旅程:注册→下单→支付→查询。其他交给下层测试。

第5层:混沌测试——测容错能力

目标:主动制造故障,验证系统韧性。

func TestChaos_RedisDown(t *testing.T) {
    ctx := context.Background()

    // 正常流程
    val, err := cache.Get(ctx, "key")
    require.NoError(t, err)
    require.Equal(t, "cache_value", val)

    // 模拟Redis挂了
    redisContainer.Stop()

    // 验证:服务还能响应(走数据库降级)
    val, err = cache.Get(ctx, "key")
    require.NoError(t, err)               // 不应该报错,应该降级
    require.Equal(t, "db_value", val)   // 从数据库拿到了
}

混沌测试不是上线前的"加分项",而是分布式系统的必选项。你不敢在测试里杀的依赖,线上一定会挂。

需要区分的是:代码里手动 Stop 容器属于故障注入(Fault Injection),适合在CI里跑。真正的混沌测试(如随机kill Pod、注入网络延迟)通常在 Staging/QA 环境 通过专用工具(如 Chaos Mesh)实现,不是单元测试的职责范围。

一句话总结五层金字塔:单元测逻辑,集成测交互,契约测接口,E2E测流程,混沌测兜底。


03 写了1000个测试后,我总结的5个技巧

技巧1:表驱动测试——一个函数测20个场景

func TestValidateEmail(t *testing.T) {
    tests := []struct {
        email string
        valid bool
    }{
        {"alice@example.com", true},
        {"bob+tag@example.co.uk", true},
        {"", false},                         // 空
        {"no-at-sign", false},               // 没@
        {"@nouser.com", false},              // 没用户名
        {"spaces in@it.com", false},         // 有空格
        {strings.Repeat("a", 300) + "@test.com", false}, // 超长
    }

    for _, tt := range tests {
        t.Run(tt.email, func(t *testing.T) {
            got := ValidateEmail(tt.email)
            require.Equal(t, tt.valid, got)
        })
    }
}

表驱动是Go测试的精髓。一个函数、一张表、20个场景,比写20个独立函数清晰得多。失败时 t.Run 会告诉你具体哪一行输入挂了。

技巧2:Golden File——复杂输出的快照测试

API返回大型JSON,手写expected太痛苦。Golden File让机器帮你管expected:

var update = flag.Bool("update", false, "update golden files")

func TestAPIResponse(t *testing.T) {
    resp := handler.Handle(request)

    goldenFile := "testdata/response.json"
    if *update {
        os.WriteFile(goldenFile, resp.Body, 0644)
        return
    }

    expected, err := os.ReadFile(goldenFile)
    require.NoError(t, err)
    require.JSONEq(t, string(expected), string(resp.Body))
}

第一次跑 go test -update 生成快照。后续对比。API改了格式,测试会挂,提醒你检查变更是否合理。

技巧3:并行测试——速度就是生命

func TestDiscount(t *testing.T) {
    tests := []struct {
        name  string
        price float64
        want  float64
    }{
        {"正常", 100, 90},
        {"零元", 0, 0},
        {"负数", -10, 0},
    }

    for _, tt := range tests {
        tt := tt // 捕获range变量(Go 1.22+ 已自动处理,无需此行;低于1.22则不能删,否则并发运行会引用同一个变量地址)
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel() // 👈 关键
            got := CalculateDiscount(tt.price)
            require.Equal(t, tt.want, got)
        })
    }
}

200个测试串行跑30秒,并行跑3秒。t.Parallel() 是Go测试被低估的神器。

技巧4:模糊测试——让机器帮你找边界

Go 1.18+内置fuzzing:

func FuzzValidateEmail(f *testing.F) {
    f.Add("test@example.com")
    f.Add("bad-email")

    f.Fuzz(func(t *testing.T, email string) {
        // 模糊测试:随机生成字符串,验证不会panic
        _ = ValidateEmail(email)
    })
}

go test -fuzz=FuzzValidateEmail -fuzztime=30s,Go自动生成各种奇葩输入——空指针、超长字符串、特殊字符、二进制垃圾。帮你发现那些"正常用户不会这么干"的崩溃点。

模糊测试不是替代人工设计case,是补位那些你没想到的边界。

技巧5:测试辅助函数——别让重复代码毁了可读性

// 不好的:每个测试都写一堆setup
t.Run("create order", func(t *testing.T) {
    db, _ := sql.Open("mysql", testDSN)
    redis := redis.NewClient(testRedisOpt)
    svc := NewOrderService(db, redis)
    // ... 20行后才开始测试逻辑
})

// 好的:提取setup,测试一眼看完
func setupTestOrderService(t *testing.T) *OrderService {
    t.Helper()
    db := testDB(t)       // 自动创建、自动回滚
    rds := testRedis(t)   // 自动清理
    return NewOrderService(db, rds)
}

func TestCreateOrder(t *testing.T) {
    svc := setupTestOrderService(t)
    order, err := svc.Create(ctx, &CreateOrderReq{...})
    require.NoError(t, err)
    require.NotZero(t, order.ID)
}

t.Helper() 让错误堆栈指向调用处,而不是辅助函数内部。没有它,测试失败时IDE的红线会划在 setupTestOrderService 的层层嵌套里;加了它,红线直接标在 TestCreateOrder 里的那一行 require——省掉你逐层翻代码的时间。别小看这几秒钟,修100个Bug就能省出一个下午。


04 5个测试反模式,团队里一定有人在用

反模式1:测试依赖执行顺序

// 错了:TestB依赖TestA创建的数据
func TestA_CreateUser(t *testing.T) {
    db.Exec("INSERT INTO users (id, name) VALUES (1, 'alice')")
}

func TestB_GetUser(t *testing.T) {
    var name string
    db.QueryRow("SELECT name FROM users WHERE id = 1").Scan(&name)
    require.Equal(t, "alice", name) // 假设用户已存在
}

单独跑 TestB 必挂。Go测试默认乱序执行,你控制不了先后。

解法:每个测试独立,用 t.Cleanup 清理:

func TestUserLifecycle(t *testing.T) {
    db := setupDB(t)
    t.Cleanup(func() { db.Exec("DELETE FROM users") })

    // 创建
    _, err := db.Exec("INSERT INTO users (id, name) VALUES (1, 'alice')")
    require.NoError(t, err)

    // 查询
    var name string
    db.QueryRow("SELECT name FROM users WHERE id = 1").Scan(&name)
    require.Equal(t, "alice", name)
}

反模式2:时间相关的脆弱测试

// 错的:依赖当前时间
func TestIsExpired(t *testing.T) {
    item := Item{CreatedAt: time.Now().Add(-25 * time.Hour)}
    require.True(t, item.IsExpired()) // 24小时过期
}

如果测试在夏令时切换时跑?如果CI机器时间不准?这条测试会随机失败。

解法:注入时钟:

type Clock interface { Now() time.Time }

type MockClock struct { NowTime time.Time }
func (m *MockClock) Now() time.Time { return m.NowTime }

func TestIsExpired(t *testing.T) {
    mock := &MockClock{NowTime: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)}
    item := Item{
        CreatedAt: mock.Now().Add(-25 * time.Hour),
        clock:     mock,
    }
    require.True(t, item.IsExpired())
}

反模式3:全局状态污染

// 错的:修改全局变量
func TestWithFeatureFlag(t *testing.T) {
    oldFlag := FeatureFlag
    FeatureFlag = true // 全局变量!
    // 测试...
    FeatureFlag = oldFlag // 如果panic了,恢复不了
}

解法:Go 1.17+用 t.Setenv,自动恢复:

func TestWithFeatureFlag(t *testing.T) {
    t.Setenv("FEATURE_FLAG", "true") // 测试结束自动恢复
    // 测试...
}

或者用函数参数化,消灭全局变量。

反模式4:Mock验证实现细节

// 错的:验证调用了具体方法,参数必须完全匹配
mockDB.On("Query", "SELECT * FROM orders WHERE id = ?", 123).
    Return(&sql.Rows{}, nil)

如果重构时把SQL改成JOIN?测试挂了,但功能是对的。你修了一上午测试,最后发现业务逻辑没问题——只是在折磨自己。

解法:Mock验证行为,不验证SQL

// 好的:验证结果,不验证过程
order, err := repo.GetByID(ctx, 123)
require.NoError(t, err)
require.Equal(t, expectedOrder, order)

反模式5:没有测试的测试

// 错的:测试什么也没assert
func TestProcess(t *testing.T) {
    Process(data) // 没报错就行?
    // 没有require,没有assert
}

没有断言的测试,是代码版的 placebo。它让你觉得自己安全了,其实什么也没验证。


05 测试不是写完了,是跑对了

测试分级流水线

提交前(pre-commit):
  └─ 单元测试(<1秒)→ 快速反馈

PR时(CI):
  └─ 单元测试 + 集成测试(<5分钟)→ 合并门槛

合并后(post-merge):
  └─ E2E测试 + 契约测试(<30分钟)→ 回归验证

 nightly:
  └─ 混沌测试 + 性能测试 → 韧性验证

单元测试要快——开发者在等反馈。E2E可以慢——反正夜里跑。

覆盖率目标:不是越高越好

层级 合理目标 为什么
单元测试 70-80% 核心业务逻辑覆盖
集成测试 40-60% 关键交互路径覆盖
E2E 20-30% 主流程覆盖
总计 不追求数字 追求关键路径无遗漏

覆盖率是用来发现未测试代码的工具,而不是衡量测试质量的指标。

100%覆盖率但全是Mock = 自欺欺人。60%覆盖率但有集成测试 + 混沌测试 = 真正的信心。

flaky test 治理

定义:时而过、时不过的测试。比没测试还糟——它消耗团队对测试的信任。

治理策略

  1. 标记隔离t.Skip("flaky: #issue-123"),不让它污染CI
  2. 修或删:优先修(找到根因),修不了就删——测不到价值的测试不如没有
  3. 重试是最后手段retry 掩盖问题,不是解决问题
func TestFlakyFeature(t *testing.T) {
    if os.Getenv("CI") != "" {
        t.Skip("flaky test, see issue #123")
    }
    // ...
}

06 面试官爱问的2道题

Q1:Mock和Stub有什么区别?什么时候该用Test Container?

Mock:验证交互(调用了几次、参数是什么),适合验证副作用。比如"支付接口必须被调用一次"。

Stub:提供固定返回值,适合隔离依赖。比如"外部API返回500,测我方的降级逻辑"。

Fake:简化的真实实现,如内存数据库。适合需要真实行为但不想连外部服务。

Test Container:当SQL语句本身需要验证(索引命中、事务隔离级别)时,必须用真实数据库。Mock测不出SQL错误。

决策树:只关心"有没有调用"→ Mock;关心"SQL写得对不对"→ Test Container;关心"性能好不好"→ 压测环境,不是单元测试。

Q2:怎么测试并发代码?怎么发现race condition?

工具go test -race 开启竞态检测,自动发现data race。CI里必须开。

压力测试:高并发跑大量goroutine,用普通 map 演示 -race 的威力:

func TestConcurrentMapRace(t *testing.T) {
    m := make(map[int]int) // 普通map,并发写入会触发race
    var wg sync.WaitGroup
    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func(n int) {
            defer wg.Done()
            m[n] = n // 并发写入,-race 会报错
        }(i)
    }
    wg.Wait()
}

跑的时候加 -racego test -race ./...

注意:如果你用 sync.Map,它内部已经处理了并发安全,-race 不会报警——这也是为什么示例要用普通 map。

Go的教训:不要靠直觉保证没有 race,要靠 -race 发现 race,并验证在各种并发交织下业务逻辑的正确性。


记住这三句话

  1. 覆盖率是必要不充分条件——有覆盖不等于测对了。
  2. 测行为,不测实现——重构时测试应该帮你,不是拖你后腿。
  3. 集成测试是底线——Mock测不出SQL错误,Test Container能。

现在可以做的3件事

  1. 打开你项目的测试,检查有没有"只测Happy Path"的。把边界和错误分支补上。
  2. 选一个核心业务,补一个集成测试。用 testcontainers-go 起个真实MySQL,跑通一次创建+查询。
  3. 跑一次 go test -race ./...,看看有没有竞态。有的话,恭喜——你在测试里发现了本该线上出现的bug。

你们项目测试覆盖率多少?有没有"覆盖率很高但线上还是崩"的经历?评论区见。

另外好奇一问:你们是在用标准库的 testing,还是 Ginkgo/Testify Suite 这类BDD风格框架?我在小团队里偏爱 testing 的简洁,但项目大了确实会想念 BeforeEachContext。你们的选择是什么?

下期预告:《性能监控——pprof用了但没找到瓶颈,采样时机错了》


文中提到的工具与库

库/工具 用途 地址
testify require/assert 断言库 github.com/stretchr/testify
testcontainers-go Docker化集成测试 github.com/testcontainers/testcontainers-go
pact-go 消费者驱动契约测试 github.com/pact-foundation/pact-go
httpexpect E2E测试HTTP断言 github.com/gavv/httpexpect
golang-migrate 数据库Schema迁移 github.com/golang-migrate/migrate
chaosmonkey 混沌测试工具 github.com/netflix/chaosmonkey

本文完。如果对你有用,点个「在看」等于告诉微信"这货值得推给别人"。

wx

关注公众号

©2017-2023 鲁ICP备17023316号-1 Powered by Hugo