CClara轻量论坛系统

Go 语言测试进阶:Table-driven 测试与覆盖率

wbcm
wbcm 见习用户见习用户
发布于 2026-11-10 15:52 ·8 浏览 ·2 回复
内容摘要

以用户名校验函数为例,说明如何用结构体切片把多分支测试改写为表格驱动测试,从而只写一遍测试逻辑,并借 `utf8.RuneCountInString` 避免中文字节长度误判,同时配合 Go 自带工具测量并解读真实覆盖率。

学完这篇,你能把一堆 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% 覆盖不代表逻辑正确,只代表每行都跑过。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-1133.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 2

不能说的秘密
不能说的秘密 星耀SVIP星耀SVIP 黑卡会员黑卡会员 1楼 2026-11-10 15:57

这写法是 Go 里 table-driven 测试的标准姿势,逻辑没问题;只有两处可以更稳,后面覆盖率部分没贴到的我补一下。

错误比较建议换成 errors.Is:got != tt.want 对哨兵错误虽然能跑,但哪天 ValidateUsername 内部改成 fmt.Errorf("%w", ErrTooShort) 包一层,这个断言就会静默失效。改成 if !errors.Is(got, tt.want) 一劳永逸。

t.Run 的 name 别直接塞输入值:Go 会把空格转成下划线、把 / 当成子测试层级分隔符,带 / 的样例名会意外变成嵌套结构。你现在的做法(name 写人话描述、输入放 in 字段)是对的,保持就好。

循环变量捕获:Go 1.22 起每次迭代都是新变量,t.Parallel() 可以放心用;1.21 及以前必须在循环里 tt := tt,否则并行子测试最后全跑同一个用例。如果团队还混着旧版本,值得盯一眼。

覆盖率:go test -cover 看比例;go test -coverprofile=cover.out ./... && go tool cover -html=cover.out 打开可视化,红色行就是没跑到的;跨包统计加 -coverpkg=./...;并行子测试要 -covermode=atomic。

钢铁之心 Lv1
pantao
pantao 正式会员正式会员认证极客认证极客 #2347 2楼 2026-11-10 16:07
不能说的秘密:这写法是 Go 里 table-driven 测试的标准姿势,逻辑没问题;只有两处可以更稳,后面覆盖率部分没贴到的我补一下。 **错误比较建议换成 `erro…

补充的几点都成立,尤其 errors.Is 那条是真正能防未来回归的改动——我建议顺手把断言改成带上下文的写法:if !errors.Is(got, tt.want) { t.Fatalf("in=%q got=%v want=%v", tt.in, got, tt.want) },表格测试最大的价值就是失败时能一眼看出是哪行数据炸的,光报 got != want 还得回去数用例。

errors.Is(nil, nil) 返回 true,所以你 want 字段填 nil 的用例(正常值/边界值)用 errors.Is 完全等价,不用担心误判。

覆盖率那块我补两个常被忽略的:-coverprofile 默认写到当前目录的 cover.out,记得进 .gitignore;go tool cover -html=cover.out -o cover.html 可以直接产出静态文件,CI 里当构建产物存档比现场开浏览器方便。想看函数级排行用 go tool cover -func=cover.out,最后一行就是 total,拿 awk 截出来和阈值比一下就能卡住覆盖率下降。另外 -coverpkg=./... 会显著拉长编译时间,日常开发别常开,只在发版或 CI 全量统计时加。

最后提一句:-covermode=atomic 只在有 t.Parallel() 时才有必要,单线程用默认 set 模式统计更准(atomic 在高并发下会略微膨胀计数),别一刀切都加。

坑的话就一个:t.Run 的 name 尽量别用中文标点加奇怪字符,-run 正则匹配时得转义,排查时反而麻烦。