Appearance
测试基础:testing 包与表驱动测试
Go 语言从设计之初就把「测试」当作一等公民来对待。标准库自带 testing 包,工具链原生支持 go test 命令,无需引入任何第三方框架就能写出工业级的单元测试。本篇面向已经掌握 Go 基础语法的开发者,系统讲解 testing 包的核心用法、表驱动测试模式、子测试与并行测试,以及测试覆盖率的基本使用。
一、Go 测试哲学:测试即代码
很多语言(比如 Java、Python)需要借助 JUnit、pytest 这样的外部框架才能写测试,而 Go 选择了完全不同的路线:测试就是普通的 Go 代码。这一哲学体现在以下几个方面:
- 测试文件与源文件同目录:
foo.go的测试写在foo_test.go里,两者放在同一个包下,紧邻放置便于查找。 - 测试函数遵循命名约定:以
Test开头、参数为*testing.T的函数会被go test自动识别并执行,无需注册。 - 不使用注解或装饰器:Go 没有注解机制,所有测试组织都靠命名约定与代码结构表达,简单直接。
- 工具链原生支持:
go test是go工具的内置子命令,配合-v、-cover、-bench等参数即可完成绝大多数测试需求。
这种设计的好处是:任何 Go 项目,无论规模大小,打开就能测。你不需要先学习一个框架的 DSL,也不需要配置复杂的运行环境。测试代码与生产代码使用同一套语法、同一套工具链、同一种思维方式。
一个经验法则:如果一个函数难以测试,往往说明它的设计有问题(例如依赖过重、副作用过多)。在 Go 中,可测试性与代码质量是高度相关的。
二、testing 包基础
testing 包位于标准库,导入路径是 testing。它提供了三个核心类型:
| 类型 | 用途 | 触发命令 |
|---|---|---|
*testing.T | 功能测试(单元测试、集成测试) | go test |
*testing.B | 基准测试(性能测试) | go test -bench=. |
*testing.F | Fuzz 测试(Go 1.18+) | go test -fuzz=Fuzz |
本篇聚焦功能测试,基准测试在后续篇章专门讲解。
测试文件必须满足以下规则:
- 文件名以
_test.go结尾,例如user_test.go。 - 测试函数签名必须是
func TestXxx(t *testing.T),其中Xxx首字母必须大写,但不能以小写字母开头。 - 测试文件可以与被测代码在同一个包(内部测试,可访问未导出成员),也可以在
package_test这样的外部测试包中(外部测试,只能访问导出成员)。
下面是一份最小可运行的测试示例。先准备被测代码:
go
package mathutil
// Add 返回两个整数的和。
func Add(a, b int) int {
return a + b
}
// Sub 返回 a 减 b 的差。
func Sub(a, b int) int {
return a - b
}再写对应的测试文件 mathutil_test.go:
go
package mathutil
import "testing"
func TestAdd(t *testing.T) {
got := Add(1, 2)
if got != 3 {
t.Errorf("Add(1, 2) = %d, want 3", got)
}
}
func TestSub(t *testing.T) {
got := Sub(5, 3)
if got != 2 {
t.Errorf("Sub(5, 3) = %d, want 2", got)
}
}执行 go test 即可运行测试:
bash
go test输出示例:
text
PASS
ok example.com/mathutil 0.003s加上 -v 参数可以看到每个用例的执行情况:
bash
go test -v输出:
text
=== RUN TestAdd
--- PASS: TestAdd (0.00s)
=== RUN TestSub
--- PASS: TestSub (0.00s)
PASS
ok example.com/mathutil 0.003s三、第一个测试函数:TestXxx
测试函数的命名直接影响测试输出的可读性。建议遵循以下规范:
- 函数名以
Test开头,后接被测函数或行为的名称,例如TestAdd、TestUserLogin。 - 名称应该描述「被测什么」,而不是「测试什么」。
TestAdd比TestAddFunc更好。 - 当一个函数有多种行为需要分别测试时,使用子测试(见后文)而不是拆分成多个顶层测试函数。
下面是一个稍完整的示例,被测代码是一个字符串工具:
go
package stringutil
import "strings"
// 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.ToLower(s)
return s == Reverse(s)
}
// Truncate 把字符串截断到指定长度,超出部分用省略号替代。
func Truncate(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
if maxLen <= 3 {
return s[:maxLen]
}
return s[:maxLen-3] + "..."
}对应测试:
go
package stringutil
import "testing"
func TestReverse(t *testing.T) {
got := Reverse("hello")
want := "olleh"
if got != want {
t.Errorf("Reverse(\"hello\") = %q, want %q", got, want)
}
}
func TestIsPalindrome(t *testing.T) {
cases := []struct {
input string
want bool
}{
{"racecar", true},
{"hello", false},
{"a", true},
{"", true},
}
for _, c := range cases {
if got := IsPalindrome(c.input); got != c.want {
t.Errorf("IsPalindrome(%q) = %v, want %v", c.input, got, c.want)
}
}
}
func TestTruncate(t *testing.T) {
if got := Truncate("hello world", 8); got != "hello..." {
t.Errorf("Truncate(\"hello world\", 8) = %q, want \"hello...\"", got)
}
if got := Truncate("hi", 5); got != "hi" {
t.Errorf("Truncate(\"hi\", 5) = %q, want \"hi\"", got)
}
}四、t.Error、t.Fatal、t.Skip
*testing.T 提供了多种标记测试失败的方式,三者的关键区别在于「失败后是否继续执行当前函数」:
| 方法 | 行为 | 适用场景 |
|---|---|---|
t.Error(args...) | 标记失败并继续执行(等价于 t.Log + t.Fail) | 收集多个错误一起展示 |
t.Errorf(format,...) | 同上,但支持格式化 | 收集多个错误 |
t.Fatal(args...) | 标记失败并立即停止当前测试函数 | 后续断言依赖前面,再测无意义 |
t.Fatalf(format,...) | 同上,支持格式化 | 同上 |
t.Skip(args...) | 跳过当前测试,标记为 SKIP | 环境不满足、长期测试、平台特异 |
t.Skipf(format,...) | 同上,支持格式化 | 同上 |
下面演示三者的差异:
go
package mathutil_test
import (
"math"
"testing"
)
func TestErrorCollect(t *testing.T) {
// t.Errorf 会标记失败但继续执行,便于一次看到多个失败点
if got := math.Sqrt(4); got != 2 {
t.Errorf("Sqrt(4) = %v, want 2", got)
}
if got := math.Sqrt(9); got != 3 {
t.Errorf("Sqrt(9) = %v, want 3", got)
}
if got := math.Sqrt(16); got != 4 {
t.Errorf("Sqrt(16) = %v, want 4", got)
}
// 即使上面有失败,这里仍会执行
t.Log("TestErrorCollect 完成")
}
func TestFatalImmediate(t *testing.T) {
// 假设需要先初始化资源,失败则后续断言无意义
if 1 != 2 {
t.Fatal("前置条件失败:1 应该等于 2,后续断言不再执行")
}
// 这一行永远不会执行
t.Log("这行不会打印")
}
func TestSkipExample(t *testing.T) {
// 跳过长测试或环境不支持的测试
if testing.Short() {
t.Skip("短模式下跳过耗时测试")
}
// 只有非短模式才会执行到这里
for i := 0; i < 1000000; i++ {
_ = i * i
}
t.Log("耗时测试执行完成")
}执行 go test -v -short 可以看到 SKIP 的效果:
text
=== RUN TestErrorCollect
mathutil_test.go:15: TestErrorCollect 完成
--- PASS: TestErrorCollect (0.00s)
=== RUN TestFatalImmediate
mathutil_test.go:27: 前置条件失败:1 应该等于 2,后续断言不再执行
--- FAIL: TestFatalImmediate (0.00s)
=== RUN TestSkipExample
mathutil_test.go:35: 短模式下跳过耗时测试
--- SKIP: TestSkipExample (0.00s)五、测试组织:子测试 t.Run
当被测函数有多种输入组合或多种场景时,把所有断言塞进一个 TestXxx 函数会导致失败时定位困难。t.Run 让你在一个顶层测试内部组织多个子测试,每个子测试有独立的名字和独立的失败状态。
go
package stringutil_test
import "testing"
func TestReverseTable(t *testing.T) {
t.Run("ASCII 字符串", func(t *testing.T) {
if got := Reverse("hello"); got != "olleh" {
t.Errorf("got %q, want %q", got, "olleh")
}
})
t.Run("空字符串", func(t *testing.T) {
if got := Reverse(""); got != "" {
t.Errorf("got %q, want \"\"", got)
}
})
t.Run("中文 UTF-8", func(t *testing.T) {
if got := Reverse("你好"); got != "好你" {
t.Errorf("got %q, want %q", got, "好你")
}
})
t.Run("奇数长度", func(t *testing.T) {
if got := Reverse("abcde"); got != "edcba" {
t.Errorf("got %q, want %q", got, "edcba")
}
})
}子测试可以嵌套,也可以单独运行某个子测试:
bash
go test -v -run TestReverseTable/中文_UTF-8输出:
text
=== RUN TestReverseTable
=== RUN TestReverseTable/中文_UTF-8
--- PASS: TestReverseTable/中文_UTF-8 (0.00s)
--- PASS: TestReverseTable (0.00s)
PASS-run 参数支持正则,灵活选择要跑的子测试,对调试单个用例非常有用。
六、表驱动测试模式(Table-Driven Tests)
表驱动测试是 Go 社区最经典的测试模式。核心思想:把测试用例抽象成一张「表」(切片),每行包含输入和期望输出,然后用一个循环统一执行断言。这种模式的好处是:
- 新增用例只需加一行,无需复制粘贴测试骨架。
- 断言逻辑集中,避免代码重复。
- 天然适合子测试,每行一个独立的
t.Run,失败隔离。 - 可读性强,测试意图一眼可读。
下面是 IsPalindrome 的标准表驱动写法:
go
package stringutil_test
import "testing"
func TestIsPalindromeTable(t *testing.T) {
cases := []struct {
name string // 子测试名称
input string
want bool
}{
{"纯英文回文", "racecar", true},
{"非回文", "hello", false},
{"单字符", "a", true},
{"空字符串", "", true},
{"带大小写", "RaceCar", true},
{"中文回文", "上海自来水来自海上", true},
{"混合非回文", "abc123", false},
}
for _, c := range cases {
c := c // 捕获循环变量(Go 1.22 之前需要,1.22+ 默认每轮独立)
t.Run(c.name, func(t *testing.T) {
got := IsPalindrome(c.input)
if got != c.want {
t.Errorf("IsPalindrome(%q) = %v, want %v", c.input, got, c.want)
}
})
}
}关于
c := c:在 Go 1.22 之前,for range循环变量是共享的,闭包捕获会出问题,需要手动c := c制造一份拷贝。Go 1.22 起for循环变量每次迭代都是独立的,这行可以省略。但为了兼容旧版本,许多项目仍然保留这种写法。
表驱动测试的常见变体是「期望错误」的用例:
go
package mathutil
import "errors"
// Divide 返回 a/b,当 b 为 0 时返回错误。
func Divide(a, b float64) (float64, error) {
if b == 0 {
return 0, errors.New("divisor cannot be zero")
}
return a / b, nil
}go
package mathutil_test
import (
"errors"
"testing"
)
func TestDivide(t *testing.T) {
cases := []struct {
name string
a, b float64
want float64
wantErr error
}{
{"正常除法", 10, 2, 5, nil},
{"负数除法", -10, 2, -5, nil},
{"小数除法", 1, 3, 0.3333333333333333, nil},
{"除零错误", 10, 0, 0, errors.New("divisor cannot be zero")},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, err := Divide(c.a, c.b)
if c.wantErr != nil {
if err == nil {
t.Fatalf("期望返回错误,得到 nil")
}
if err.Error() != c.wantErr.Error() {
t.Fatalf("错误信息不匹配: got %q, want %q", err.Error(), c.wantErr.Error())
}
return
}
if err != nil {
t.Fatalf("未期望错误: %v", err)
}
if got != c.want {
t.Errorf("Divide(%v, %v) = %v, want %v", c.a, c.b, got, c.want)
}
})
}
}七、测试并行:t.Parallel
t.Parallel() 让一个测试(或子测试)声明自己可以与其他并行测试同时运行。Go 会在所有串行测试执行完后,并行执行所有调用了 t.Parallel() 的测试。使用要点:
t.Parallel()通常放在测试函数的第一行(在子测试里则放在闭包第一行)。- 并行测试之间不能共享可变状态,否则会有数据竞争。
- 并行度默认等于
GOMAXPROCS,可通过-parallel N参数控制。
go
package stringutil_test
import (
"sync/atomic"
"testing"
)
func TestParallelSubtests(t *testing.T) {
var counter int64
cases := []struct {
name string
input string
want string
}{
{"case1", "abc", "cba"},
{"case2", "12345", "54321"},
{"case3", "Go语言", "言语oG"},
{"case4", "!@#$", "$#@!"},
}
for _, c := range cases {
c := c
t.Run(c.name, func(t *testing.T) {
t.Parallel() // 声明这个子测试可以与其他并行测试同时跑
atomic.AddInt64(&counter, 1)
got := Reverse(c.input)
if got != c.want {
t.Errorf("Reverse(%q) = %q, want %q", c.input, got, c.want)
}
})
}
t.Logf("counter 最终值(不保证): %d", atomic.LoadInt64(&counter))
}并行测试适合 IO 密集型场景(如网络请求、数据库查询),但不适合 CPU 密集型短测试(并行反而增加调度开销)。在写库或公共组件时,推荐默认开启并行以提前发现数据竞争。
配合 go test -race 可以在并行场景下检测数据竞争:
bash
go test -race -v八、测试覆盖率:go test -cover
覆盖率(Coverage)是指「被测试执行到的代码行数 / 总代码行数」的比例。Go 工具链内置了覆盖率统计,使用非常简单:
bash
go test -cover输出示例:
text
PASS
coverage: 85.7% of statements
ok example.com/stringutil 0.005s如果想要更详细的覆盖率报告,可以生成 profile 文件并用 go tool cover 查看:
bash
# 生成 coverage profile
go test -coverprofile=coverage.out
# 在浏览器中查看 HTML 报告
go tool cover -html=coverage.out
# 也可以输出函数级别的覆盖率
go tool cover -func=coverage.outgo tool cover -func 输出示例:
text
example.com/stringutil/Reverse.go:4: Reverse 100.0%
example.com/stringutil/IsPalindrome.go:9: IsPalindrome 100.0%
example.com/stringutil/Truncate.go:16: Truncate 80.0%
total: statements 85.7%覆盖率不是越高越好。100% 覆盖率只能保证「每一行都被执行过」,但无法保证「所有边界条件都被覆盖」。把覆盖率当作发现未测试代码的工具,而不是终极目标。一般建议核心业务逻辑覆盖率不低于 80%,工具类代码可以更高。
九、运行测试:go test 常用参数
go test 的参数非常丰富,下面列出最常用的几个:
| 参数 | 作用 |
|---|---|
-v | 显示每个测试的详细运行过程 |
-run regexp | 只运行名字匹配正则的测试 |
-count N | 每个测试运行 N 次,用于检测偶发失败 |
-cover | 显示覆盖率 |
-coverprofile file | 输出覆盖率到文件 |
-race | 开启数据竞争检测 |
-short | 跳过耗时测试(在测试中通过 testing.Short() 判断) |
-timeout duration | 设置整个测试的超时时间,默认 10 分钟 |
-parallel N | 设置并行测试的并行度 |
-bench regexp | 同时运行匹配的基准测试 |
-benchmem | 基准测试中报告内存分配情况 |
-cpu 1,2,4 | 在不同 GOMAXPROCS 下运行基准测试 |
-failfast | 第一个测试失败后立即停止 |
-v -run xxx -count=1 | 调试单个测试时最常用的组合 |
几个常用组合示例:
bash
# 详细运行所有测试,并显示覆盖率
go test -v -cover
# 只运行名字包含 "Reverse" 的测试,并显示详细过程
go test -v -run Reverse
# 检测数据竞争,运行 3 次确保稳定
go test -race -count=3
# 设置 30 秒超时,避免死锁测试拖慢 CI
go test -timeout 30s
# 跑基准测试并显示内存分配
go test -bench=. -benchmem十、完整示例:字符串工具的测试
下面把前面散落的示例整合成一个完整可运行的小工程。目录结构:
text
stringutil/
├── go.mod
├── stringutil.go
└── stringutil_test.gogo.mod:
text
module example.com/stringutil
go 1.21stringutil.go:
go
// Package stringutil 提供一组字符串处理工具函数。
package stringutil
import (
"strings"
"unicode"
)
// Reverse 返回字符串的反转结果,按 rune 反转以正确处理 UTF-8。
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 {
var letters []rune
for _, r := range s {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
letters = append(letters, unicode.ToLower(r))
}
}
for i, j := 0, len(letters)-1; i < j; i, j = i+1, j-1 {
if letters[i] != letters[j] {
return false
}
}
return true
}
// Truncate 把字符串截断到指定长度,超出部分用省略号替代。
func Truncate(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
if maxLen <= 3 {
return s[:maxLen]
}
return s[:maxLen-3] + "..."
}
// CountWords 统计字符串中的英文单词数量。
func CountWords(s string) int {
return len(strings.Fields(s))
}
// IsBlank 判断字符串是否为空或仅包含空白字符。
func IsBlank(s string) bool {
return strings.TrimSpace(s) == ""
}stringutil_test.go:
go
package stringutil_test
import "testing"
func TestReverse(t *testing.T) {
cases := []struct {
name, input, want string
}{
{"ASCII", "hello", "olleh"},
{"空字符串", "", ""},
{"单字符", "a", "a"},
{"中文", "你好", "好你"},
{"混合", "Go语言", "言语oG"},
{"符号", "!@#$", "$#@!"},
{"奇数长度", "abcde", "edcba"},
{"偶数长度", "abcdef", "fedcba"},
}
for _, c := range cases {
c := c
t.Run(c.name, func(t *testing.T) {
t.Parallel()
if got := Reverse(c.input); got != c.want {
t.Errorf("Reverse(%q) = %q, want %q", c.input, got, c.want)
}
})
}
}
func TestIsPalindrome(t *testing.T) {
cases := []struct {
name string
input string
want bool
}{
{"纯英文回文", "racecar", true},
{"非回文", "hello", false},
{"单字符", "a", true},
{"空字符串", "", true},
{"带大小写", "RaceCar", true},
{"带标点", "A man, a plan, a canal: Panama", true},
{"中文回文", "上海自来水来自海上", true},
{"混合非回文", "abc123", false},
}
for _, c := range cases {
c := c
t.Run(c.name, func(t *testing.T) {
t.Parallel()
if got := IsPalindrome(c.input); got != c.want {
t.Errorf("IsPalindrome(%q) = %v, want %v", c.input, got, c.want)
}
})
}
}
func TestTruncate(t *testing.T) {
cases := []struct {
name string
input string
maxLen int
want string
}{
{"无需截断", "hi", 5, "hi"},
{"刚好等于", "hello", 5, "hello"},
{"需要截断", "hello world", 8, "hello..."},
{"极短截断", "hello world", 3, "hel"},
{"超短截断", "hello world", 2, "he"},
{"空字符串", "", 5, ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := Truncate(c.input, c.maxLen); got != c.want {
t.Errorf("Truncate(%q, %d) = %q, want %q",
c.input, c.maxLen, got, c.want)
}
})
}
}
func TestCountWords(t *testing.T) {
cases := []struct {
input string
want int
}{
{"hello world", 2},
{"one", 1},
{"", 0},
{" ", 0},
{" multiple spaces ", 2},
{"a b c d e", 5},
{"tab\tseparated", 2},
{"newline\nseparated", 2},
}
for _, c := range cases {
if got := CountWords(c.input); got != c.want {
t.Errorf("CountWords(%q) = %d, want %d", c.input, got, c.want)
}
}
}
func TestIsBlank(t *testing.T) {
cases := []struct {
input string
want bool
}{
{"", true},
{" ", true},
{"\t\n", true},
{"hello", false},
{" hello ", false},
}
for _, c := range cases {
if got := IsBlank(c.input); got != c.want {
t.Errorf("IsBlank(%q) = %v, want %v", c.input, got, c.want)
}
}
}运行整套测试:
bash
go test -v -cover -race预期输出(节选):
text
=== RUN TestReverse
=== RUN TestReverse/ASCII
=== RUN TestReverse/中文
...
--- PASS: TestReverse (0.00s)
=== RUN TestIsPalindrome
...
PASS
coverage: 100.0% of statements
ok example.com/stringutil 0.008s十一、常见错误与避坑指南
初学 Go 测试时,以下几个坑比较常见:
1. 忘记捕获循环变量
在 Go 1.22 之前,下面的写法会有数据竞争问题:
go
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
// 这里 c 永远是最后一个用例!
if got := Reverse(c.input); got != c.want {
t.Errorf("got %q", got)
}
})
}修复方法:在循环体第一行加 c := c。
2. 在 t.Run 之外使用 t.Parallel
t.Parallel() 必须在测试函数内部、子测试闭包内部调用,否则没有效果。顶层 TestXxx 函数里直接 t.Parallel() 也是合法的,但子测试里的并行必须在子测试闭包里声明。
3. 测试函数互相依赖执行顺序
go
func TestCreateUser(t *testing.T) { ... }
func TestGetUser(t *testing.T) {
// 依赖 TestCreateUser 先执行并创建了用户
}这是反模式。Go 不保证测试执行顺序,每个测试必须独立可运行。需要前置数据时,使用 setup 函数或 TestMain。
4. 用 t.Fatal 过多导致信息丢失
t.Fatal 会立即终止当前测试函数,导致后续断言不执行。如果一个测试有多个独立的断言,应该用 t.Errorf 收集所有失败信息,便于一次看完所有问题。
5. 测试覆盖率追求 100% 而忽略边界
100% 覆盖率不代表覆盖了所有边界条件。比如下面的代码:
go
func Abs(n int) int {
if n < 0 {
return -n
}
return n
}用 Abs(-1) 和 Abs(1) 即可达到 100% 覆盖,但漏掉了 Abs(math.MinInt32) 这种边界情况。
十二、小结
本篇系统介绍了 Go 测试的基础知识,重点掌握以下几点:
- 测试即代码:Go 不需要外部框架,
go test内置一切,门槛极低。 - testing 包三剑客:
*testing.T(功能测试)、*testing.B(基准测试)、*testing.F(Fuzz 测试)。 - 失败标记三种方式:
t.Error(继续)、t.Fatal(停止)、t.Skip(跳过)。 - 子测试
t.Run:组织多种场景,便于单独运行和失败定位。 - 表驱动测试:Go 社区最经典的测试模式,新增用例只需加一行。
t.Parallel:声明并行执行,配合-race检测数据竞争。- 覆盖率:
go test -cover与go tool cover是发现未测试代码的工具。 go test参数:-v、-run、-count、-race、-timeout是日常调试的五大法宝。
下一篇我们会学习如何用 testify 让断言更简洁、更可读,并介绍测试套件、Setup/TearDown 等更高级的组织方式。
至此,你已经掌握了 Go 测试的基础工具链。把这些概念用到你的项目里,先给一个核心函数补上一份表驱动测试,再逐步扩展到整个模块。测试代码与生产代码同等重要——它们同样需要可读、可维护、可演进。