学完这篇,你能把一堆 if 分支的测试从「复制粘贴一堆 TestXxx」改写成一张表格,并且会用 Go 自带的工具量出真实覆盖率、看懂覆盖率报告里哪几行没被测到。
第一步:准备一个分支足够多的被测函数
先拿一个真实会写到的函数当靶子:用户名校验。它有长度下限、上限和字符集三类分支,天然适合表格化。
// user.go
package user
import (
"errors"
"strings"
"unicode/utf8"
)
var (
ErrTooShort = errors.New("用户名至少 3 个字符")
ErrTooLong = errors.New("用户名最多 20 个字符")
ErrBadChar = errors.New("用户名只能包含字母、数字和下划线")
)
func ValidateUsername(s string) error {
s = strings.TrimSpace(s)
n := utf8.RuneCountInString(s)
if n < 3 {
return ErrTooShort
}
if n > 20 {
return ErrTooLong
}
for _, r := range s {
ok := r == '_' ||
(r >= '0' && r <= '9') ||
(r >= 'a' && r <= 'z') ||
(r >= 'A' && r <= 'Z')
if !ok {
return ErrBadChar
}
}
return nil
}
注意:这里刻意用 utf8.RuneCountInString 而不是 len(s)。中文一个字符占 3 字节,用 len 统计长度会让「张三」这种 2 个字的名字被判定为 6 个字符,测试用例会写成错的。
第二步:用结构体切片描述所有用例
核心思路只有一句话:用例是数据,测试逻辑只写一遍。
// user_test.go
package user
import (
"errors"
"strings"
"testing"
)
func TestValidateUsername(t *testing.T) {
tests := []struct {
name string
in string
want error
}{
{"正常值", "alice_01", nil},
{"最短边界", "abc", nil},
{"最长边界", strings.Repeat("a", 20), nil},
{"太短", "ab", ErrTooShort},
{"超长", strings.Repeat("a", 21), ErrTooLong},
{"空串", "", ErrTooShort},
{"纯空格", " ", ErrTooShort},
{"首尾空格被裁剪", " bob ", nil},
{"中文", "张三丰", ErrBadChar},
{"含连字符", "a-b", ErrBadChar},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ValidateUsername(tt.in)
if !errors.Is(got, tt.want) {
t.Fatalf("ValidateUsername(%q) = %v, want %v", tt.in, got, tt.want)
}
})
}
}
跑起来:
go test -v -run TestValidateUsername
每个用例会独立成一行输出(--- PASS: TestValidateUsername/最短边界),哪个边界挂了,名字直接告诉你,不用翻代码数第几个 case。
几个约定值得固定下来:
name 字段放第一位,方便 -run 精确定位单个用例,比如 -run 'TestValidateUsername/超长'。
- 断言错误用
errors.Is 比对哨兵错误,不要用字符串比较,否则改了错误文案测试就全红。
- 判定用
t.Fatalf 而不是 t.Errorf:一个用例就是一次独立运行,失败了没必要继续往下走。
- 表里放
wantErr bool 也行,但用 want error 能顺带测出「返回了错误但不是这个错误」这种更细的问题。
注意:如果你用的是 Go 1.22 之前的版本,t.Run 里直接引用 tt 会踩循环变量共享的坑——所有子测试拿到的是最后一个用例。老版本要写 tt := tt 拿到闭包里;Go 1.22 起每轮循环新建变量,不需要这一行。
第三步:量覆盖率
跑测试时加 -cover 就能看到包级覆盖率:
go test -cover ./...
想看具体哪几行没跑到,生成 profile 再看:
go test -coverprofile=cover.out ./...
go tool cover -func=cover.out # 按函数列出覆盖率
go tool cover -html=cover.out # 浏览器打开,未覆盖行标红
-html 出来的页面是最有用的:红色块就是没执行过的语句。对着它补用例,比凭感觉猜快得多。
另外两个参数顺手记一下:
go test -covermode=count -coverprofile=cover.out ./... # 记录每行被执行几次
go test -race -covermode=atomic ./... # 配合竞态检测时必须用 atomic
注意:覆盖率只统计「语句有没有被执行」,不统计「断言写得对不对」。一个只调用函数、从不检查返回值的测试,照样能刷出 100%。别把覆盖率当成质量指标,它只说明你没测到的代码在哪。
注意:-coverprofile 写文件时,包级别默认只输出被测包;如果仓库有多个包,建议加上 ./... 并用 go tool cover -func 汇总,否则容易只看单个包就误以为整体达标。
小结
- Table-driven 的本质是把用例抽成结构体切片,测什么全在表里,测试逻辑只维护一份。
- 每个用例用
t.Run 包成子测试,失败时名字直接定位问题;Go 1.22 之前记得处理循环变量捕获。
- 边界值(最短、最长、空串、纯空格、非 ASCII)一定要进表,这是最容易出 bug 的地方。
- 覆盖率用
go test -cover 看数字,-coverprofile + go tool cover -html 看具体哪行没跑到。
- 覆盖率是找漏测的工具,不是 KPI;100% 覆盖不代表逻辑正确,只代表每行都跑过。