Go 从 0 到精通 · 第 31 课:测试基础

学习定位:这是整套 Go 教程的第 31 课,也是阶段五(并发与工程阶段)的第七课。
前置要求:已经完成第 30 课,掌握了常见并发设计模式。需要熟悉函数、结构体、错误处理和包管理。
本课目标:掌握 Go 的 testing 包,理解单元测试的写法和命名约定,学会表驱动测试这一 Go 最经典的测试范式,熟悉 go test 命令的常用参数,能为自己写的代码编写可靠的测试。


1. 本课你要解决的核心问题

你已经写了 30 课的代码了。每次写完一个函数,你怎么确认它是对的?大概率是 fmt.Println 打印一下看看结果对不对。

这有几个问题:

  • 每次改完代码,你得手动再运行一遍看输出
  • 多个函数的验证代码混在 main 里,越来越乱
  • 别人拿到你的代码,不知道怎么验证它是否正确
  • 三个月后你自己改了一个函数,不确定有没有影响其他地方

测试就是把"手动验证"变成"自动验证"。 你写一段代码来检查另一段代码的行为,运行一条命令就能知道所有功能是否正常。

Go 在这方面做得非常好:

  • 测试是内置的:不需要安装任何第三方框架
  • 命令统一:一条 go test 就跑所有测试
  • 约定简单:文件名以 _test.go 结尾,函数名以 Test 开头
  • 表驱动测试:Go 社区的标准测试模式,清晰且可扩展

这一课讲清楚以下问题:

  • Go 的测试文件和测试函数长什么样
  • 怎么用 t.Errort.Fatalt.Log 报告结果
  • 什么是表驱动测试,为什么 Go 程序员都用它
  • go test 有哪些常用参数
  • 测试覆盖率怎么看
  • 测试辅助函数怎么写

2. 最简单的测试

2.1 先写一个待测函数

假设你有一个文件 math.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package mathutil

// Add 返回两个整数的和
func Add(a, b int) int {
return a + b
}

// Abs 返回整数的绝对值
func Abs(n int) int {
if n < 0 {
return -n
}
return n
}

2.2 写测试文件

在同一个目录下创建 math_test.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package mathutil

import "testing"

func TestAdd(t *testing.T) {
result := Add(2, 3)
if result != 5 {
t.Errorf("Add(2, 3) = %d, 期望 5", result)
}
}

func TestAbs(t *testing.T) {
result := Abs(-7)
if result != 7 {
t.Errorf("Abs(-7) = %d, 期望 7", result)
}
}

2.3 运行测试

1
go test

输出:

1
2
PASS
ok mathutil 0.003s

如果某个测试失败了:

1
2
3
4
5
--- FAIL: TestAdd (0.00s)
math_test.go:7: Add(2, 3) = 6, 期望 5
FAIL
exit status 1
FAIL mathutil 0.003s

2.4 三条铁规

Go 测试有三条约定,必须遵守:

约定 规则 示例
文件名 _test.go 结尾 math_test.go
函数名 Test 开头,后面跟大写字母 TestAddTestAbs
参数 只有一个 *testing.T 参数 func TestAdd(t *testing.T)

不符合这三条的不会被 go test 识别。

注意:Test 后面必须跟大写字母或下划线。Testadd 不行(小写 a),TestAdd 可以,Test_add 也可以。


3. 报告测试结果的方法

testing.T 提供了几种报告结果的方法,理解它们的区别很重要。

3.1 四个关键方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
func TestExample(t *testing.T) {
// 1. t.Log / t.Logf — 记录信息,测试继续
t.Log("开始测试...")
t.Logf("测试参数: %d", 42)

// 2. t.Error / t.Errorf — 报告失败,测试继续
if got := Add(1, 2); got != 3 {
t.Errorf("Add(1,2) = %d, 期望 3", got)
}
// 这里还会继续执行

// 3. t.Fatal / t.Fatalf — 报告失败,测试立即停止
if got := Add(0, 0); got != 0 {
t.Fatalf("Add(0,0) = %d, 期望 0", got)
}
// 如果上面失败了,这里不会执行

// 4. t.Skip / t.Skipf — 跳过测试
if testing.Short() {
t.Skip("跳过耗时测试")
}
}

3.2 什么时候用哪个

方法 效果 使用场景
t.Error 标记失败,继续执行 一个测试函数里检查多个条件
t.Fatal 标记失败,立即停止 后续断言依赖当前结果(比如检查返回值前先检查 err == nil)
t.Log 记录信息(仅 -v 模式显示) 调试时记录中间值
t.Skip 跳过当前测试 某些环境下不适合运行

3.3 典型的 Error vs Fatal 选择

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func TestUserService(t *testing.T) {
user, err := GetUser(1)

// 用 Fatal:如果 err != nil,后面用 user 会 panic
if err != nil {
t.Fatalf("GetUser(1) 返回错误: %v", err)
}

// 用 Error:即使名字不对,还可以继续检查其他字段
if user.Name != "张三" {
t.Errorf("用户名 = %q, 期望 %q", user.Name, "张三")
}
if user.Age != 25 {
t.Errorf("年龄 = %d, 期望 %d", user.Age, 25)
}
}

4. 表驱动测试

4.1 为什么需要表驱动测试

假设你要测试 Abs 函数。你需要覆盖这些情况:

  • 正数 → 返回自身
  • 负数 → 返回相反数
  • 零 → 返回零

最直接的写法:

1
2
3
4
5
6
7
8
9
10
11
func TestAbs(t *testing.T) {
if Abs(5) != 5 {
t.Errorf("Abs(5) = %d, 期望 5", Abs(5))
}
if Abs(-3) != 3 {
t.Errorf("Abs(-3) = %d, 期望 3", Abs(-3))
}
if Abs(0) != 0 {
t.Errorf("Abs(0) = %d, 期望 0", Abs(0))
}
}

三个 case 还行,如果有 20 个呢?全是重复代码。

4.2 表驱动测试的标准写法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
func TestAbs(t *testing.T) {
tests := []struct {
name string
input int
want int
}{
{"正数", 5, 5},
{"负数", -3, 3},
{"零", 0, 0},
{"最小负数+1", -2147483647, 2147483647},
{"大正数", 999999, 999999},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Abs(tt.input)
if got != tt.want {
t.Errorf("Abs(%d) = %d, 期望 %d", tt.input, got, tt.want)
}
})
}
}

运行 go test -v 的输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
=== RUN   TestAbs
=== RUN TestAbs/正数
=== RUN TestAbs/负数
=== RUN TestAbs/零
=== RUN TestAbs/最小负数+1
=== RUN TestAbs/大正数
--- PASS: TestAbs (0.00s)
--- PASS: TestAbs/正数 (0.00s)
--- PASS: TestAbs/负数 (0.00s)
--- PASS: TestAbs/零 (0.00s)
--- PASS: TestAbs/最小负数+1 (0.00s)
--- PASS: TestAbs/大正数 (0.00s)
PASS

4.3 表驱动测试的结构拆解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 第一步:定义测试用例的结构
tests := []struct {
name string // 用例名称(必须有,方便定位失败)
input int // 输入
want int // 期望输出
}{
// 第二步:列出所有测试用例
{"正数", 5, 5},
{"负数", -3, 3},
}

// 第三步:遍历并用 t.Run 运行每个子测试
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Abs(tt.input)
if got != tt.want {
t.Errorf("Abs(%d) = %d, 期望 %d", tt.input, got, tt.want)
}
})
}

4.4 为什么用 t.Run

t.Run 创建子测试。好处:

  1. 每个 case 有名字:失败时直接告诉你是哪个 case 挂了
  2. 可以单独运行某个 casego test -run TestAbs/负数
  3. 子测试之间互不影响:一个 case 失败不影响其他 case 继续运行
  4. 支持并行t.Parallel() 让子测试并行执行

4.5 更复杂的表驱动测试:带错误检查

1
2
3
4
5
6
7
8
9
10
11
12
package mathutil

import "errors"

var ErrDivisionByZero = errors.New("除数不能为零")

func Divide(a, b float64) (float64, error) {
if b == 0 {
return 0, ErrDivisionByZero
}
return a / b, nil
}

测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
func TestDivide(t *testing.T) {
tests := []struct {
name string
a, b float64
want float64
wantErr error
}{
{"正常除法", 10, 3, 3.3333333333333335, nil},
{"整除", 10, 2, 5, nil},
{"被除数为零", 0, 5, 0, nil},
{"除以零", 10, 0, 0, ErrDivisionByZero},
{"负数除法", -6, 3, -2, nil},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Divide(tt.a, tt.b)

// 先检查错误
if tt.wantErr != nil {
if err == nil {
t.Fatalf("Divide(%g, %g) 期望错误 %v, 但没有返回错误",
tt.a, tt.b, tt.wantErr)
}
if !errors.Is(err, tt.wantErr) {
t.Fatalf("Divide(%g, %g) 错误 = %v, 期望 %v",
tt.a, tt.b, err, tt.wantErr)
}
return // 有错误就不检查返回值了
}

// 再检查正常返回值
if err != nil {
t.Fatalf("Divide(%g, %g) 意外错误: %v", tt.a, tt.b, err)
}
if got != tt.want {
t.Errorf("Divide(%g, %g) = %g, 期望 %g",
tt.a, tt.b, got, tt.want)
}
})
}
}

4.6 表驱动测试的核心优势

优势 说明
易扩展 新增 case 只需要加一行
结构清晰 输入、期望输出一目了然
减少重复 验证逻辑只写一次
定位方便 子测试名称直接指出哪个 case 失败
可维护 修改验证逻辑时只改一处

5. go test 命令详解

5.1 常用参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# 运行当前包的所有测试
go test

# 显示详细输出(包括通过的测试和 t.Log)
go test -v

# 运行所有子包的测试
go test ./...

# 只运行名字匹配的测试(支持正则)
go test -run TestAdd
go test -run TestAbs/负数
go test -run "Test(Add|Abs)"

# 显示测试覆盖率
go test -cover

# 设置超时时间(默认 10 分钟)
go test -timeout 30s

# 短模式(跳过耗时测试)
go test -short

# 多次运行(检查偶发失败)
go test -count 5

# 禁用缓存
go test -count=1

# 并行度控制
go test -parallel 4

5.2 常见用法组合

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 开发时最常用:详细输出 + 指定测试
go test -v -run TestDivide

# 提交前跑一遍全部测试
go test ./...

# 查看覆盖率详情
go test -cover -coverprofile=coverage.out
go tool cover -html=coverage.out # 在浏览器中查看

# 调试偶发失败
go test -v -count 10 -run TestFlaky

# CI 中常用:超时 + 全部 + 详细
go test -v -timeout 5m ./...

5.3 测试缓存

Go 会缓存测试结果。如果代码没变,再次运行 go test 会直接用缓存的结果(输出带 (cached) 标记):

1
ok      mathutil    (cached)

想强制重新运行:

1
2
go test -count=1    # 最常用的方式
go clean -testcache # 清除所有测试缓存

6. 测试覆盖率

6.1 什么是覆盖率

测试覆盖率告诉你:你的测试跑过了代码的百分之多少

1
go test -cover

输出:

1
2
3
PASS
coverage: 85.7% of statements
ok mathutil 0.003s

表示你的测试执行了被测代码中 85.7% 的语句。

6.2 生成覆盖率报告

1
2
3
4
5
6
7
8
# 步骤 1:生成覆盖率数据文件
go test -coverprofile=coverage.out

# 步骤 2:在终端查看每个函数的覆盖率
go tool cover -func=coverage.out

# 步骤 3:在浏览器中可视化查看
go tool cover -html=coverage.out

go tool cover -func 的输出:

1
2
3
4
mathutil/math.go:4:     Add             100.0%
mathutil/math.go:9: Abs 100.0%
mathutil/math.go:17: Divide 80.0%
total: (statements) 85.7%

6.3 覆盖率的正确态度

  • 100% 覆盖率不是目标:追求 100% 往往导致写出大量无意义的测试
  • 关注关键逻辑的覆盖:错误处理分支、边界条件、核心业务逻辑
  • 覆盖率低不一定坏:某些代码(如 main 函数、UI 层)不适合单元测试
  • 覆盖率高不一定好:测试可能只是跑过了代码但没有正确断言

实用标准:核心业务逻辑 80%+,工具函数 90%+,整体项目 60%+ 就不错。


7. 测试辅助函数

7.1 t.Helper()

当你提取公共的检查逻辑时,用 t.Helper() 标记辅助函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 辅助函数:检查两个整数是否相等
func assertEqual(t *testing.T, got, want int) {
t.Helper() // 关键:标记为辅助函数
if got != want {
t.Errorf("got %d, want %d", got, want)
}
}

func TestAdd(t *testing.T) {
assertEqual(t, Add(2, 3), 5) // 失败时报告这一行,而不是 assertEqual 内部
assertEqual(t, Add(0, 0), 0)
assertEqual(t, Add(-1, 1), 0)
}

不加 t.Helper(),失败时错误信息指向 assertEqual 函数内部——你还得猜是哪个调用挂了。加了之后,错误信息指向调用 assertEqual 的那一行,定位更快。

7.2 t.Cleanup()

注册清理函数,测试结束后自动执行(类似 defer,但专门为测试设计):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
func TestWithTempFile(t *testing.T) {
// 创建临时文件
f, err := os.CreateTemp("", "test-*.txt")
if err != nil {
t.Fatal(err)
}

// 注册清理:测试结束后自动删除
t.Cleanup(func() {
os.Remove(f.Name())
})

// 使用临时文件进行测试...
_, err = f.WriteString("test data")
if err != nil {
t.Fatal(err)
}
f.Close()

data, err := os.ReadFile(f.Name())
if err != nil {
t.Fatal(err)
}
if string(data) != "test data" {
t.Errorf("文件内容 = %q, 期望 %q", string(data), "test data")
}
}

7.3 t.TempDir()

Go 1.15+ 提供了更简单的临时目录方法:

1
2
3
4
5
6
7
8
9
10
11
func TestWithTempDir(t *testing.T) {
dir := t.TempDir() // 自动创建,测试结束自动删除

path := filepath.Join(dir, "test.txt")
err := os.WriteFile(path, []byte("hello"), 0644)
if err != nil {
t.Fatal(err)
}

// ... 测试逻辑 ...
}

8. 子测试与并行测试

8.1 子测试的嵌套

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func TestMathOperations(t *testing.T) {
t.Run("加法", func(t *testing.T) {
t.Run("正数相加", func(t *testing.T) {
assertEqual(t, Add(2, 3), 5)
})
t.Run("负数相加", func(t *testing.T) {
assertEqual(t, Add(-2, -3), -5)
})
})

t.Run("绝对值", func(t *testing.T) {
t.Run("正数", func(t *testing.T) {
assertEqual(t, Abs(5), 5)
})
t.Run("负数", func(t *testing.T) {
assertEqual(t, Abs(-5), 5)
})
})
}

运行指定层级:

1
go test -v -run TestMathOperations/加法/正数相加

8.2 并行测试

对于互不依赖的测试,可以用 t.Parallel() 并行执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
func TestParallel(t *testing.T) {
tests := []struct {
name string
input int
want int
}{
{"case1", 5, 5},
{"case2", -3, 3},
{"case3", 0, 0},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel() // 标记为可并行

got := Abs(tt.input)
if got != tt.want {
t.Errorf("Abs(%d) = %d, 期望 %d", tt.input, got, tt.want)
}
})
}
}

注意:使用 t.Parallel() 时,循环变量需要在 Go 1.22 之前手动捕获。Go 1.22+ 修复了循环变量的问题,不再需要手动捕获。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Go 1.21 及之前:需要手动捕获
for _, tt := range tests {
tt := tt // 捕获循环变量
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
// ...
})
}

// Go 1.22+:不需要了,直接用
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
// ...
})
}

9. 完整实战示例

9.1 被测代码:字符串工具包

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
// stringutil/stringutil.go
package stringutil

import (
"strings"
"unicode"
)

// Reverse 反转字符串(支持中文)
func Reverse(s string) string {
runes := []rune(s)
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)
}

// IsPalindrome 判断是否是回文(忽略大小写和空格)
func IsPalindrome(s string) bool {
// 移除空格并转小写
s = strings.ReplaceAll(s, " ", "")
s = strings.ToLower(s)

runes := []rune(s)
for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
if runes[i] != runes[j] {
return false
}
}
return true
}

// WordCount 统计字符串中的单词数
func WordCount(s string) int {
return len(strings.Fields(s))
}

// Capitalize 将字符串的首字母大写
func Capitalize(s string) string {
if s == "" {
return s
}
runes := []rune(s)
runes[0] = unicode.ToUpper(runes[0])
return string(runes)
}

// Truncate 截断字符串到指定长度,超出部分用 "..." 替换
func Truncate(s string, maxLen int) string {
if maxLen <= 0 {
return ""
}
runes := []rune(s)
if len(runes) <= maxLen {
return s
}
if maxLen <= 3 {
return string(runes[:maxLen])
}
return string(runes[:maxLen-3]) + "..."
}

9.2 测试代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
// stringutil/stringutil_test.go
package stringutil

import "testing"

func TestReverse(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{"英文", "hello", "olleh"},
{"中文", "你好世界", "界世好你"},
{"空字符串", "", ""},
{"单字符", "a", "a"},
{"回文", "abcba", "abcba"},
{"带空格", "hello world", "dlrow olleh"},
{"中英混合", "Go语言", "言语oG"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Reverse(tt.input)
if got != tt.want {
t.Errorf("Reverse(%q) = %q, 期望 %q", tt.input, got, tt.want)
}
})
}
}

func TestIsPalindrome(t *testing.T) {
tests := []struct {
name string
input string
want bool
}{
{"英文回文", "racecar", true},
{"非回文", "hello", false},
{"带空格回文", "was it a car or a cat I saw", true},
{"大小写混合", "RaceCar", true},
{"空字符串", "", true},
{"单字符", "a", true},
{"两个字符相同", "aa", true},
{"两个字符不同", "ab", false},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := IsPalindrome(tt.input)
if got != tt.want {
t.Errorf("IsPalindrome(%q) = %v, 期望 %v", tt.input, got, tt.want)
}
})
}
}

func TestWordCount(t *testing.T) {
tests := []struct {
name string
input string
want int
}{
{"正常句子", "hello world foo", 3},
{"单个单词", "hello", 1},
{"空字符串", "", 0},
{"多余空格", " hello world ", 2},
{"制表符分隔", "hello\tworld", 2},
{"换行分隔", "hello\nworld", 2},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := WordCount(tt.input)
if got != tt.want {
t.Errorf("WordCount(%q) = %d, 期望 %d", tt.input, got, tt.want)
}
})
}
}

func TestCapitalize(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{"小写开头", "hello", "Hello"},
{"已经大写", "Hello", "Hello"},
{"空字符串", "", ""},
{"单字符", "a", "A"},
{"中文开头", "你好", "你好"},
{"数字开头", "123abc", "123abc"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Capitalize(tt.input)
if got != tt.want {
t.Errorf("Capitalize(%q) = %q, 期望 %q", tt.input, got, tt.want)
}
})
}
}

func TestTruncate(t *testing.T) {
tests := []struct {
name string
input string
maxLen int
want string
}{
{"不需要截断", "hello", 10, "hello"},
{"刚好等于长度", "hello", 5, "hello"},
{"需要截断", "hello world", 8, "hello..."},
{"截断中文", "你好世界早上好", 5, "你好..."},
{"空字符串", "", 5, ""},
{"maxLen为0", "hello", 0, ""},
{"maxLen为1", "hello", 1, "h"},
{"maxLen为3", "hello", 3, "hel"},
{"maxLen为4", "hello world", 4, "h..."},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Truncate(tt.input, tt.maxLen)
if got != tt.want {
t.Errorf("Truncate(%q, %d) = %q, 期望 %q",
tt.input, tt.maxLen, got, tt.want)
}
})
}
}

9.3 运行效果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
$ go test -v
=== RUN TestReverse
=== RUN TestReverse/英文
=== RUN TestReverse/中文
=== RUN TestReverse/空字符串
=== RUN TestReverse/单字符
=== RUN TestReverse/回文
=== RUN TestReverse/带空格
=== RUN TestReverse/中英混合
--- PASS: TestReverse (0.00s)
--- PASS: TestReverse/英文 (0.00s)
--- PASS: TestReverse/中文 (0.00s)
--- PASS: TestReverse/空字符串 (0.00s)
--- PASS: TestReverse/单字符 (0.00s)
--- PASS: TestReverse/回文 (0.00s)
--- PASS: TestReverse/带空格 (0.00s)
--- PASS: TestReverse/中英混合 (0.00s)
=== RUN TestIsPalindrome
...
--- PASS: TestIsPalindrome (0.00s)
=== RUN TestWordCount
...
--- PASS: TestWordCount (0.00s)
=== RUN TestCapitalize
...
--- PASS: TestCapitalize (0.00s)
=== RUN TestTruncate
...
--- PASS: TestTruncate (0.00s)
PASS
ok stringutil 0.004s

10. 常见坑总结

10.1 文件名或函数名不对

1
2
3
4
5
6
7
8
9
// 坏:文件名不以 _test.go 结尾
// math_tests.go ← go test 不会识别这个文件

// 坏:函数名 Test 后面跟小写字母
func Testadd(t *testing.T) {} // 不会被执行

// 好
func TestAdd(t *testing.T) {} // Test + 大写字母
func Test_add(t *testing.T) {} // Test + 下划线也行

10.2 测试函数中用了 fmt.Println

1
2
3
4
5
6
7
8
9
10
11
// 坏:用 fmt.Println 输出调试信息
func TestAdd(t *testing.T) {
result := Add(2, 3)
fmt.Println("result:", result) // 总是打印,不受 -v 控制
}

// 好:用 t.Log
func TestAdd(t *testing.T) {
result := Add(2, 3)
t.Log("result:", result) // 只在 -v 模式或测试失败时打印
}

10.3 表驱动测试中没有给 case 命名

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 坏:没有名字,失败时不知道是哪个 case
tests := []struct {
input int
want int
}{
{5, 5},
{-3, 3},
}
for _, tt := range tests {
got := Abs(tt.input)
if got != tt.want {
t.Errorf("Abs(%d) = %d, 期望 %d", tt.input, got, tt.want)
}
}

// 好:有名字 + t.Run
tests := []struct {
name string
input int
want int
}{
{"正数", 5, 5},
{"负数", -3, 3},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// ...
})
}

10.4 该用 Fatal 的地方用了 Error

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 坏:err 不为 nil 时继续用 result 会 panic
result, err := SomeFunction()
if err != nil {
t.Errorf("unexpected error: %v", err)
}
// 如果 err != nil,result 可能是零值,下面会出错
if result.Name != "test" { // 可能 panic
t.Error("wrong name")
}

// 好:用 Fatal 阻止继续执行
result, err := SomeFunction()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Name != "test" {
t.Error("wrong name")
}

10.5 测试之间有依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 坏:Test2 依赖 Test1 创建的全局状态
var globalData string

func TestStep1(t *testing.T) {
globalData = "initialized"
}

func TestStep2(t *testing.T) {
// 如果 TestStep1 没跑或失败了,这里就出问题
if globalData != "initialized" {
t.Fatal("data not ready")
}
}

// 好:每个测试独立,自己准备自己的数据
func TestStep2(t *testing.T) {
data := "initialized" // 自己准备数据
if data != "initialized" {
t.Fatal("data not ready")
}
}

每个测试函数应该是独立的。Go 不保证测试的执行顺序。


11. 本课练习

练习 1:写一个计算器的测试

实现一个 Calculator 包,包含 AddSubtractMultiplyDivide 四个函数。为每个函数写表驱动测试,Divide 要测试除以零的错误情况。


练习 2:测试一个栈

实现一个简单的整数栈(PushPopPeekIsEmptySize),为所有方法写测试。PopPeek 在栈为空时应返回错误,测试要覆盖这些边界情况。


练习 3:测试字符串验证器

实现以下验证函数并写测试:

  • IsEmail(s string) bool:简单判断是否包含 @.
  • IsPhoneNumber(s string) bool:判断是否是 11 位数字
  • IsStrongPassword(s string) bool:至少 8 位,包含大写、小写和数字

每个函数至少写 6 个测试用例,覆盖正常、异常和边界情况。


练习 4:测试覆盖率

对练习 1 的代码运行覆盖率分析:

  1. go test -cover 查看覆盖率百分比
  2. go test -coverprofile=coverage.out 生成报告
  3. go tool cover -func=coverage.out 查看每个函数的覆盖率
  4. 如果覆盖率低于 90%,补充测试用例

练习 5:重构已有代码的测试

回到第 23 课的排序代码,为自定义排序逻辑写表驱动测试。要求:

  • 测试按年龄排序
  • 测试按名字排序
  • 测试空切片
  • 测试只有一个元素的切片

12. 自测题

12.1 概念题

  1. Go 测试文件和测试函数的命名规则是什么?
  2. t.Errort.Fatal 的区别是什么?什么时候用哪个?
  3. 什么是表驱动测试?它比逐个断言的写法好在哪里?
  4. t.Run 创建的子测试有什么好处?
  5. t.Helper() 的作用是什么?不加会怎样?
  6. go test -covergo test -coverprofile 有什么区别?
  7. 怎么只运行某个特定的测试函数或子测试?
  8. 为什么测试之间不应该有依赖关系?

12.2 代码阅读题

以下测试代码有两个问题,找出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func TestProcess(t *testing.T) {
tests := []struct {
input int
want int
}{
{1, 2},
{2, 4},
{3, 6},
}

for _, tt := range tests {
got, err := Process(tt.input)
if err != nil {
t.Errorf("Process(%d) error: %v", tt.input, err)
}
if got != tt.want {
t.Errorf("Process(%d) = %d, want %d", tt.input, got, tt.want)
}
}
}
点击查看答案

问题 1:没有用 t.Run 和测试名称。当某个 case 失败时,不容易快速定位是哪个 case 的问题。应该给每个测试用例加 name 字段并用 t.Run

问题 2:err 检查用了 t.Errorf 而不是 t.Fatalf。如果 Process 返回了错误,got 可能是零值,后面的 got != tt.want 断言会给出误导性的错误信息。应该用 t.Fatalf 在出错时立即停止当前子测试。

修复后:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func TestProcess(t *testing.T) {
tests := []struct {
name string
input int
want int
}{
{"一倍", 1, 2},
{"二倍", 2, 4},
{"三倍", 3, 6},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Process(tt.input)
if err != nil {
t.Fatalf("Process(%d) error: %v", tt.input, err)
}
if got != tt.want {
t.Errorf("Process(%d) = %d, want %d", tt.input, got, tt.want)
}
})
}
}

13. 本课总结

这一课你学到了 Go 测试的核心内容。

知识点 要点
测试约定 文件 _test.go,函数 TestXxx(t *testing.T)
报告结果 t.Error(继续)、t.Fatal(停止)、t.Log(记录)
表驱动测试 定义结构体切片 + t.Run 遍历,Go 测试的标准范式
go test -v(详细)、-run(过滤)、-cover(覆盖率)、-count(禁缓存)
覆盖率 -coverprofile 生成报告,go tool cover 可视化
辅助工具 t.Helper()t.Cleanup()t.TempDir()t.Parallel()

最重要的三件事:

  1. 表驱动测试是 Go 的标准测试范式——每个 case 有名字、有输入、有期望输出,用 t.Run 遍历
  2. t.Fatal 用于前置条件检查(如 err != nil),t.Error 用于多个独立断言
  3. 每个测试必须独立——不依赖其他测试的执行结果或顺序

14. 下一课预告

你已经会写基本测试了。下一课我们来学怎么衡量代码的性能。

下一课:Benchmark 与性能测试

会重点讲:

  • 基准测试的写法:BenchmarkXxx(b *testing.B)
  • b.N 是什么,Go 怎么自动调整迭代次数
  • 怎么用 go test -bench 运行基准测试
  • 怎么对比两个实现的性能差异
  • 基准测试的常见误区

学完下一课,你就能用数据说话——“这个实现比那个快多少”,而不是凭感觉猜。