Go代码如何测试?


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 表驱动测试

表驱动测试,顾名思义,就是把测试用例组织成一张表格,每一行包含输入和期望的输出结果。这种表格形式有两点好处:

  1. 表格中的测试数据复用同一套测试逻辑,使测试逻辑清晰明了;
  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 循环的循环变量在整个循环过程中只声明一次,每次迭代都是修改它的值。旧版本的执行流程:

  1. 第一次迭代,tc = {9, "Foo"},启动子测试(但被 t.Parallel() 挂起,暂不执行函数体);
  2. 第二次迭代,tc = {3, "Foo"},启动子测试(同样挂起);
  3. ……循环快速跑完,最终 tc 变成了最后一个用例的值 {19, "19"};
  4. 所有子测试解除挂起,同时去读取 tc.input 和 tc.want。

结果就是:所有并发的子测试拿到的都是最后一个 tc 的值,导致测试全部失败或产生混乱的结果。这就是经典的「循环变量捕获陷阱」。

从 Go 1.22 开始,for 循环的语义发生了变化:每次迭代都会创建一个新的循环变量实例,每个子测试闭包捕获的都是属于自己那次迭代的独立变量,互不干扰。

问题3:fuzz 测试没有触发预期的 bug

进行 fuzz 测试时,我只添加了 3 个种子,但故意预留的 2025 这个 bug 没有被触发,为什么这个模糊测试没有测到它?

原因:fuzz 测试中,f.Add(xx) 是基于传入的种子参数进行变异(位翻转、加减、交换等操作)来生成新输入的,同时结合覆盖率引导。由于变异是随机/启发式的,并不能保证一定生成 2025 这个特定值;只有当种子或变异路径恰好接近 2025 时,才可能覆盖到它。这也正是模糊测试需要足够多样、足够多的种子和运行时间的原因。

参考链接

本文为个人学习记录,如有错误,欢迎批评指正。


文章作者: ZhangZhengYu
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 ZhangZhengYu !
评论
  目录