1、引言
测试是开发过程中不可或缺的一部分,也是软件生命周期中的关键环节。它能够确保应用程序正常运行,满足用户的需求。本文将基于 Go 内置的标准库 testing,从最简单的测试函数开始,逐步介绍 Go 语言代码测试的基础知识与常用技巧。
Go 中常见的测试方式有以下几种:
- 单元测试(Unit Tests):测试单个函数或方法等最小代码单元,帮助尽早发现错误。
- 表驱动测试(Table-Driven Tests):将测试用例组织成表格,便于更好地组织测试用例、探索边界情况。
- 基准测试(Benchmark Tests):用来验证代码的性能。
- 模糊测试(Fuzz Tests):通过自动生成大量随机/半随机输入,探索边界情况、发现崩溃或错误。
此外还有 测试驱动开发(Test-Driven Development,TDD):一种软件开发方法,先编写测试、再编写功能代码,通过「测试失败 → 编写代码 → 测试通过 → 重构」的循环一步步迭代,最终完成功能代码。

2、基本用法
2.1 编写一个简单的单元测试
单元测试(Unit Tests)用于测试小型代码单元,比如某个函数或方法。它能让你在早期发现错误;而且单元测试规模小、相互独立,更容易维护。
下面创建一个「字符串反转 + 回文判断」示例来练习测试。创建 utils.go 和 utils_test.go 两个文件,并在 utils_test.go 中编写一个名为 TestReverse 的测试函数。在 Go 中,测试函数必须以 Test 开头,且函数签名只有一个 *testing.T 类型的参数。
package utils
import "testing"
func TestReverse(t *testing.T) {
result := Reverse("abc")
if result != "cba" {
t.Errorf("Reverse(abc)= %s, but want %s", result, "cba")
}
}
这是一个很简单的测试函数:调用
Reverse函数,判断结果是否符合预期。
接下来先执行测试:
go test -v
输出结果:
~> go test -v
# go-test [go-test.test]
.\utils_test.go:6:12: undefined: Reverse
FAIL go-test [build failed]
在 utils.go 中编写代码,让测试通过,即简单写一个 Reverse 函数:
package utils
func Reverse(input string) string {
return input
}
继续执行测试:
go test -v
=== RUN TestReverse
utils_test.go:8: Reverse(abc)= abc, but want cba
--- FAIL: TestReverse (0.00s)
FAIL
exit status 1
FAIL go-test 0.274s
编写代码让测试通过:
package utils
func Reverse(input string) string {
output := ""
for i := len(input) - 1; i >= 0; i-- {
output += string(input[i])
}
return output
}
执行测试:
go test -v
=== RUN TestReverse
--- PASS: TestReverse (0.00s)
PASS
ok go-test 0.267s
一个最简单的单元测试就基本完成了。但是现在的测试用例太少,需要补充用例;如果继续按上面的方式补充,会导致大量重复代码、复用性极差,因此引出了「表驱动测试」。
2.2 表驱动测试
表驱动测试,顾名思义,就是把测试用例组织成一张表格,每一行包含输入和期望的输出结果。这种表格形式有两点好处:
- 表格中的测试数据复用同一套测试逻辑,使测试逻辑清晰明了;
- 能一目了然地看清测试覆盖了哪些内容,并且每个用例都有自己的名字,方便定位是哪个用例的输出出了问题。
修改 utils_test.go:
func TestReverseTable(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{"english", "abc", "cba"},
{"empty", "", ""},
{"single", "a", "a"},
{"chinese", "你好", "好你"}, // 这个会失败
{"emoji", "hello😀", "😀olleh"}, // 这个也会失败
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
res := Reverse(tt.input)
if res != tt.want {
t.Errorf("Reverse(%s)= %s, but want %s", tt.input, res, tt.want)
}
})
}
}
在上面的测试代码中,通过一个结构体切片来组织「测试名称、输入、期望输出」这三类信息,再遍历执行。t.Run 用于启动一个子测试,-run=TestReverseTable 可以指定只执行该测试。
~> go test -v -run=TestReverseTable
=== RUN TestReverseTable
=== RUN TestReverseTable/english
=== RUN TestReverseTable/empty
=== RUN TestReverseTable/single
=== RUN TestReverseTable/chinese
utils_test.go:28: Reverse(你好)= ½¥å ½ä, but want 好你
=== RUN TestReverseTable/emoji
utils_test.go:28: Reverse(hello😀)= olleh, but want 😀olleh
--- FAIL: TestReverseTable (0.00s)
--- PASS: TestReverseTable/english (0.00s)
--- PASS: TestReverseTable/empty (0.00s)
--- PASS: TestReverseTable/single (0.00s)
--- FAIL: TestReverseTable/chinese (0.00s)
--- FAIL: TestReverseTable/emoji (0.00s)
FAIL
exit status 1
FAIL go-test 0.521s
可以看到,加入中文、表情等特殊字符后测试就失败了。这是因为上面的 Reverse 是按字节反转的,而中文、表情等字符在 UTF-8 编码下由多个字节组成,按字节反转后就变成了乱码。继续修改代码,让测试通过:
package utils
// 在 Go 中有一个 rune 类型,表示一个 Unicode 码点,
// 这样无论是中文还是表情,都会以一个 rune 存在,而不是以字节来处理。
func Reverse(input string) string {
runes := []rune(input)
for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
runes[i], runes[j] = runes[j], runes[i]
}
return string(runes)
}
2.3 t.Helper() 辅助函数
t.Helper() 的作用是标记当前函数为辅助函数。加上它之后,测试失败时错误信息会跳过这个辅助函数,直接指向真正调用它的地方,让定位问题更准确。可以对比 checkResult 中 t.Helper() 注释与取消注释两种情况下的报错位置。
func TestReverseByHelper(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{"english", "abc", "cba"},
{"empty", "", ""},
{"single", "a", "a"},
{"chinese", "xx", "好你"}, // 这儿故意写错
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
checkResult(t, tt.input, tt.want)
})
}
}
func checkResult(t *testing.T, input string, want string) {
t.Helper()
result := Reverse(input)
t.Logf("Reverse(%s)= %s", input, result)
if result != want {
t.Errorf("Reverse(%s)= %s, but want %s", input, result, want)
}
}
2.4 错误和日志
testing.T 提供了多种用于与测试交互的工具,上面用到的 t.Errorf 就是其中之一,它表示将该测试标记为失败,并输出一条错误信息。
需要说明的是,t.Error* 系列并不会立即停止测试,而是在测试执行结束后统一报告失败;但有些时候希望测试立即停止,此时更适合使用 t.Fatal* 系列(它会立即结束当前测试函数)。此外,想在测试过程中输出一些信息,可以使用 t.Log* 系列。
func TestReverse2(t *testing.T) {
input := "abcd"
result := Reverse(input)
t.Logf("The input was %s", input)
if result != "dcba" {
t.Errorf("Result was incorrect, got: %s, want: %s.", result, "dcba")
}
t.Fatalf("Stop the test now, we have seen enough")
t.Error("This won't be executed")
}
单独执行这一个测试,可以看到执行 t.Fatalf 之后,后续代码就不会再执行了:
go test -v -run=TestReverse2
=== RUN TestReverse2
utils_test.go:86: The input was abcd
utils_test.go:90: Stop the test now, we have seen enough
--- FAIL: TestReverse2 (0.00s)
FAIL
exit status 1
FAIL go-test 0.622s
2.5 并行测试
默认情况下,测试是顺序执行的,但可以通过 t.Parallel() 开启并行测试。go test 会先执行完所有非并行测试,再并行执行这些调用了 t.Parallel() 的测试;并行数量由 -parallel 标志控制,默认值等于 GOMAXPROCS(默认等于机器可用的 CPU 数量)。
下面的例子演示在子测试中使用并行。若在包级测试中使用 t.Parallel(),go test 会先执行该包中的其他非并行测试,再并行执行并行测试。
包(package)
└── 顶层测试(也叫包级别测试)
└── TestXxx 函数 ← 这就是“包级别 / 顶层测试”
├── t.Run("子测试1", ...) ← 子测试
├── t.Run("子测试2", ...) ← 子测试
└── t.Run("子测试3", ...) ← 子测试
└── 还可以再嵌套 t.Run(更深层的子测试)
下面通过构建几个测试的例子,来查看具体的运行情况:
func TestReverseParallel(t *testing.T) {
// 串行子测试
t.Run("sequential_1", func(t *testing.T) {
start := time.Now()
t.Logf("sequential_1 start: %v", start.Format("15:04:05.000"))
time.Sleep(200 * time.Millisecond) // 模拟耗时
result := Reverse("abc")
if result != "cba" {
t.Errorf("got %q, want %q", result, "cba")
}
t.Logf("sequential_1 end: %v", time.Now().Format("15:04:05.000"))
})
// 并行子测试
t.Run("parallel_1", func(t *testing.T) {
t.Parallel()
start := time.Now()
t.Logf("parallel_1 start: %v", start.Format("15:04:05.000"))
time.Sleep(300 * time.Millisecond)
result := Reverse("abc")
if result != "cba" {
t.Errorf("got %q, want %q", result, "cba")
}
t.Logf("parallel_1 end: %v", time.Now().Format("15:04:05.000"))
})
t.Run("parallel_2", func(t *testing.T) {
t.Parallel()
start := time.Now()
t.Logf("parallel_2 start: %v", start.Format("15:04:05.000"))
time.Sleep(300 * time.Millisecond)
result := Reverse("hello")
if result != "olleh" {
t.Errorf("got %q, want %q", result, "olleh")
}
t.Logf("parallel_2 end: %v", time.Now().Format("15:04:05.000"))
})
// 再跟一个串行子测试(可选,用来观察顺序)
t.Run("sequential_2", func(t *testing.T) {
start := time.Now()
t.Logf("sequential_2 start: %v", start.Format("15:04:05.000"))
time.Sleep(100 * time.Millisecond)
result := Reverse("xyz")
if result != "zyx" {
t.Errorf("got %q, want %q", result, "zyx")
}
t.Logf("sequential_2 end: %v", time.Now().Format("15:04:05.000"))
})
}
执行命令观察结果 go test -v -run=TestReverseParallel,可以看到先执行了所有非并行测试:
go test -v -run=TestReverseParallel -parallel=2
=== RUN TestReverseParallel
=== RUN TestReverseParallel/sequential_1
utils_test.go:99: sequential_1 start: 12:30:34.262
utils_test.go:105: sequential_1 end: 12:30:34.481
=== RUN TestReverseParallel/parallel_1
=== PAUSE TestReverseParallel/parallel_1
=== RUN TestReverseParallel/parallel_2
=== PAUSE TestReverseParallel/parallel_2
=== RUN TestReverseParallel/sequential_2
utils_test.go:136: sequential_2 start: 12:30:34.483
utils_test.go:142: sequential_2 end: 12:30:34.583
=== CONT TestReverseParallel/parallel_1
utils_test.go:112: parallel_1 start: 12:30:34.583
=== CONT TestReverseParallel/parallel_2
utils_test.go:124: parallel_2 start: 12:30:34.583
utils_test.go:130: parallel_2 end: 12:30:34.885
=== NAME TestReverseParallel/parallel_1
utils_test.go:118: parallel_1 end: 12:30:34.885
--- PASS: TestReverseParallel (0.32s)
--- PASS: TestReverseParallel/sequential_1 (0.22s)
--- PASS: TestReverseParallel/sequential_2 (0.10s)
--- PASS: TestReverseParallel/parallel_2 (0.30s)
--- PASS: TestReverseParallel/parallel_1 (0.30s)
PASS
ok go-test 0.881s
2.6 跳过测试
测试通常分为单元测试和集成测试。顾名思义,集成测试用于测试多个函数、组件之间的协同工作,执行速度较慢。有时只想执行单元测试,就可以用 t.Skip() 跳过指定测试。
go test 支持 -short 标志,当命令行传入该标志时,可以在代码中通过 testing.Short() 来捕获,从而在短模式下跳过耗时测试。
func TestReverseSkiped(t *testing.T) {
if testing.Short() {
t.Skip("skipping test in short mode.")
}
result := Reverse("abc")
if result != "cba" {
t.Errorf("Reverse(abc)= %s, but want %s", result, "cba")
}
}
执行测试 go test -run=TestReverseSkiped -v -short:
~> go test -run=TestReverseSkiped -v -short
=== RUN TestReverseSkiped
utils_test.go:148: skipping test in short mode.
--- SKIP: TestReverseSkiped (0.00s)
PASS
ok go-test 0.258s
2.7 清理与回收
测试过程中常会遇到资源回收的需求,比如删除临时文件/目录、关闭资源等。testing.T 提供了 t.Cleanup(),注册的清理函数会在测试结束时自动执行。
func TestWriteFile(t *testing.T) {
// 创建临时目录
dir, err := os.MkdirTemp("", "test-*")
if err != nil {
t.Fatal(err)
}
t.Log("临时目录位置:", dir)
// 注册清理函数,测试结束后自动删除临时目录
t.Cleanup(func() {
os.RemoveAll(dir)
})
// 在临时目录中写一个文件
file := filepath.Join(dir, "data.txt")
if err := os.WriteFile(file, []byte("hello"), 0644); err != nil {
t.Fatal(err)
}
}
说明:原示例中误用了
os.MkdirAll(file, 0644),它的作用是创建目录而非写文件,这里已改为os.WriteFile。另外,file本身位于dir内,dir已被清理,因此无需再单独注册清理file的逻辑。
3、个人遇到的坑和不理解的问题
问题1:测试代码写错,没有传递实际参数
func Fooer(input int) string {
isFoo := (input % 3) == 0
if isFoo {
return "Foo"
}
return strconv.Itoa(input)
}
这个代码对于 1 来说,1 对 3 取余是 1,isFoo 为 false,本应返回 "1",为什么会返回 "Foo"?
结果:是测试代码中写死了参数 3,没有把实际的测试参数传进去。
问题2:t.Parallel() 与循环变量捕获陷阱
当在子测试中使用 t.Parallel() 时,如果直接在闭包里引用循环变量,会出现并发访问循环变量的问题,导致结果混乱。
for _, tc := range tests {
tc := tc // 捕获循环变量,避免并行问题
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := Fooer(tc.input)
if got != tc.want {
t.Errorf("got %s, want %s", got, tc.want)
}
})
}
在 Go 1.21 及更早的版本中,for 循环的循环变量在整个循环过程中只声明一次,每次迭代都是修改它的值。旧版本的执行流程:
- 第一次迭代,
tc = {9, "Foo"},启动子测试(但被t.Parallel()挂起,暂不执行函数体); - 第二次迭代,
tc = {3, "Foo"},启动子测试(同样挂起); - ……循环快速跑完,最终
tc变成了最后一个用例的值{19, "19"}; - 所有子测试解除挂起,同时去读取
tc.input和tc.want。
结果就是:所有并发的子测试拿到的都是最后一个 tc 的值,导致测试全部失败或产生混乱的结果。这就是经典的「循环变量捕获陷阱」。
从 Go 1.22 开始,for 循环的语义发生了变化:每次迭代都会创建一个新的循环变量实例,每个子测试闭包捕获的都是属于自己那次迭代的独立变量,互不干扰。
问题3:fuzz 测试没有触发预期的 bug
进行 fuzz 测试时,我只添加了 3 个种子,但故意预留的 2025 这个 bug 没有被触发,为什么这个模糊测试没有测到它?
原因:fuzz 测试中,f.Add(xx) 是基于传入的种子参数进行变异(位翻转、加减、交换等操作)来生成新输入的,同时结合覆盖率引导。由于变异是随机/启发式的,并不能保证一定生成 2025 这个特定值;只有当种子或变异路径恰好接近 2025 时,才可能覆盖到它。这也正是模糊测试需要足够多样、足够多的种子和运行时间的原因。
参考链接
本文为个人学习记录,如有错误,欢迎批评指正。