Go语言单元测试

Go语言自带了一套轻量级的测试框架——testing 包和 go test 命令。不需要引入第三方框架,开箱即用就能完成单元测试、基准测试和示例测试。这套工具的设计哲学和Go一脉相承:简单、显式、够用


测试文件与函数的约定

Go的测试遵循严格的命名约定,go test 依赖这些约定来发现和执行测试:

约定 规则 示例
文件名 必须以 _test.go 结尾 math_test.go
测试函数 必须以 Test 开头,参数为 *testing.T func TestAdd(t *testing.T)
函数名格式 Test + 被测函数名(首字母大写) TestCalculateSum

_test.go 文件不会被编译到最终的二进制文件中,它只在 go test 时参与编译。


第一个单元测试

假设我们有一个简单的计算模块:

// calc.go
package calc

func Add(a, b int) int {
return a + b
}

func Multiply(a, b int) int {
return a * b
}

对应的测试文件:

// calc_test.go
package calc

import "testing"

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

func TestMultiply(t *testing.T) {
result := Multiply(3, 4)
if result != 12 {
t.Errorf("Multiply(3, 4) = %d, want 12", result)
}
}

运行测试:

# 运行当前包的所有测试
go test

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

# 运行特定测试函数(支持正则)
go test -run TestAdd

# 运行项目中所有包的测试
go test ./...

go test -v 的输出:

=== RUN   TestAdd
--- PASS: TestAdd (0.00s)
=== RUN TestMultiply
--- PASS: TestMultiply (0.00s)
PASS
ok example/calc 0.003s

testing.T 的常用方法

testing.T 是测试函数的核心参数,提供了日志和失败控制方法:

日志方法

func TestExample(t *testing.T) {
t.Log("这是一条日志,只在 -v 模式或测试失败时显示")
t.Logf("格式化日志: name=%s, age=%d", "Go", 15)
}

失败方法

func TestFailMethods(t *testing.T) {
// Errorf:标记失败 + 记录信息,继续执行后续代码
t.Errorf("这个断言失败了,但后面的代码还会执行")

// Fatalf:标记失败 + 记录信息,立即停止当前测试函数
t.Fatalf("严重错误,后面的代码不会执行")

// 这行不会执行
t.Log("不会到这里")
}

完整的方法对比:

方法 记录信息 标记失败 停止执行 适用场景
Log / Logf 调试输出
Error / Errorf 断言失败,但想继续检查其他断言
Fatal / Fatalf 关键前置条件失败,后续测试无意义
Skip / Skipf 跳过测试(如环境不满足)

选择原则:大多数情况用 Errorf——让一个测试函数中的多个断言都能执行,这样一次运行就能看到所有失败。只在”后续代码依赖这个结果”时用 Fatalf


表驱动测试(Table-Driven Tests)

Go社区推崇的测试模式——用一个结构体切片定义所有测试用例,循环执行:

func TestAdd(t *testing.T) {
tests := []struct {
name string
a, b int
expected int
}{
{"正数相加", 2, 3, 5},
{"负数相加", -1, -2, -3},
{"零值", 0, 0, 0},
{"正负相加", 10, -3, 7},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := Add(tt.a, tt.b)
if result != tt.expected {
t.Errorf("Add(%d, %d) = %d, want %d", tt.a, tt.b, result, tt.expected)
}
})
}
}

输出:

=== RUN   TestAdd
=== RUN TestAdd/正数相加
=== RUN TestAdd/负数相加
=== RUN TestAdd/零值
=== RUN TestAdd/正负相加
--- PASS: TestAdd (0.00s)
--- PASS: TestAdd/正数相加 (0.00s)
--- PASS: TestAdd/负数相加 (0.00s)
--- PASS: TestAdd/零值 (0.00s)
--- PASS: TestAdd/正负相加 (0.00s)

表驱动测试的好处:

  1. 新增用例只需加一行——不需要写新的测试函数
  2. 用例和逻辑分离——数据在上面,断言逻辑在下面,清晰
  3. 每个用例有名字——失败时能精确定位是哪个场景出了问题

子测试(Subtests)

上面表驱动测试中用到的 t.Run 就是子测试。子测试不仅用于表驱动,还有更多能力:

单独运行某个子测试

# 运行 TestAdd 下名为 "零值" 的子测试
go test -run TestAdd/零值

# 支持正则匹配
go test -run TestAdd/正

子测试的嵌套

func TestMath(t *testing.T) {
t.Run("加法", func(t *testing.T) {
t.Run("正数", func(t *testing.T) {
if Add(1, 2) != 3 {
t.Error("failed")
}
})
t.Run("负数", func(t *testing.T) {
if Add(-1, -2) != -3 {
t.Error("failed")
}
})
})

t.Run("乘法", func(t *testing.T) {
if Multiply(3, 4) != 12 {
t.Error("failed")
}
})
}

并行子测试

func TestAddParallel(t *testing.T) {
tests := []struct {
name string
a, b int
expected int
}{
{"case1", 1, 2, 3},
{"case2", 10, 20, 30},
{"case3", -5, 5, 0},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel() // 标记为可并行执行
result := Add(tt.a, tt.b)
if result != tt.expected {
t.Errorf("Add(%d, %d) = %d, want %d", tt.a, tt.b, result, tt.expected)
}
})
}
}

t.Parallel() 让子测试并行运行,可以加速独立用例的执行。注意:并行测试中不要共享可变状态。


TestMain:测试的生命周期控制

TestMain 是整个测试包的入口函数。如果定义了它,go test 会调用 TestMain 而不是直接运行测试函数。你需要在 TestMain 中手动调用 m.Run() 来执行测试:

// calc_test.go
package calc

import (
"fmt"
"os"
"testing"
)

func TestMain(m *testing.M) {
// ① 测试前的初始化(Setup)
fmt.Println("=== 初始化测试环境 ===")
// 比如:连接测试数据库、创建临时目录、加载测试配置...

// ② 运行所有测试
exitCode := m.Run()

// ③ 测试后的清理(Teardown)
fmt.Println("=== 清理测试环境 ===")
// 比如:关闭数据库连接、删除临时文件...

// ④ 必须用 os.Exit 传递退出码
os.Exit(exitCode)
}

func TestAdd(t *testing.T) {
if Add(1, 2) != 3 {
t.Error("Add(1, 2) should be 3")
}
}

执行流程:

TestMain 开始
→ Setup(初始化)
→ m.Run()(执行所有 Test* 函数)
→ Teardown(清理)
→ os.Exit(exitCode)

TestMain的注意事项

  1. 一个包只能有一个 TestMain——它是包级别的入口
  2. **必须调用 m.Run()**——否则不会执行任何测试
  3. **必须调用 os.Exit(exitCode)**——否则 go test 无法正确报告成败
  4. **TestMain 中不能用 testing.T**——参数是 *testing.M

实际应用:数据库测试

var testDB *sql.DB

func TestMain(m *testing.M) {
// 连接测试数据库
var err error
testDB, err = sql.Open("mysql", "user:pass@tcp(localhost:3306)/test_db")
if err != nil {
fmt.Println("数据库连接失败:", err)
os.Exit(1)
}

// 运行测试
exitCode := m.Run()

// 清理
testDB.Close()
os.Exit(exitCode)
}

func TestCreateUser(t *testing.T) {
// 使用 testDB 进行测试...
}

t.Cleanup:更细粒度的清理

Go 1.14引入了 t.Cleanup,用于注册单个测试函数级别的清理逻辑(类似defer,但在测试结束时执行):

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)
}
}

t.Cleanup vs defer

特性 defer t.Cleanup
执行时机 函数返回时 测试结束时(包括子测试)
适用范围 当前函数 当前测试及其子测试
推荐场景 一般函数 测试辅助函数中注册清理

t.Cleanup 在编写测试辅助函数(test helper)时特别有用——辅助函数可以自己注册清理逻辑,调用方不需要关心清理:

// 辅助函数:创建临时目录并自动清理
func setupTempDir(t *testing.T) string {
t.Helper() // 标记为辅助函数,报错时显示调用方的行号
dir, err := os.MkdirTemp("", "test-*")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
os.RemoveAll(dir)
})
return dir
}

func TestSomething(t *testing.T) {
dir := setupTempDir(t) // 不需要管清理,自动处理
// 使用 dir...
}

t.Helper:让报错定位更精确

当封装测试辅助函数时,错误信息默认指向辅助函数内部,而不是调用方。t.Helper() 解决这个问题:

// 没有 t.Helper()
func assertEqual(t *testing.T, got, want int) {
if got != want {
t.Errorf("got %d, want %d", got, want) // 报错指向这一行
}
}

// 有 t.Helper()
func assertEqual(t *testing.T, got, want int) {
t.Helper() // 告诉testing:我是辅助函数,报错时跳过我
if got != want {
t.Errorf("got %d, want %d", got, want) // 报错指向调用 assertEqual 的那一行
}
}

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

go test 常用命令

# 基础用法
go test # 运行当前包测试
go test ./... # 运行所有包测试
go test -v # 详细输出(显示每个测试的Log)
go test -run TestAdd # 只运行匹配的测试(正则)

# 超时控制
go test -timeout 30s # 设置超时,默认10分钟

# 覆盖率
go test -cover # 显示覆盖率百分比
go test -coverprofile=coverage.out # 生成覆盖率文件
go tool cover -html=coverage.out # 浏览器查看覆盖率报告(可视化)
go tool cover -func=coverage.out # 命令行查看每个函数的覆盖率

# 缓存控制
go test -count=1 ./... # 禁用缓存,强制重新运行

# 短模式
go test -short # 跳过耗时测试(需要代码配合)

-short 模式配合

func TestIntegration(t *testing.T) {
if testing.Short() {
t.Skip("跳过集成测试(-short模式)")
}
// 耗时的集成测试逻辑...
}

覆盖率分析实践

# 生成覆盖率报告
go test -coverprofile=coverage.out ./...

# 查看每个函数的覆盖率
go tool cover -func=coverage.out

输出示例:

example/calc/calc.go:3:     Add         100.0%
example/calc/calc.go:7: Multiply 100.0%
total: (statements) 100.0%
# 生成HTML可视化报告(在GoLand中也可以直接查看)
go tool cover -html=coverage.out -o coverage.html

HTML报告中,绿色表示已覆盖的代码,红色表示未覆盖的代码,一目了然。


易错点总结

问题 后果 解决方案
文件名不以 _test.go 结尾 测试不会被发现和执行 严格遵循命名约定
函数名不以 Test 开头 不会被识别为测试函数 Test + 大写字母开头
TestMain 没调用 m.Run() 所有测试被跳过 必须调用 m.Run()
TestMain 没调用 os.Exit 退出码丢失,CI误判 os.Exit(m.Run())
并行测试中共享可变状态 数据竞争,结果不确定 每个子测试用独立数据
辅助函数没调用 t.Helper() 报错行号指向辅助函数内部 辅助函数首行加 t.Helper()
Fatalf 后还期望执行代码 Fatalf 之后的代码不执行 多数断言用 Errorf,仅关键前置用 Fatalf
表驱动测试闭包捕获循环变量 Go 1.22之前可能用到错误的值 Go 1.22+ 已修复;旧版本在循环内 tt := tt

面试题精选

基础题

Q1:Go的单元测试有哪些命名约定?

三个约定:(1)测试文件必须以 _test.go 结尾;(2)测试函数必须以 Test 开头,且紧跟的字母必须大写(如 TestAdd 而非 Testadd);(3)测试函数的参数必须是 *testing.T_test.go 文件不会被编译到最终二进制中,只在 go test 时参与编译。

Q2:t.Errorft.Fatalf 的区别是什么?分别在什么时候使用?

t.Errorf 标记测试失败并记录信息,但继续执行当前测试函数后续代码;t.Fatalf 标记失败并立即停止当前测试函数(底层调用 runtime.Goexit())。使用原则:大多数断言用 Errorf,这样一次运行能看到所有失败;只在前置条件失败、后续代码无法执行时用 Fatalf(如文件打开失败、连接建立失败)。

Q3:什么是表驱动测试?为什么Go社区推荐这种模式?

表驱动测试是把测试用例定义在一个结构体切片中,然后循环遍历执行的模式。推荐原因:(1)新增用例只需加一行数据,不需要写新函数;(2)测试数据和断言逻辑分离,结构清晰;(3)结合 t.Run 给每个用例命名,失败时能精确定位;(4)减少重复代码,维护成本低。这是Go标准库源码中大量使用的测试模式。

进阶题

Q4:TestMain的作用是什么?执行流程是怎样的?

TestMain 是包级别的测试入口函数,签名为 func TestMain(m *testing.M)。当定义了 TestMain 后,go test 不再直接运行 Test* 函数,而是调用 TestMain。执行流程:(1)TestMain 开始,执行Setup逻辑;(2)调用 m.Run() 运行所有测试,返回退出码;(3)执行Teardown逻辑;(4)调用 os.Exit(exitCode)。典型场景:连接/断开测试数据库、创建/清理临时目录、设置全局测试配置。注意:一个包只能有一个 TestMain,且必须调用 m.Run()os.Exit

Q5:t.Parallel() 是怎么工作的?有什么注意事项?

t.Parallel() 标记当前测试或子测试为可并行执行。调用后,当前测试会暂停,等到同一层级的串行测试全部完成后,所有标记了 Parallel 的测试并发运行。注意事项:(1)并行测试之间不能共享可变状态,否则会数据竞争;(2)可以用 go test -race 检测数据竞争;(3)在表驱动测试中使用 Parallel 时,Go 1.22之前需要 tt := tt 避免闭包捕获循环变量的问题;(4)默认并行度等于 GOMAXPROCS,可以用 -parallel N 参数控制。

Q6:t.Helper() 解决什么问题?什么时候应该使用?

当测试失败信息从辅助函数中发出时,默认报错行号指向辅助函数内部,而不是实际调用辅助函数的测试代码。t.Helper() 将当前函数标记为测试辅助函数,这样 t.Errorf 等方法报告的行号会跳过辅助函数,直接指向调用方。应该在所有自定义的断言函数(如 assertEqual)和测试Setup函数的第一行调用 t.Helper()

高级题

Q7:如何保证测试的独立性和可重复性?

(1)每个测试函数不依赖其他测试的执行顺序和结果——go test 不保证执行顺序;(2)测试所需的外部资源(文件、数据库记录等)在测试内创建、测试后清理,用 t.CleanupTestMain;(3)不依赖全局可变状态,如果必须用,在每个测试开始时重置;(4)使用 t.TempDir() 获取自动清理的临时目录,避免文件冲突;(5)网络依赖使用 httptest.NewServer 做本地mock;(6)用 -count=1 禁用缓存确保每次真实执行。

Q8:go test -cover 显示覆盖率90%,就说明代码质量好吗?

不一定。覆盖率衡量的是”代码被执行过”,不是”代码被正确验证”。例如:一个测试调用了函数但没有任何断言(只是 _ = result),覆盖率会上升但没有实际验证。高质量测试应该关注:(1)是否覆盖了边界条件(零值、空值、极限值);(2)是否验证了错误路径(不仅是happy path);(3)断言是否具体、有意义(不是简单的 != nil)。覆盖率是必要条件但不是充分条件——80%+的覆盖率是好的基线,但核心逻辑的覆盖率应该更高。

Q9:下面的表驱动并行测试在Go 1.21及以前版本有什么问题?如何修复?

func TestAdd(t *testing.T) {
tests := []struct {
name string
a, b int
expected int
}{
{"case1", 1, 2, 3},
{"case2", 10, 20, 30},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if Add(tt.a, tt.b) != tt.expected {
t.Errorf("failed")
}
})
}
}

在Go 1.21及以前,for range 的循环变量 tt 在整个循环中是同一个变量,闭包捕获的是变量的引用而非值。当子测试并行执行时,循环可能已经结束,所有子测试都使用最后一次迭代的值。修复方法:在循环内加 tt := tt(shadowing),创建一个局部副本。Go 1.22修改了循环变量的语义——每次迭代都是新变量,这个问题不再存在。但如果项目需要兼容旧版本,仍应加 tt := tt


小结

功能 工具/方法 说明
基本测试 func TestXxx(t *testing.T) 命名约定是发现测试的基础
失败报告 t.Errorf / t.Fatalf Errorf继续执行,Fatalf立即停止
表驱动测试 结构体切片 + t.Run Go社区推荐的标准测试模式
子测试 t.Run("name", func(t *testing.T){}) 支持嵌套、单独运行、并行
生命周期 TestMain(m *testing.M) 包级别的Setup/Teardown
清理 t.Cleanup(func(){}) 测试级别的自动清理
辅助函数 t.Helper() 让报错行号指向调用方
覆盖率 go test -cover 生成可视化覆盖率报告

Go的测试哲学:不需要复杂的断言库和mock框架,标准库的 if + t.Errorf 就是最好的断言。当项目规模增大后,可以按需引入 testify 等第三方库,但先掌握标准工具是基础。