很多人一提到 Go 测试,第一反应就是标准库 testing 包够用,go test 跑得够快,没什么好学的。我刚用 Go 写业务时也是这个想法,后来被一个“单测全绿、集成一跑就跪”的问题折腾了两天,才逐步意识到:单元测试和集成测试根本不是同一套策略,选型、方法、排错方式都完全不一样。这篇内容不追求把所有工具讲一遍,而是想从第一个单元测试用例开始,把整个测试链路的工具选型、常见坑、以及在 CI 里稳定运行的思路走一遍,适合正在用 Go 做 Web 服务、微服务或工具类项目的开发者参考。
1. 为什么 Go 的测试生态值得单独梳理一遍
Go 在发布时就把测试作为一等公民内置进了标准库,go test 不需要额外安装框架,这让很多从 Java、JavaScript 转过来的开发者低估了它的能力。但真实项目里,测试的难点从来不是“不会写 Test 函数”,而是“如何用最小的成本构造稳定的测试环境”。单元测试要解决的是纯逻辑验证,集成测试要解决的是模块之间真实协作的问题,这两者需要的工具非常不同,想用一套写法通吃一定会在某个阶段遇到瓶颈。
1.1 标准库 testing 包解决的核心问题
标准库的 testing 包解决了几件最基本的事情:测试文件自动发现(_test.go 后缀)、TestXxx 函数自动注册、go test 的编译执行、断言失败时的错误输出、子测试、基准测试、示例测试,以及覆盖率统计。在纯函数和内部逻辑这一层,标准库几乎已经够用了。
比如你写了一个字符串切片的去重函数,只需要建一个 dedup_test.go,写几个 TestDedup 函数,用 t.Errorf 输出期望值和实际值,跑一下 go test ./...,就能完成最基本的验证。这种模式的好处是足够轻,不引入第三方依赖,CI 里也不需要额外安装工具。
标准库也提供了两个容易忽略的能力:一个是 t.Fatal 和 t.Errorf 的区别,Errorf 标记失败但继续执行当前测试,Fatalf 会立即终止当前测试。另一个是 testing.B 基准测试,能让你在同一个测试文件里做性能比较。对于刚起步的项目,先把标准库用好,比盲目引入一堆测试框架更稳。
1.2 标准库的边界:它不负责什么
说到边界,标准库测试有几个明显的空白。第一,不做 mock,Go 标准库里没有 mock 生成器,需要自己实现测试替身或引入 mock 框架。第二,不做“高级断言”,标准库只有 Error/Fatal,没有 assert.Equal 这种带有可读性信息的断言方法,所以社区里很多项目用 testify 补位。第三,不解决外部依赖环境,比如测试时要连 MySQL、Redis、消息队列,标准库只管发起请求或报错,不会帮你启动容器或管理数据库状态。第四,不处理测试报告的展示,go test 默认输出是文本,CI 平台想展示 JUnit 风格报告时还需要额外转换工具。
这四点决定了 Go 测试工具链的基本盘:标准库做骨架,社区工具补肉。没有哪个工具是万能的,选型完全取决于你的项目处在哪个阶段。单机工具项目可能只要标准库加 testify 就够了;微服务项目通常要引入 gomock、testcontainers-go、gotestsum。下面的内容就是按这条主线展开的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 单元测试:从第一个用例到表驱动测试
单元测试的核心是按函数或模块粒度进行验证,不接触真实网络和数据库。写单元测试时,最重要的不是背语法,而是养成“从调用方视角描述行为”的习惯。先想清楚被测函数有哪些输入、哪些边界条件,再开始写代码。
2.1 测试文件命名与基础结构
Go 对测试文件有强约定:测试文件必须放在与被测代码相同的包目录下,文件名必须以 _test.go 结尾。比如被测文件是 internal/service/order.go,测试文件就应该放在同目录下的 order_test.go 里。
一个最基础的测试函数长这样:
go复制func TestAdd(t *testing.T) {
result := Add(1, 2)
if result != 3 {
t.Errorf("Add(1, 2) = %d, want %d", result, 3)
}
}
这里的 t *testing.T 是测试控制器,Errorf 会输出格式化错误信息并标记测试失败,但不会中断该函数。如果你需要“一旦失败就不再继续执行后续断言”,要用 Fatalf。测试函数的名称必须以 Test 开头,后面建议接被测函数名或业务场景名,例如 TestOrderCreateSuccess。go test 在包目录里执行时,会把当前目录下所有 _test.go 文件编译进测试二进制,然后逐个执行 Test 开头的函数。
这里有个新手容易忽略的点:测试文件里可以访问被测包内部的未导出函数,前提是测试文件和源文件在同一个 package 下。如果测试文件声明为 package order_test,那它就在外部包视角,只能访问导出函数。两者各有用途:内部测试适合验证未导出的实现细节,外部测试适合模拟真正的使用者。我通常建议对外 API 用外部测试,内部复杂逻辑用同包测试。
2.2 表驱动测试:Go 社区最通用的组织方式
单一测试函数写大量 if 判断,用例多了以后会很难维护。Go 社区普遍采用表驱动测试,把测试数据组织成结构体切片,再用循环跑断言。
下面是一个典型的去重函数测试:
go复制func TestUniqueStrings(t *testing.T) {
tests := []struct {
name string
input []string
want []string
}{
{"空输入", nil, nil},
{"重复元素", []string{"a", "a", "b"}, []string{"a", "b"}},
{"顺序保持", []string{"b", "a", "b", "c"}, []string{"b", "a", "c"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := UniqueStrings(tt.input)
if !equal(got, tt.want) {
t.Errorf("UniqueStrings(%v) = %v, want %v", tt.input, got, tt.want)
}
})
}
}
表驱动测试有几个好处:用例和数据放在同一处,新增一条用例只需要在测试表格里加一行;t.Run 会把每条用例变成独立子测试,单个用例失败不会影响其他用例的结果;测试输出里会直接显示子测试名称,失败时一眼能定位到具体场景。我用这个模式重写过很多老测试,通常能把几百行重复代码压缩成一张表加一个循环,可读性提升非常明显。
在断言切片、结构体这类复合值时,不建议直接用 ==,因为没导出的字段或包含不可比较类型会导致编译失败。这时可以用 reflect.DeepEqual,但更推荐后面的 go-cmp 或 testify,因为它们能输出字段级别的差异。
2.3 覆盖率统计与分支完整性
写完测试后,第一件事是看覆盖率。命令行执行:
bash复制go test ./... -cover
会输出每个包的覆盖率百分比。想看到具体是哪些行没有覆盖,可以生成 coverprofile 再转成 HTML:
bash复制go test ./... -coverprofile=coverage.out
go tool cover -html=coverage.out
浏览器里打开 HTML 后,绿色是已执行代码,红色是未覆盖代码。很多团队喜欢把覆盖率当质量门槛,但我更建议把它当“线索”而不是“KPI”。举个例子,一个只有 if 分支但没测 else 分支的函数,覆盖率可能到不了 60%,但核心逻辑其实已经测过了;反过来,一个调用很深的 Web handler,表面覆盖率能到 80%,真正的错误分支可能完全没测到。
所以我写单元测试时的习惯是:先建表驱动用例覆盖正常路径、边界值、错误输入,再跑 -cover 看有没有明显的红色块。如果某个分支连续几次都没执行,说明用例还没覆盖到,可能需要补一个反例。特别值得注意的边界包括:空字符串、nil 切片、0 值、极大整数、并发调用。
3. 让单元测试不再依赖真实环境:mock、依赖注入与测试替身
单元测试里最容易卡住的地方不是函数逻辑,而是依赖。如果一个订单服务要调用支付接口,测试的时候总不能真的去付款;如果一个项目要读数据库,测试的时候也不想真的连库。这就需要依赖注入和测试替身。
3.1 可测试性的地基:接口与依赖注入
Go 的接口设计非常自然,只要实现了接口的方法,就自动满足接口约束。这意味着,你在写业务时如果依赖了一个抽象接口,测试时就可以传入一个测试替身,完全不用改造被测代码。
举个例子,假设订单服务依赖一个支付客户端:
go复制type PaymentClient interface {
Pay(ctx context.Context, orderID string, amount int64) error
}
type OrderService struct {
payment PaymentClient
}
func NewOrderService(payment PaymentClient) *OrderService {
return &OrderService{payment: payment}
}
在 NewOrderService 里把 PaymentClient 注入进来,而不是直接在 OrderService 内部初始化一个具体的支付客户端。单元测试时,可以定义一个 fake:
go复制type fakePayment struct {
shouldFail bool
paidOrders []string
}
func (f *fakePayment) Pay(ctx context.Context, orderID string, amount int64) error {
if f.shouldFail {
return errors.New("pay failed")
}
f.paidOrders = append(f.paidOrders, orderID)
return nil
}
这种 fake 实现不用引入任何 mock 框架,代码可读性极高,而且可以记录调用参数,方便后续断言。依赖注入是 Go 可测试性的地基,接口不要拍脑袋设计,而是根据业务调用边界来定。如果一个类型在业务里始终只有一个实现,也可以先不用接口,等真正需要 mock 时再抽。
3.2 gomock 与 testify mock 的取舍
如果依赖特别多,手写 fake 会变成体力活。社区常用的 mock 方案有两个主流:gomock 和 testify/mock。
gomock 是 Google 出的 mock 生成框架,通过 mockgen 从接口生成类型安全的 mock 代码。用法大致是:
bash复制mockgen -source=pkg/payment/client.go -destination=pkg/payment/mock/client_mock.go -package=mock
生成后,测试代码里可以直接写:
go复制ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockPayment := mock.NewMockPaymentClient(ctrl)
mockPayment.EXPECT().Pay(gomock.Any(), gomock.Any(), int64(100)).Return(nil)
gomock 的好处是生成代码类型固定,不用手写结构体,匹配条件也灵活;缺点是生成步骤需要额外工具链,团队里如果不统一生成命令,容易产生代码漂移。testify/mock 是运行时 mock,需要你手动定义一个结构体并内嵌 mock.Mock,然后重写接口方法。它没有代码生成,但自由度更高,适合快速写临时替身。
我实际项目里的取舍是:核心领域接口、调用频率高的依赖用 gomock,因为它稳定且能防止方法签名变更后 mock 没同步;边角场景、只在某个测试用一次的 fake 直接手写 struct,少引入一层生成逻辑。还有一点要提醒:mock 得越多,测试越容易变成“验证测试和实现是否匹配”,而不是验证真实行为。过度 mock 会让单元测试的置信度下降,所以优先用依赖注入的手写 fake,mock 框架只用来解决接口方法多、手写成本高的情况。
3.3 httptest 与 go-sqlmock:处理外部协议
如果你的代码要请求外部 HTTP 服务,单测里不需要真的启动服务。标准库 net/http/httptest 可以在测试进程内启动一个本地 HTTP server,返回真实的响应,代码不需要知道它连接的是 mock 还是真实地址。
go复制mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/health" {
http.Error(w, "not found", http.StatusNotFound)
return
}
w.WriteHeader(http.StatusOK)
_,_ = w.Write([]byte(`{"status":"ok"}`))
}))
defer mockServer.Close()
client := NewClient(mockServer.URL)
err := client.Health()
if err != nil {
t.Fatal(err)
}
这种做法比单纯的 mock 对象更贴近真实,因为你的代码要走完整的 HTTP 解析、请求序列化、响应反序列化链路。数据库查询也不一定非要连真实实例,go-sqlmock 提供了一种基于 database/sql/driver 的假连接,能拦截 SQL 并返回预设结果,适合验证 SQL 语句和数据处理逻辑。
不过这里有个前提,如果你的 SQL 比较复杂,比如依赖 LIMIT、FOR UPDATE、事务隔离级别,go-sqlmock 很容易出现“SQL 和预设不匹配”的情况。写起来非常痛苦。所以我在涉及事务或复杂查询时会优先用真实数据库做集成测试,单测里只测不依赖 SQL 结果的业务分支。
4. 集成测试:把模块真正连起来跑
单元测试可以保证每一个小零件单独工作正常,但组合起来能不能转,还得靠集成测试。集成测试的核心是:多个真实模块一起运行,验证它们之间的接口协议、数据格式、调用时序是否符合预期。
4.1 单元测试和集成测试的边界怎么划
我习惯用两个问题区分:这轮测试有没有启动真实的外部资源?有没有跨进程或跨网络调用?如果答案是“是”,那它是集成测试。同一套测试代码,如果只是用内存中的 mock 对象模拟外部依赖,那还是单元测试。
集成测试覆盖的场景通常包括:HTTP handler 到数据库的完整链路、服务之间的 RPC 调用、消息队列的发布和消费、定时任务依赖的配置中心、缓存击穿后的回源逻辑等。这些场景如果只靠单元测试,容易漏掉字段名大小写不一致、序列化标签错误、事务超时、连接池耗尽这类问题。
我曾遇到一个典型的例子:单元测试里 mock 的 User 结构体和真实接口返回的 JSON 字段名差了 json:"user_name" 和 json:"username",单测全绿,联调时数据全丢。原因就是 mock 没有经过真实 JSON 编解码。从那之后,凡是涉及反序列化的函数,我都至少写一个用真实 JSON 字符串做输入的用例,有条件就上集成测试。
4.2 用 build tag 隔离集成测试
Go 里没有专门的集成测试目录,社区常见做法是用 build tag 把集成测试和普通单测分开。约定在集成测试文件第一行写:
go复制//go:build integration
// +build integration
测试文件顶部声明了这个 build tag 之后,普通的 go test ./... 不会编译它,运行时需要显式指定:
bash复制go test -tags=integration ./...
这种隔离方式的好处是,开发环境里跑单测速度依旧很快,CI 里可以单独跑一条集成测试流水线。需要注意的是,//go:build 和 // +build 两行都要写,前者是 Go 1.17 以后的新语法,后者是为老版本兼容。我建议统一维护在 integration_test.go 文件里,避免和普通测试文件混在一起还夹着 tag 声明,那样很容易忘。
4.3 testcontainers-go 拉起真实依赖
集成测试最难解决的是环境一致问题。最省心的方案是 testcontainers-go,它可以在测试运行时通过 Docker 拉起真实中间件,测试结束后自动销毁容器。
一个拉起 MySQL 的集成测试示例:
go复制func TestOrderRepositoryWithMySQL(t *testing.T) {
ctx := context.Background()
dbContainer, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: testcontainers.ContainerRequest{
Image: "mysql:8.0",
ExposedPorts: []string{"3306/tcp"},
Env: map[string]string{
"MYSQL_ROOT_PASSWORD": "test",
"MYSQL_DATABASE": "order",
},
WaitingFor: wait.ForListeningPort("3306/tcp"),
},
Started: true,
})
if err != nil {
t.Fatal(err)
}
defer dbContainer.Terminate(ctx)
mappedPort, err := dbContainer.MappedPort(ctx, "3306/tcp")
if err != nil {
t.Fatal(err)
}
dsn := fmt.Sprintf("root:test@tcp(127.0.0.1:%s)/order?parseTime=true", mappedPort.Port())
// 连接数据库,执行测试
}
这里的关键是 WaitingFor,容器启动到能接受连接通常有一段时间,不能拉起来立刻连,要等待端口监听。容器启动时间通常在几秒到十几秒,比直接连本机数据库慢,所以集成测试建议不要在代码提交时每次都全量跑,而是按需触发。
testcontainers 不是唯一选择,如果团队已经有稳定的测试数据库或 docker-compose 环境,可以自己管理生命周期。但 testcontainers 的优势是测试用例和依赖定义写在一起,任何人都能跑出同样的环境,不会出现“我本机能过,你本机不行”的扯皮问题。
4.4 集成测试的失败排查技巧
集成测试的失败往往比单测难查,因为涉及的服务可能不止一个。我总结的排查顺序是:先看测试进程自己的日志,再看依赖服务的日志,最后才怀疑业务代码。为此,建议在测试里多打一些关键路径日志,至少要能看到“当前连的是哪个地址、准备执行什么操作”。
还有一个常用做法是给整个测试设超时,防止某一个依赖一直挂起:
go复制err := testContainer.Terminate(ctx)
if err != nil {
t.Logf("terminate container failed: %v", err)
}
在 CI 里,集成测试跑挂时最需要的是现场,所以不要急着清理容器。把 terminate 放到 defer 里执行没问题,但可以在测试对象上保存容器 ID,失败时先输出 docker logs 再清理。这个操作能帮你省掉大量“本地复现不出来”的时间。
5. 测试工具链:断言、race、覆盖率与持续集成
单测和集成测试都写完后,工具链配套决定了测试跑起来舒不舒服。我按使用频率把常用工具排一下:断言库、race 检测、覆盖率、测试输出格式化、CI 报告转换。
5.1 断言库:testify/require 与 go-cmp
标准库只提供 Errorf,断言可读性确实一般。testify 的 assert 和 require 是社区最常用的补充。区别在于:assert.Equal(t, want, got) 失败后继续执行当前测试;require.Equal(t, want, got) 失败后立即终止该测试。大多数情况下,一旦某个关键步骤失败,后续断言已经没有意义,所以我更常用 require。
testify 虽然方便,但底层用 reflect.DeepEqual 比较结构体,遇到包含未导出字段的结构体时,输出差异不够直观。这个场景我推荐 go-cmp,它来自 Google,专门做结构体比较,输出的是两个值之间字段级别的差异。例如:
go复制if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("mismatch (-want +got):\n%s", diff)
}
对比一下,两个长结构体 assert.Equal 失败时可能只是打印整段内容,肉眼很难定位差异;go-cmp 只会输出有差异的字段路径和变更方向,排错效率高很多。所以我的选择很简单:结构体比较用 go-cmp,基础类型和错误断言用 testify/require。
5.2 跑测试时我必加的几组参数
日常执行测试时,我会习惯性带上几组参数:
| 参数 | 作用 | 说明 |
|---|---|---|
-race |
检测数据竞争 | 开启后会产生并发安全性报告,是并发代码的必备项 |
-count=1 |
禁用测试缓存 | 避免测试结果被 Go 缓存导致“改代码后测试没真正跑” |
-shuffle=on |
随机打乱测试顺序 | 暴露测试用例之间的隐藏依赖 |
-failfast |
首个失败后停止 | 排查问题时能快速定位 |
-timeout=30s |
设置测试超时 | 防止测试死循环拖垮 CI |
完整命令通常是:
bash复制go test ./... -race -count=1 -shuffle=on -timeout=120s
其中 -race 在 CI 里建议单独跑,因为它会显著增加执行时间。-count=1 是我被坑过之后养成的习惯,后面会详细展开。
5.3 让测试结果在 CI 里真正可读
CI 里跑测试不只看红绿,还要看失败信息是否方便定位。默认的 go test 输出是逐行 --- FAIL: TestXxx,多人协作时很难快速筛选。gotestsum 是一个测试日志格式化工具,可以在测试过程中输出更友好的进度条和失败摘要,也能生成 JUnit XML 报告。
Jenkins 这类 CI 平台通常通过 JUnit 插件解析测试报告。生成命令类似:
bash复制gotestsum --junitfile report.xml -- -race ./...
这样 Jenkins 页面里就能看每个测试用例的历史结果,谁挂了、什么时候挂的、挂的是断言失败还是超时,一目了然。除了 gotestsum,go test -json 也可以输出结构化日志,配合 tparse 或自研脚本解析。工具选择不重要,关键是要有“从日志到用例”的可追溯能力。
6. 我把测试跑崩的几个经典场景:排查链路
测试代码写多了,总会遇到一些看起来“玄学”的问题。这一节总结的是我自己踩过的坑,每个都是真实现象,不是理论推演。
6.1 为什么测试时好时坏:全局状态污染
有一次我负责的服务单测在本地全绿,但只要在 CI 上跑,偶尔会有两三个用例失败,重跑又好了。最后排查发现,某个包级变量在测试中会被修改,而测试用例的执行顺序不稳定,影响了后面的用例。
问题代码大概是这样的:
go复制var defaultConfig = &Config{Timeout: 10}
func LoadConfig(overrides map[string]string) *Config {
if v, ok := overrides["timeout"]; ok {
defaultConfig.Timeout = parse(v)
}
return defaultConfig
}
第一个测试改了 defaultConfig,第二个测试又依赖默认值,于是产生脏数据。这种问题的排查链路是先看失败用例是否总是排在某个修改全局状态的用例之后。把可疑用例单独跑一遍基本都能过,放在整个包里跑就会偶发失败。
修复方式很简单:不要在测试里改包级变量,要么让函数返回新对象,要么在测试用例的 setup 里显式重置全局状态。同时用 -shuffle=on 随机化测试顺序,能更快暴露这类问题。
6.2 并行测试的坑:t.Parallel 与共享资源
t.Parallel() 能让多个测试并行执行,大幅缩短测试时间,但前提是这些测试不能碰共享资源。Go 的并行测试并非所有测试都并行,而是通过信号机制,只有显式标记了 t.Parallel() 的测试会在同一组并行集合中并行执行。
有一次我把所有测试都加了 t.Parallel(),结果很多测试依赖同一个内存缓存,测试之间互相覆盖数据,导致一片失败。排查时看到失败信息指向的数据完全对不上,最后才发现是缓存竞争。
现在的习惯是:默认不写 t.Parallel(),只有当测试确实只读、不修改共享资源时才加。如果一个包里有多个用例访问同一个数据库表,建议用串行执行,避免数据冲突。数据库集成测试如果需要并行,可以给每条用例分配独立的表名或连接,成本很高,一般不值得。
6.3 -count=1:别再让缓存坑你
Go 在重复运行测试时,如果测试程序没有变化,会直接复用上一次的测试结果,日志里还会出现 (cached)。这个设计本来是为提速,但开发时容易误判:明明改了被测源码,重跑 go test 还是显示缓存,测试结果还是老的。
我遇到过团队里同事改了一个 function,跑 go test 发现没失败,以为没问题,结果缓存的是改之前的测试。从那以后,我本地跑测试一律带 -count=1,CI 脚本里也默认加上。这个参数强制禁用缓存,确保每次测试都是真实执行。
6.4 集成测试超时,第一个排查的不是代码
集成测试超时,很多人第一反应是代码死循环或者数据库链接有问题,但我现在会先去检查依赖服务是否真的就绪。testcontainers 拉起 Redis 后,如果 WaitingFor 只监听了端口,而 Redis 还没完成初始化,测试连接时可能直接超时。
解决办法是设置更可靠的等待条件。比如 MySQL 容器不仅要等端口监听,还要等待可以执行 SELECT 1;Redis 容器可以多试几次 Ping。testcontainers 提供了 wait.ForSQL、wait.ForLog 等模式,比单纯监听端口稳定得多。
go复制wait.ForSQL("3306/tcp", "mysql", func(host string, port nat.Port) string {
return fmt.Sprintf("root:test@tcp(%s:%s)/order?parseTime=true", host, port.Port())
})
这里的关键是:等待条件应该模拟实际连接方式,而不是只等端口开放。端口虽然监听了,但数据库可能还没完成初始化和建表,直接拿业务代码去连当然会超时。排查链路的顺序应该是:容器日志 -> 依赖日志 -> 测试日志 -> 连接参数 -> 业务代码。
7. 测试金字塔在 Go 项目里怎么落地
前面讲了很多工具和踩坑,最后聊一下项目层面的策略。测试金字塔在 Go 项目里的体现是:大量单元测试、适量集成测试、少量端到端手工冒烟。具体比例不用死板,但优先级一定要有。
7.1 先写哪一类测试:从核心业务逻辑开始
如果你的项目刚起步,测试资源有限,不要把精力平均分配到所有模块。我建议按以下顺序:
- 第一优先:核心业务逻辑,比如订单金额计算、状态机流转、权限判断。这些代码通常没有外部依赖,最适合写表驱动单测,覆盖率也容易提高。
- 第二优先:HTTP handler 和 service 层,用 httptest 加 fake 依赖把请求、响应、错误链路跑通。
- 第三优先:数据库访问层和跨服务调用,这类代码需要集成测试,可以在 CI 里单独跑。
不要在 Web handler 上追求 100% 覆盖率,因为 handler 的职责是解析参数、调用 service、返回响应。真正容易出错的业务规则在 service 层。把 service 层的分支测透,比纠结 handler 里的某个错误分支值多少钱有意义得多。
7.2 哪些代码值得写测试,哪些不该硬写
不是所有代码都需要测试。比如:没有逻辑的 DTO 转换、纯配置加载、框架生成的代码,这些测试成本高、收益低。相反,有复杂条件判断的代码、跨模块协作的代码、容易被重构破坏的代码,一定要有测试保护。
我自己的经验是,用“重构恐惧感”来判断:改一段代码时,如果心里发慌,不知道会不会破坏现有行为,那它就需要补测试;如果改起来完全没压力,说明它可能已经足够简单,或者被更上层的测试覆盖了。测试不是越多越好,而是要在关键路径上形成安全网。
最后分享一个个人体会:Go 的测试生态已经足够成熟,工具选型不需要一步到位。先写基础单测,跑通 go test,再把 testify 和 go-cmp 加入断言层,等真正遇到外部依赖时再引入 mock 和 testcontainers。每一步都是因为痛了才加,不要为了用工具而用工具。测试代码和业务代码一样,也是需要维护的资产,写得清晰、跑得稳定,才能让你在后续迭代中越走越快。
