1. 先想清楚:Go项目到底需要什么层级的测试
写Go测试之前,我建议你先冷静下来想一个问题:你的项目真的需要95%的代码覆盖率吗?答案是大概率不需要。这个问题的背后,其实是整套测试策略的取舍。
我在维护多个Go服务之后,最大的体会是:Go语言本身把测试的门槛压得非常低,go test一条命令就能跑完所有东西,但这反而让人容易犯一个错误——把测试当成“凑数”。很多人写完一个函数,顺手补个Test,覆盖率看着挺漂亮,可一到改需求、做重构的时候,测试全红,还都是因为测试写得太死、耦合太深,这时候才意识到测试设计比测试数量重要得多。
所以这篇内容我不会只跟你聊怎么写testing包的基础用法,我想把这些年落地Go测试时真正踩过的坑、验证过有效的模式,尽量完整地捋一遍。包括测试分层怎么切分、表驱动测试到底好在哪、mock什么时候该用什么时候不该用、httptest怎么做接口测试、覆盖率这个数字怎么解读、fuzz和benchmark怎么在日常开发里为我所用,最后说说CI里怎么让测试真正卡住质量,而不是变成摆设。
先把这个基础打牢:Go的测试文件必须以_test.go结尾,并且放在被测代码同一个包或_test包下。测试函数签名必须是func TestXxx(t *testing.T)。这是Go测试的地基,后面所有内容都建立在这套约定上。你不需要额外装什么框架,标准库testing配合go test已经能覆盖绝大多数场景。
但go test能跑,和你的测试真的有价值,这是两码事。我见过不少项目,测试代码足足写了几千行,但每次CI跑完都秒绿,因为测试根本没断言任何有意义的东西,或者是mock mock到把整个逻辑都“替换”掉了,测了个寂寞。要避免这种情况,关键是从设计层面就想清楚:每个测试到底在验证什么行为,而不是在验证代码本身长什么样。
2. 单元测试换来的是一种安全网
很多人在纠结要不要写单测、覆盖率多高才达标。我的看法是:单元测试真正的价值不是那串覆盖率数字,而是在你改代码的时候能放心。它给你一张安全网,让重构、调优、修bug的时候敢下手。拿我自己的经历来说,一个模块能跑起来不难,难的是三个月后需求变了,你要在不动外部行为的前提下调整内部实现——这时候单测就是你最可靠的底牌。
2.1 表驱动测试:Go生态里最值得养成的习惯
表驱动测试(table-driven tests)是我在Go项目里最推荐、也最常用的模式。Go社区对这个模式几乎有共识,标准库里大量测试都是这么写的。它的核心思想很简单:把测试用例的输入、期望输出、可能需要的额外参数,都定义成一张表(一个结构体切片),然后循环跑每一个用例。
func TestParseDuration(t *testing.T) { tests := []struct { name string input string want time.Duration wantErr bool }{ {"空字符串返回错误", "", 0, true}, {"纯数字被当作秒", "300", 5 * time.Minute, false}, {"标准时间格式", "1h30m", 90 * time.Minute, false}, {"非法后缀报错", "10x", 0, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := ParseDuration(tt.input) if tt.wantErr { if err == nil { t.Fatalf("期望报错,但没有错误返回") } return } if err != nil { t.Fatalf("期望成功,却返回错误: %v", err) } if got != tt.want { t.Errorf("ParseDuration(%q) = %v, want %v", tt.input, got, tt.want) } }) } }注意上面几个细节,这些都是我踩过坑换来的:
- 每个用例都带
name字段,并且用t.Run包一层子测试。这样单个用例失败时,你能直接从输出里定位是哪一个场景挂了,而不是只看到第几行断言失败。go test -run TestParseDuration/非法后缀报错还能单独跑某一条,调试体验天差地别。 - 期望值和实际输出一起打出来。
t.Errorf里带上入参、期望、实际三样东西,看到日志的人不需要翻代码就能知道发生了什么。 - 错误场景和成功场景分开断言。不要在错误分支里还去比较
got值,因为出错时got通常是个零值,比较起来没有意义。
表驱动的好处在于,新增一个边界条件就是往切片里加一项,测试代码不用大改。时间长了,这张表本身就变成了一份非常清晰的“行为规格说明书”,新同事接手的时候,看表就明白这个函数支持什么、拒绝什么。
2.2 什么时候需要mock,什么时候坚决不mock
mock是单元测试里最容易走火入魔的地方。我的原则很简单:跨进程的依赖才mock,进程内的依赖用真实实现。
举个例子,如果你的函数需要读数据库、调外部HTTP接口、发消息队列,那写单测时确实应该mock掉这些依赖,否则你的测试就得依赖数据库和网络环境,跑起来又慢又脆。但如果你的代码只是调用同项目里另一个纯函数,那就不需要mock——直接用真实的就行,mock纯函数只会让测试和实现绑得更紧、改起来更痛苦。
Go里做mock的方式常见有三种:手写接口实现、使用gomock生成代码、使用testify/mock。我个人的经验是:如果项目本身不大,手写一个简单的fake实现往往是最省事的;如果接口很多、团队也习惯TDD,那gomock的代码生成能力能帮你省下大量重复劳动。
type UserRepository interface { GetByID(ctx context.Context, id int64) (*User, error) } type fakeUserRepo struct { users map[int64]*User err error } func (f *fakeUserRepo) GetByID(ctx context.Context, id int64) (*User, error) { if f.err != nil { return nil, f.err } return f.users[id], nil }手写fake的好处是清晰、直观、不引入额外依赖,测试数据捏在测试代码里,一眼就能看懂。mock框架的强项是你能精确控制某个方法的调用次数、传入参数,适合验证复杂的调用时序。但代价是生成的代码可读性差,测试失败时的报错信息也绕,新人排查起来费劲。
另外一点很重要:interface要定义在使用方,不要定义在实现方。Go的惯例是“接口由消费者定义”,这样才能解耦。很多人一上来就给所有服务都定义接口,然后疯狂mock,结果接口方法数量膨胀、每个方法都返回error,mock代码比业务代码还多,这就是过度设计了。
2.3 覆盖率数字要看,但别太当真
go test -cover会给你一个覆盖率百分比,go tool cover -html=cover.out可以生成可视化的覆盖报告。覆盖率能告诉你的只是“哪些代码被执行过”,它不告诉你“执行得正不正确”。
我见过一个项目,覆盖率做到了85%,但所有if的else分支都没走到,错误处理路径基本裸奔。因为测试数据全是happy path,根本没人构造异常输入。覆盖率这玩意儿更像是一个给管理层的安心指标,真正有价值的反而是打开html报告,仔细看看那些红着的块——它们才是最容易藏bug的地方。
实操建议:把覆盖率阈值设置在一个让你难受但又不会逼着大家写废测试的水平。比如新项目可以设60%的硬门槛,通过go test -coverprofile=cover.out -covermode=atomic在CI里校验。但更重要的是定期人工检查覆盖报告里那些“红色的角落”,而不是无条件追逐100%。
3. 经典误区:把测试全压在同一层
如果整个项目的测试只有单测,那你测出的是“每块砖的合格性”,但“整面墙会不会倒”是另一回事。单元测试跑得飞快,但它天然无法发现模块之间的协作问题、配置问题、环境问题。所以我一般会把测试分成三层:单元测试、集成测试、端到端测试。Go里跑这三层的方式不一样,但可以用同一个go test命令管理。
3.1 用build tag区分测试层级
一个很实用的小技巧:用//go:build integration这样的build tag把集成测试和普通单元测试分离开。默认go test ./...只跑不带tag的测试,需要跑集成测试时,额外执行go test -tags=integration ./...。
//go:build integration package user_test import ( "testing" "time" ) func TestUserRepositoryIntegration(t *testing.T) { // 这里可以放心地连真实数据库、真实Redis dsn := os.Getenv("TEST_DATABASE_DSN") if dsn == "" { t.Skip("未设置TEST_DATABASE_DSN,跳过集成测试") } // 测试逻辑... }另外建议集成测试里尽量配合t.Skip做环境判断。这样即使有人忘了加-tags=integration,也不会导致整条CI挂掉,最多是测试被跳过。等到你真正想跑的时候,准备好环境变量,一次就能全部跑起来。
这种分层的核心价值是:让高频测试跑得快、让低频测试跑得深。日常开发、提交代码时,只跑单测,几秒钟就出结果;合并请求或发版前,再跑完整的分层测试。如果你把数据库依赖、网络请求全部混进单测,那么你的开发循环会越来越慢,最后大家就不愿意跑测试了。
3.2 httptest:给HTTP接口写测试的标准姿势
Go标准库的net/http/httptest是写HTTP接口测试的神器,不依赖任何第三方库,就能起一个真实的HTTP服务、发起真实请求、校验真实响应。我经常用它来做两种事情:
- 测试自己的handler;
- 在测试里启动一个假的外部服务,代替真实第三方API。
func TestUserHandler_GetUser(t *testing.T) { handler := NewUserHandler(fakeUserService{}) srv := httptest.NewServer(handler) defer srv.Close() resp, err := http.Get(srv.URL + "/users/42") if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("期望200,实际 %d", resp.StatusCode) } var got UserDTO if err := json.NewDecoder(resp.Body).Decode(&got); err != nil { t.Fatal(err) } if got.ID != 42 { t.Errorf("期望ID=42,实际 %d", got.ID) } }这里你注意到没有,测试里用的是http.Get这种真实HTTP调用,而不是直接调handler函数。这是有意为之,因为httptest.NewServer能把你整个路由、中间件、参数绑定、序列化全部串起来一起测,而不是只测一个孤零零的函数。我见过很多人写handler测试,直接用httptest.NewRecorder调handler,这么写也不是不行,但它绕过了中间件和路由,和真实请求的差距就大了。
还有一点:如果你要mock一个外部服务,比如你依赖了某个支付平台的接口,单测里可以用httptest.NewServer返回写死的JSON,这样CI不需要联网、也不依赖第三方稳定性和沙箱额度,就能把“调用外部服务成功/失败/超时”这些分支全部测到位。
3.3 临时目录与数据库测试:避免测试垃圾
测试最忌讳的是“跑完一次,环境变脏了”。比如测试里创建了一个用户,但没清理,第二次跑的时候用户名就冲突了。应对思路有两个:
- 涉及文件系统:尽量用
t.TempDir()创建临时目录。测试结束自动清理,不用你自己写defer os.RemoveAll,省心且安全。 - 涉及数据库:优先设计成“事务回滚”或“每条用例独立数据”。事务回滚的做法是:测试开始前开启一个事务,所有操作都在这个事务里执行,测试结束时直接Rollback。这样既不污染数据库,又不影响并发跑测试。
func TestUserRepository_Create(t *testing.T) { db := mustOpenTestDB(t) tx := db.Begin() defer tx.Rollback() repo := NewUserRepository(tx) err := repo.Create(ctx, &User{Name: "alice"}) if err != nil { t.Fatal(err) } // 在这个事务内部做断言 }t.TempDir()和事务回滚这套组合,是我在项目里用得最频繁的“防脏”手段。你只要把测试数据隔离做得足够好,测试就能支持并行执行,速度能快非常多。
4. 高级测试技巧:模糊测试、基准测试与自定义错误
4.1 模糊测试发现你想象不到的崩溃
Go 1.18起,标准库原生支持fuzz testing,这东西我真的推荐每个项目都试试。它和普通单元测试的核心区别是:你不用手动给输入,fuzzer会自动生成一堆边界输入、随机字符串、极端数值,然后把任何导致panic、断言失败、死锁的输入记录下来。
func FuzzParseTime(f *testing.F) { // seed corpus:提供一些合理的初始输入 f.Add("2006-01-02 15:04:05") f.Add("2024-07-11T00:00:00Z") f.Add("not a time") f.Fuzz(func(t *testing.T, input string) { // 我们要验证的核心性质:只要没报错,解析出来的时间就一定不是零值 parsed, err := time.Parse(time.RFC3339, input) if err == nil { if parsed.IsZero() { t.Errorf("解析成功却是零值: %q", input) } } }) }执行方式也简单:go test -fuzz=FuzzParseTime -fuzztime=30s。它会一直跑,把你暴露出来的崩溃信息写入testdata/fuzz/目录。之后每次go test都会自动重放这些崩溃样本,确保bug不会再回来。
这里要敲个黑板:fuzz适合的是有复杂、不可预测输入的函数,比如解析器、反序列化器、命令行参数处理。对于“输入是整数,逻辑是加法”这种,fuzz纯属浪费算力。另外fuzz测试跑起来会吃CPU,时间控制在30秒到几分钟即可,不要无脑跑半小时。
4.2 benchmark:别靠感觉调性能
Go标准库的testing.B提供了基准测试能力,它解决的问题非常实在:你的代码到底慢了没有,慢了百分之几。我调过的一个JSON解析模块,凭感觉优化了三轮,结果每次“优化”之后性能变化都在噪声范围以内,根本说不清有没有效果。后来老老实实写benchmark,才真正量化出热点在哪。
func BenchmarkParseJSON(b *testing.B) { data := []byte(`{"name":"alice","age":30,"tags":["go","test"]}`) b.ResetTimer() for i := 0; i < b.N; i++ { var v User if err := json.Unmarshal(data, &v); err != nil { b.Fatal(err) } } }运行方式是go test -bench=. -benchmem。输出会告诉你每次操作耗时和每次分配的内存字节数。-benchmem特别重要,很多性能问题不是耗时长的,而是分配了大量的临时对象,给GC带来了压力。
两个实操经验:
- 如果代码里有需要预热缓存的逻辑,记得
b.ResetTimer()在准备数据后再开始计时,否则准备阶段的时间也被计入。 - 做对比时,尽量用
BenchmarkFoo-8这样的并发基准,或者在不同b.N量级下多跑几次。单次跑出来的数字不可信,至少要跑三次以上,看方差大不大。
4.3 自定义error在测试中的注意细节
Go的error就是接口,坑也往往藏在接口里。你在测试里断言error时,最忌讳的是直接if err != nil完事,不去检查错误类型或错误内容。常见的问题有:
- 使用了
errors.New,导致每次错误都是不同的实例,只能通过字符串匹配判断,非常脆弱; - 比较error时误用了
==,遇到包装过的error就直接失效; - 包装error时用
fmt.Errorf("xxx: %w", err),但上游测试里忘了用errors.Is去判断。
我在实际测试里通常会先定义好sentinel error(哨兵错误),然后在测试里用errors.Is和errors.As来断言:
var ErrUserNotFound = errors.New("user not found") func GetUser(ctx context.Context, id int64) (*User, error) { // ... if !exist { return nil, fmt.Errorf("query user: %w", ErrUserNotFound) } // ... } func TestGetUser_NotFound(t *testing.T) { _, err := GetUser(context.Background(), 9999) if !errors.Is(err, ErrUserNotFound) { t.Fatalf("期望ErrUserNotFound,实际 %v", err) } }这样写的好处是,无论上游调用了多少次fmt.Errorf包装,errors.Is都能沿着error链找到最底层的哨兵错误,测试不会被中间层包装信息干扰。如果你需要拿到某个具体类型的错误,比如一个带有状态码的结构体,那就用errors.As。
这块的测试经验,是我在处理一堆线上日志时总结出来的。很多错误在日志里能看到“pq: invalid input syntax”,但代码里根本定位不到是哪个包打出来的,就是因为所有错误都被简单透传,没做类型判断。测试里提前把这些错误链的分叉点卡住,生产环境的排查效率会高很多。
5. 从“写测试”到“测试驱动开发”的落地经验
前面几大节基本都在讲具体写法,这一节我想聊聊工作流的改变。很多人问我要不要严格TDD:先写测试,再写实现,红灯、绿灯、重构。我的观点是:不必所有代码都TDD,但对复杂难度高、修起来成本高的逻辑,TDD是性价比最高的方式。比如一个订单金额计算功能,如果你先写实现再补测试,很容易“实现怎么写,测试就怎么顺着写”,最后变成互相证明,少了独立的校验角度。如果先写测试,你被迫先想清楚输入、输出、边界、异常,再动手写实现,思路会清晰很多。
TDD的关键不在“先后顺序”,而在于写测试的时候把自己当成使用者,而不是实现者。测试不该知道函数内部用了什么数据结构、调用了哪几个私有方法,它只需要知道“给定某个输入,应该得到某个输出”。这种黑盒视角是保证测试有效性的核心。
还有一点:测试代码也是需要维护的代码。不要因为它不是生产代码就放飞自我。我见过很多人测试里大量使用魔法数值、超长函数、反复复制粘贴,结果生产代码重构了,测试代码重写的工作量比生产代码还大。测试代码同样需要遵循良好的命名规范、抽公共方法、控制复杂度。我把这称为“把测试代码当成一等公民”,你重视它,它才会真正保护你。
5.1 新代码必有测试,老代码重构时补测试
如果你在维护一个老项目,历史代码完全没有测试,怎么推测试文化?我的建议是避免“一步到位”的冲动。老代码往往耦合严重、依赖横行,你强行给它们补测试,投入产出比极低,还会让团队沮丧。更务实的策略是:
- 正在新增的功能,强制要求对应测试,哪怕一开始覆盖不全,也要有核心路径的用例。
- 在修复线上bug时,顺手把bug复现的测试写进去。这就是“回归测试”,它的价值是保证同一个坑不会踩第二次。
- 重构老代码时,先只加“特征测试”——不追求覆盖所有逻辑,只把当前行为用测试锁住,再动手改代码。改完确认测试还绿,就说明你重构没有改变外部行为。
这套节奏会让测试覆盖率曲线慢慢往上走,而不是在一次大规模补测试运动中虚假繁荣。
5.2 CI里的门禁:跑得快、又确实是真门禁
测试写好了,必须接进CI才有约束力。但CI里关于测试有一个经典的张力:跑得全、跑得稳,和跑得快、反馈及时,往往是矛盾的。我的做法是拆成两道闸:
- 合并请求(MR/PR)门禁:只跑单元测试和静态检查,控制时间在3分钟以内。这里的核心指标不是覆盖率而是“是否全绿”。如果MR动不动就要等10分钟测试出结果,开发体验会崩塌。
- 主分支发版门禁:跑完整的分层测试,包括集成测试、fuzz冒烟、覆盖率检查。这里才需要严格的标准,因为这里是发布前的最后一道关卡。
具体到命令上,我常用的CI测试脚本大概长这样:
go build ./... go vet ./... go test ./... -coverprofile=cover.out -race -count=1 go tool cover -func=cover.out | tail -n 1注意-race和-count=1这两个参数。-race会开启数据竞争检测,能抓到不少并发bug;-count=1是强制不缓存结果。Go测试默认有缓存,如果代码没变,第二次跑会直接命中缓存,看起来秒绿,但万一你想验证“在干净环境下测试能不能过”,缓存就会给你错觉。
如果你把测试跑在CI容器里,尤其是跑集成测试连数据库时,最好给每个测试都做随机数据隔离,不然并发跑顺序一乱,用例之间互相踩数据,你会被折磨死。我踩过的坑是:所有测试共用一个固定用户ID,结果两个CI Job同时跑,一个创建用户、一个删除用户,最后谁跑谁挂。
5.3 常见失败模式速查:CI挂了我一般先看什么
长期跑测试下来,你会发现90%的CI失败类型其实就那几样,我把最常见的情况整理成一张速查表:
| 症状 | 最常见原因 | 排查方向 |
|---|---|---|
| 本地能过,CI挂了 | 环境差异、测试数据未隔离 | 查测试是否依赖了本机配置、绝对路径、外部服务 |
| 偶发失败,时好时坏 | 并发冲突、时序依赖 | 查共享变量、数据库残留数据、端口冲突 |
| 加了代码却报旧测试失败 | 测试对内部实现耦合太深 | 查是否断言了私有函数、内部字段、调用次数 |
| 全绿但上线出bug | 测试覆盖的路径太浅 | 打开cover html,看红块集中在哪里 |
| 跑得越来越慢 | 类里创建真实连接、没有复用实例 | 查TestMain或setup部分,尽量复用连接 |
| 改动无关模块却导致测试挂 | 测试共享了全局状态 | 查全局变量、环境变量、单例初始化 |
我在项目里,遇到偶发失败的第一反应绝不是“重跑一次看看”,而是去查测试之间有没有隐藏的共享状态。打个比方,多个测试如果都改动了同一个全局配置对象,那测试的顺序就变成了隐式的用例依赖,这种问题是跑100次挂1次的经典来源。
另外,把测试输出的时间戳、随机数种子、端口号都打出来,对排查偶发问题帮助很大。很多隐性bug都是“无数据”状态下测不出来,一旦有随机性介入,马上暴露。
6. 实际案例:一个时间解析函数从单测到fuzz的完整套路
空谈这么多,我拿一个具体的例子串一下整套流程。假设你负责一个日志处理模块,里面有一个函数负责把各种格式的时间字符串统一解析成time.Time。这个函数已经在生产环境跑了一段时间,期间收到过好几个解析失败的工单,因为用户提交的日志时间格式五花八门,从标准RFC3339到yyyy-MM-dd HH:mm:ss,甚至还有带时区偏移但省略秒的格式。
第一步:先写表驱动单测,锁住当前已知行为。
先枚举出所有已知格式:RFC3339、2006-01-02 15:04:05、2006/01/02 15:04、2006-01-02等等。每个格式给一个成功用例,再给几个非法格式的失败用例。这一步的目标是把“已知的正确行为”固定住,避免以后改动时悄悄破坏哪个分支。
第二步:加边界case。
时间解析最容易挂的边界是:时区缺失、月份/日期越界、秒含小数、前后有空格、年份只有两位。把这些全塞进表里。特别注意:Go的time.Parse用的是参考时间2006-01-02 15:04:05 MST,这个布局字符串本身很容易写错(这也是关键字里“go语言中时间为什么是20060102”这个问题的由来),单测里如果没有一个case专门验证参考时间用得对不对,以后很容易在布局上踩坑。
第三步:跑fuzz,寻找未知崩溃。
表驱动测覆盖的是已知输入,但线上谁知道用户会传什么字符串进来。用f.Fuzz跑上30秒,往往能找到几个预期之外的panic——比如空字符串、超长字符串、只含数字的字符串。fuzz发现的crash会被自动存进testdata/fuzz/,每次go test都会重放,这样等于把“未知边界”也变成了“回归样本”。
第四步:benchmark决定是否缓存解析结果。
如果这个时间解析函数被用于高QPS的日志管道,就值得写一个benchmark。假如基准测试显示每次解析要几十微秒,你可以考虑给高频格式加一个sync.Map做解析结果缓存,然后再用benchmark验证优化是否有效。
这个例子想说明的是:测试永远不是一套固定的模板,它是一个随着你对代码理解加深而不断演化的体系。从单测到fuzz到benchmark,并不是“越高阶越好”,而是“越贴近风险越好”。风险在解析边界,就用fuzz;风险在调用频率,就用benchmark;风险在需求变更,就用表驱动单测。
7. 工程化测试的最后几块拼图
7.1 测试命名:一眼看出在测谁、测什么
测试函数的命名规范在Go里没有强制要求,但我建议长期坚持这套模式:Test_被测对象_场景_期望结果,比如TestParseDuration_非法输入_返回错误。好处是当几百个测试一起跑,某几个失败时,你从日志里一眼就能看出是哪条路径出了问题,不需要打开文件去看。
如果被测对象有多种方法,也可以用结构体或前缀进一步细分。但别搞太复杂,测试名是给人看的,简单直白、一眼能懂就行。子测试的name字段同理,记得用可读的字符串,不要用case 1、case 2这种序号。因为测试输出里会拼接父测试名和子测试名,可读的名字能让失败日志美观很多。
7.2 测试数据样本:写死的、随机的、还是生成的
构造测试数据时,我见过三种流派,各自适合不同场景:
- 手工写死的小样本:比如用户结构体、几个订单项,便于阅读和手算验证。适合表驱动单测。
- 随机生成的大样本:适合属性测试,比如你验证“任何整数经过序列化和反序列化后都不变”,那就可以随机生成几万组数据来跑。
- Go的
testing/quick和第三方go-fuzz生产的样本:适合更系统化的性质探索,但需要你定义好“不变量”是什么。
我个人的习惯是:单测里能用写死样本解决的,绝不随机化。随机化会引入一个隐藏问题——这次能过、下次随机出来一堆脏数据就挂了,别人还不好复现。只有当你明确在测某条“对任意输入都成立”的性质时,才考虑随机数据。
7.3 测试遗留问题:测试也讲究可维护性
最后唠叨一句维护性。测试代码和业务代码一样,会随着时间腐化。最常见的迹象是:一个测试改了十几个地方依旧红,或者一个失败需要看一分钟日志才明白在测什么。如果有这种感觉,别纠结,停下来重构测试代码。删除低价值测试、合并重复用例、把公共setup抽成helper函数,这些操作和重构生产代码同样重要。
我个人还习惯在项目的doc.go或CONTRIBUTING.md里写一段测试指南,说明“集成测试要用-tags=integration跑”、“数据库DSN从哪里来”、“新增用例时应该加到哪个table”。这点投入不大,但能让每个参与项目的人少走很多弯路,也让测试文化更容易在团队里生根。
回到开头那句:“Go语言的测试门槛很低”,这句话是真的,但低门槛也意味着拦不住坏味道。它给你一套足够好用的标准工具(testing、httptest、fuzz、benchmark、pprof),但没有告诉你什么时候该用哪一把。我写了这几年Go测试,最深的一个体会是:好的测试不是在特定时间点一次性写出来的,它是跟着产品演进、跟着bug记录、跟着重构历史一起长出来的。每次修完一个线上bug时补的那条回归测试,往往比最初规划的所有单测都有价值;每次重构前锁住旧行为的那套特征测试,往往是敢于动手的前提。
所以别再纠结覆盖率是不是要卡90%了。真正重要的是:让测试成为你修改代码时的安全网、排查故障时的显微镜,以及团队协作时的一本行为说明书。做到这三点,Go测试这门功课,你就过关了。