GO [ 单元测试 ]

单元测试

前面我们已经学习了 Go 的变量、常量、数据类型、输入输出、条件控制、切片、字符串、映射表、指针、结构体、函数、方法、接口、类型、错误、文件、反射、泛型和模块管理。接下来学习 Go 项目中不可缺少的一部分:单元测试(unit testing)。

代码能够编译,并不代表代码是正确的。编译器可以检查语法和类型,却不能替我们判断:一个空栈应该返回什么、一个非法输入是否应该返回错误、一个函数在边界值下是否仍然满足约定。单元测试要做的,就是把这些约定写成可以重复执行的程序。

Go 标准库中的 testing 包提供了测试、基准测试、模糊测试和示例测试的基础能力,go test 命令负责发现测试文件、编译测试二进制并执行测试。

本文的核心判断是:

  • 测试应该验证公开行为,而不是只验证代码"执行过";
  • 测试文件通常与被测源文件放在同一个包目录中,并以 _test.go 结尾;
  • 表格驱动测试和子测试适合覆盖一组相似场景;
  • testing 包不强制提供断言库,使用普通的 if 判断配合 Errorf、Fatalf 就足够开始;
  • 基准测试、模糊测试、竞态检测和覆盖率分别回答不同问题,不能用一个数字代替全部质量判断;
  • 测试代码也需要维护,测试本身应该清楚、稳定并且容易定位失败原因。

本文示例在 go1.27.0 darwin/arm64 环境中编译运行。不同操作系统、处理器和 Go 版本的耗时、覆盖率以及基准测试数字可能不同;测试 API 的基本规则以 Go 官方 testing 包文档 为准。

为什么需要单元测试

先看一个看起来已经完成的函数:

复制代码
package mathutil

func AddSum(a, b int) int {
	return a - b
}

这段代码没有语法错误,也可以正常编译。如果没有测试,错误可能直到接口上线后才被发现。我们真正关心的不是函数有没有被调用,而是它的输入和输出是否符合约定:

复制代码
func TestAddSum(t *testing.T) {
	got := AddSum(1, 2)
	if got != 3 {
		t.Errorf("AddSum(1, 2) = %d; want 3", got)
	}
}

测试失败时,错误信息应该直接说明三件事:调用了什么、实际得到什么、期望得到什么。只写下面的代码没有测试价值:

复制代码
func TestAddSum(t *testing.T) {
	AddSum(1, 2) // 只是调用,没有检查结果
}

函数被调用并不等于行为正确。测试必须建立一个可观察的结果,然后把结果与期望进行比较。

testing 包的工作方式

Go 的测试发现依赖命名约定。官方文档规定,测试文件名以 _test.go 结尾,测试函数的形式是 TestXxx(*testing.T),其中 Xxx 不能以小写字母开头。

复制代码
源码文件                 测试文件
stack.go                  stack_test.go
    │                         │
    └────────── go test ──────┘
                  │
       编译临时测试二进制并执行
                  │
       Test / Benchmark / Fuzz / Example

常见的四类函数如下:

函数形式 用途 执行方式
func TestXxx(t *testing.T) 普通测试 go test
func BenchmarkXxx(b *testing.B) 基准测试 go test -bench .
func FuzzXxx(f *testing.F) 模糊测试 go test 或 go test -fuzz=...
func ExampleXxx() 示例测试和文档示例 go test

测试文件会被 go build 等普通构建流程排除,只有 go test 会把它们编译进测试二进制。因此,测试辅助代码可以放在 _test.go 文件中,不会进入正常发布的程序。

第一个可运行的测试

官方推荐把测试文件放在被测包的目录中,而不是额外创建一个名为 test 的目录。先创建一个练习模块:

复制代码
mkdir learn-test
cd learn-test
go mod init example.com/learn-test
mkdir stack

项目结构如下:

复制代码
learn-test
├── go.mod
└── stack
    ├── stack.go
    └── stack_test.go

这次我们实现一个泛型栈,并用单元测试验证它的后进先出行为。把测试和数据结构放在同一个包中,读者可以从一个完整的小例子看到测试的写法。

实现一个泛型栈

在 stack/stack.go 中写入:

复制代码
package stack

import "errors"

// ErrEmpty 表示从空栈读取数据。
var ErrEmpty = errors.New("stack is empty")

// Stack 是一个后进先出(LIFO)的栈。
// 零值可以直接使用,不需要额外的构造函数。
type Stack[T any] struct {
	items []T
}

// Push 将 value 放入栈顶。
func (s *Stack[T]) Push(value T) {
	s.items = append(s.items, value)
}

// Pop 删除并返回栈顶元素。
func (s *Stack[T]) Pop() (T, error) {
	if len(s.items) == 0 {
		var zero T
		return zero, ErrEmpty
	}

	last := len(s.items) - 1
	value := s.items[last]
	var zero T
	s.items[last] = zero // 释放元素引用,避免长生命周期栈保留对象
	s.items = s.items[:last]
	return value, nil
}

// Peek 只读取栈顶元素,不删除它。
func (s *Stack[T]) Peek() (T, error) {
	if len(s.items) == 0 {
		var zero T
		return zero, ErrEmpty
	}

	return s.items[len(s.items)-1], nil
}

// Len 返回栈中元素个数。
func (s *Stack[T]) Len() int {
	return len(s.items)
}

// IsEmpty 判断栈是否为空。
func (s *Stack[T]) IsEmpty() bool {
	return len(s.items) == 0
}

这里特意让 Stack[T] 的零值可用:

复制代码
var numbers Stack[int]
numbers.Push(10)

Go 中很多标准库类型都遵循"零值可用"的设计。这样既少了一个初始化步骤,也减少了调用方忘记初始化的可能。

Pop 在空栈时返回 ErrEmpty,而不是依赖错误字符串。调用方可以使用 errors.Is 判断错误类别:

复制代码
if errors.Is(err, ErrEmpty) {
	// 栈为空,这是一个可预期的业务情况
}

编写第一个测试

在 stack/stack_test.go 中写入:

复制代码
package stack

import (
	"errors"
	"testing"
)

func TestStackPushPop(t *testing.T) {
	var s Stack[int]

	s.Push(10)
	s.Push(20)

	if got := s.Len(); got != 2 {
		t.Fatalf("Len() = %d; want 2", got)
	}

	got, err := s.Pop()
	if err != nil {
		t.Fatalf("Pop() returned an unexpected error: %v", err)
	}
	if got != 20 {
		t.Errorf("first Pop() = %d; want 20", got)
	}

	got, err = s.Pop()
	if err != nil {
		t.Fatalf("second Pop() returned an unexpected error: %v", err)
	}
	if got != 10 {
		t.Errorf("second Pop() = %d; want 10", got)
	}

	if !s.IsEmpty() {
		t.Errorf("IsEmpty() = false; want true")
	}
}

func TestStackPopEmpty(t *testing.T) {
	var s Stack[string]

	got, err := s.Pop()
	if !errors.Is(err, ErrEmpty) {
		t.Fatalf("Pop() error = %v; want ErrEmpty", err)
	}
	if got != "" {
		t.Errorf("Pop() value = %q; want zero value", got)
	}
}

进入 stack 目录执行:

复制代码
go test

输出类似:

复制代码
PASS
ok      example.com/learn-test/stack      0.002s

go test 会执行当前目录对应的包。想看到每个测试函数的运行过程,可以添加 -v:

复制代码
go test -v

=== RUN   TestStackPushPop
--- PASS: TestStackPushPop (0.00s)
=== RUN   TestStackPopEmpty
--- PASS: TestStackPopEmpty (0.00s)
PASS
ok      example.com/learn-test/stack      0.002s

如果测试失败,go test 会输出失败测试的名称、源文件行号和我们通过 Errorf 或 Fatalf 写出的信息。

测试包应该怎么选择

测试文件可以使用两种包名。

与被测代码相同的包:白盒测试

复制代码
package stack

这种方式可以访问 stack 包中的未导出标识符,适合测试包内部的细节,例如检查一个私有辅助函数或某个内部状态。上面的 stack_test.go 就是白盒测试。

使用 _test 后缀:黑盒测试

复制代码
package stack_test

这种方式只能通过导出的 API 使用被测包,更接近真实调用方,适合验证包对外提供的契约。示例:

复制代码
package stack_test

import (
	"errors"
	"testing"

	"example.com/learn-test/stack"
)

func TestStackPublicAPI(t *testing.T) {
	var s stack.Stack[string]
	s.Push("Go")

	got, err := s.Peek()
	if err != nil {
		t.Fatalf("Peek() returned an unexpected error: %v", err)
	}
	if got != "Go" {
		t.Errorf("Peek() = %q; want Go", got)
	}

	_, err = s.Pop()
	if err != nil {
		t.Fatalf("Pop() returned an unexpected error: %v", err)
	}

	_, err = s.Pop()
	if !errors.Is(err, stack.ErrEmpty) {
		t.Errorf("Pop() error = %v; want stack.ErrEmpty", err)
	}
}

同一个目录下可以同时存在 package stack 和 package stack_test 的测试文件,但正常源文件只能属于一个包。我的建议是:先用白盒测试把边界行为测清楚,再补一组黑盒测试确认公开 API 没有依赖内部实现。

用户补充的示例中把测试文件放在独立的 test 目录,并写成 package test。这种方式也可以存在,但它已经是另一个包,导入路径必须由 go.mod 中的模块名决定;初学和普通项目中,把 utils_test.go 放在 utils 目录下通常更直接。

表格驱动测试

当多个测试场景拥有相同的测试流程时,与其复制很多个测试函数,不如把变化部分放进表格。Go 社区把这种写法称为表格驱动测试(table-driven test)。

先抽出一个测试帮助函数:

复制代码
func assertPop[T comparable](t *testing.T, s *Stack[T], want T) {
	t.Helper()

	got, err := s.Pop()
	if err != nil {
		t.Fatalf("Pop() returned an unexpected error: %v", err)
	}
	if got != want {
		t.Errorf("Pop() = %v; want %v", got, want)
	}
}

这里的 T comparable 是因为测试帮助函数需要使用 != 比较实际值和期望值。被测栈本身仍然可以存储任意 any 类型,并没有限制业务类型。

接着编写表格:

复制代码
func TestStackPopTable(t *testing.T) {
	tests := []struct {
		name  string
		items []int
		want  []int
	}{
		{
			name:  "single item",
			items: []int{1},
			want:  []int{1},
		},
		{
			name:  "last in first out",
			items: []int{1, 2, 3},
			want:  []int{3, 2, 1},
		},
		{
			name:  "duplicate values",
			items: []int{7, 7, 8},
			want:  []int{8, 7, 7},
		},
	}

	for _, tc := range tests {
		tc := tc // 兼容旧版本 Go 的闭包变量语义
		t.Run(tc.name, func(t *testing.T) {
			var s Stack[int]
			for _, item := range tc.items {
				s.Push(item)
			}

			for _, want := range tc.want {
				assertPop(t, &s, want)
			}
		})
	}
}

这里使用 t.Run 创建了三个子测试。运行结果会显示完整的层级名称:

复制代码
=== RUN   TestStackPopTable
=== RUN   TestStackPopTable/single_item
=== RUN   TestStackPopTable/last_in_first_out
=== RUN   TestStackPopTable/duplicate_values
--- PASS: TestStackPopTable (0.00s)
    --- PASS: TestStackPopTable/single_item (0.00s)
    --- PASS: TestStackPopTable/last_in_first_out (0.00s)
    --- PASS: TestStackPopTable/duplicate_values (0.00s)

子测试的价值不只是输出更漂亮,还可以单独运行某个场景:

复制代码
go test -run '^TestStackPopTable/last_in_first_out$'

-run 参数是正则表达式,并且支持用 / 匹配测试层级。官方博客 Using Subtests and Sub-benchmarks 也使用这种方式筛选子测试。

Error、Fatal 和 Fail

Error 或 Errorf 会记录失败并继续执行当前测试函数;Fatal 或 Fatalf 会记录失败并通过 runtime.Goexit 结束当前测试 goroutine。它们的选择通常遵循这个规则:

复制代码
value, err := operation()
if err != nil {
	t.Fatalf("operation failed: %v", err) // 没有 value 就无法继续
}

if value != want {
	t.Errorf("value = %v; want %v", value, want) // 一个断言失败,不妨继续看其他断言
}

不要在测试 goroutine 之外调用 t.Fatal 或 t.FailNow。官方文档明确说明,这些方法必须由运行该测试的 goroutine 调用;异步 goroutine 应该通过 channel、错误值或 t.Errorf 把结果传回测试主流程。

帮助函数和清理函数

t.Helper

公共测试逻辑抽成帮助函数后,如果没有标记,失败位置可能指向帮助函数内部,而不是具体测试用例。只需要调用一次 t.Helper():

复制代码
func assertEqual[T comparable](t *testing.T, got, want T) {
	t.Helper()
	if got != want {
		t.Errorf("got %v; want %v", got, want)
	}
}

当断言失败时,testing 会跳过这个帮助函数,把文件名和行号定位到调用 assertEqual 的测试代码。测试辅助函数越多,t.Helper 带来的定位收益越明显。

t.Cleanup

测试需要释放资源时,可以使用 t.Cleanup 注册清理函数。测试结束后,清理函数会自动执行,并且多个清理函数按照后进先出的顺序运行:

复制代码
func TestResource(t *testing.T) {
	resource := openResource()
	t.Cleanup(func() {
		_ = resource.Close()
	})

	// 测试 resource
}

实际的资源创建失败时仍然要立即报告错误。清理函数适合关闭文件、停止服务、恢复全局配置等工作,可以避免测试中间某个分支忘记释放资源。

t.TempDir 和 t.Setenv

需要临时文件时,不要把固定目录写死在项目中:

复制代码
func TestWriteFile(t *testing.T) {
	dir := t.TempDir()
	path := filepath.Join(dir, "result.txt")

	if err := os.WriteFile(path, []byte("hello"), 0o600); err != nil {
		t.Fatalf("WriteFile() failed: %v", err)
	}
}

t.TempDir 返回的目录会在测试结束后自动删除。测试环境变量时,可以使用 t.Setenv,测试结束后 Go 会恢复原值:

复制代码
func TestConfigFromEnv(t *testing.T) {
	t.Setenv("APP_MODE", "test")
	// 读取 APP_MODE 并进行断言
}

依赖全局环境的测试不应该随意调用 t.Parallel;例如 t.Setenv 与并行测试组合会让测试之间互相影响。

TestMain:整个包的钩子

如果同一个测试包需要统一初始化和收尾,可以定义 TestMain:

复制代码
package stack

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

func setup() {
	fmt.Println("setup")
}

func teardown() {
	fmt.Println("teardown")
}

func TestMain(m *testing.M) {
	setup()
	code := m.Run()
	teardown()
	os.Exit(code)
}

当 TestMain 存在时,测试进程会先调用它。只有调用 m.Run(),包中的普通测试、基准测试和示例测试才会真正运行。m.Run 返回测试结果状态码,通常直接传给 os.Exit。

不过,包级别状态越多,测试之间越容易互相影响。能用单个测试的 t.Cleanup 解决的问题,不要全部堆到 TestMain 中;TestMain 更适合启动一次测试服务器、创建数据库连接或配置统一日志等包级资源。

基准测试

普通测试回答"结果是否正确",基准测试回答"这段代码在重复执行时大致需要多少时间和内存"。基准测试函数必须以 Benchmark 开头,参数类型为 *testing.B。

在 stack/stack_test.go 中加入:

复制代码
func BenchmarkStackPushPop(b *testing.B) {
	b.ReportAllocs()

	for i := 0; i < b.N; i++ {
		var s Stack[int]
		s.Push(i)
		_, _ = s.Pop()
	}
}

func BenchmarkStackPushPopParallel(b *testing.B) {
	b.RunParallel(func(pb *testing.PB) {
		var s Stack[int]
		for pb.Next() {
			s.Push(1)
			_, _ = s.Pop()
		}
	})
}

执行基准测试:

复制代码
go test -run '^$' -bench '^BenchmarkStackPushPop$' -benchmem

输出示例:

复制代码
goos: darwin
goarch: arm64
pkg: example.com/learn-test/stack
cpu: Apple M-series
BenchmarkStackPushPop-12        104216470        11.31 ns/op       8 B/op       1 allocs/op
PASS
ok      example.com/learn-test/stack      0.612s

实际数字会随着处理器、Go 版本、编译器优化和系统负载变化。结果字段含义如下:

字段 含义
N 基准框架为了稳定测量而执行的迭代次数
ns/op 每次迭代平均耗时
B/op 每次迭代平均分配的字节数
allocs/op 每次迭代平均分配次数

这里使用 b.N 而不是手写一个固定循环次数,因为 testing 会自动调整 N,让测量达到足够的稳定性。b.RunParallel 会把工作分配给多个并发执行单元;每个并发执行单元都使用自己的栈,所以这个基准没有故意引入数据竞争。

-run '^$' 用于跳过普通测试。若不加它,go test -bench . 仍然可能先执行普通测试,然后再执行基准测试。基准测试不应该包含与被测操作无关的准备工作;如果确实有一次性准备,可以用 b.StopTimer 和 b.StartTimer 把准备阶段排除在计时之外。

覆盖率

覆盖率可以回答"测试执行过哪些语句",但不能直接回答"测试是否设计得好"。先查看当前包的覆盖率:

复制代码
go test -cover

保存覆盖率文件并查看函数级统计:

复制代码
go test -coverprofile=coverage.out
go tool cover -func=coverage.out

也可以生成 HTML 报告:

复制代码
go tool cover -html=coverage.out

覆盖率高但断言很弱的测试,仍然可能漏掉关键错误。例如测试只调用函数却从不检查返回值,代码行会被执行,业务行为却没有被验证。因此我更建议先根据输入、输出、错误和边界条件设计测试,再把覆盖率作为遗漏线索,而不是把某个百分比当成唯一目标。

在模块根目录执行所有包的测试和覆盖率:

复制代码
go test -cover ./...

./... 是 Go 包路径模式,表示当前模块及其子目录中的包。它和 go test 当前目录只测试一个包的行为不同。

模糊测试

普通测试由我们提供有限的输入;模糊测试(fuzzing)会在种子输入基础上持续变换数据,并利用覆盖率寻找新的执行路径。Go 从 1.18 起在标准工具链中支持原生模糊测试。

为栈添加一个模糊测试。它把任意字符串转换成字符序列,压栈后再逆序弹出,验证后进先出性质:

复制代码
func FuzzStackLIFO(f *testing.F) {
	f.Add("Go")
	f.Add("")
	f.Add("泛型与测试")

	f.Fuzz(func(t *testing.T, input string) {
		values := []rune(input)
		var s Stack[rune]

		for _, value := range values {
			s.Push(value)
		}

		for i := len(values) - 1; i >= 0; i-- {
			got, err := s.Pop()
			if err != nil {
				t.Fatalf("Pop() returned an unexpected error: %v", err)
			}
			if got != values[i] {
				t.Fatalf("Pop() = %q; want %q", got, values[i])
			}
		}

		if !s.IsEmpty() {
			t.Fatal("stack is not empty after popping all values")
		}
	})
}

普通执行 go test 时,Go 会运行模糊测试中的种子语料;想真正启动持续模糊搜索,可以指定 -fuzz 和运行时间:

复制代码
go test -fuzz=FuzzStackLIFO -fuzztime=10s

模糊测试函数必须满足 func FuzzXxx(f *testing.F) 的形式,并在 f.Fuzz 中接收 *testing.T 和支持的基本参数类型。官方文档 Go Fuzzing 说明了种子语料、生成语料和失败输入的关系。

如果模糊测试找到失败输入,Go 会把能够复现问题的样本保存到类似下面的目录:

复制代码
testdata/fuzz/FuzzStackLIFO/
└── <hash>

修复代码后,这个样本会继续作为回归测试执行。也就是说,模糊测试不是只"随机跑一遍",它会把发现的问题转化为项目中的测试资产。

示例测试

示例测试既能验证输出,又可以被 go doc 识别为文档示例。函数名为 Example,函数体末尾使用 // Output: 写出精确期望:

复制代码
func ExampleStack() {
	var s Stack[string]
	s.Push("Go")
	s.Push("testing")

	top, _ := s.Pop()
	fmt.Println(top)

	// Output:
	// testing
}

不要把不稳定的内容放进输出断言,例如当前时间、随机数和依赖机器环境的绝对路径。示例测试最适合展示包的常见用法,同时让文档里的代码不会悄悄过时。

并行测试和竞态检测

t.Parallel

如果测试之间完全隔离,可以让它们并行执行:

复制代码
func TestIndependentCase(t *testing.T) {
	t.Parallel()

	// 只能使用本测试独有的变量和资源
}

调用 t.Parallel 后,测试会在满足并行限制时与其他并行测试同时执行。共享可变变量、固定临时文件名、同一个数据库记录和全局环境变量,都可能使并行测试互相污染。并行执行是优化手段,不是测试正确性的前提,先保证隔离再使用。

go test -race

我们的 Stack[T] 没有加锁,因此同一个栈不能被多个 goroutine 同时读写。竞态检测器可以在测试运行时发现一部分数据竞争:

复制代码
go test -race ./...

-race 会插入额外的检测代码,运行速度和内存消耗会增加。它不能证明"绝对没有并发 bug",但对并发代码而言,定期执行 go test -race 比只看普通测试结果更可靠。Go 官方的 Race Detector 文档 介绍了它的使用方式和限制。

如果产品需求是并发安全的栈,就应该在实现中增加 sync.Mutex 或设计清晰的单线程所有权模型,并编写真正的并发测试;不要因为普通单元测试通过,就默认数据结构已经线程安全。

测试依赖和可替换的时间

测试难写,很多时候不是因为逻辑复杂,而是因为代码直接依赖当前时间、随机数、网络或真实数据库。一个简单的做法是把变化的依赖通过接口传进来:

复制代码
package expiry

import "time"

type Clock interface {
	Now() time.Time
}

func IsExpired(clock Clock, deadline time.Time) bool {
	return !clock.Now().Before(deadline)
}

type fakeClock struct {
	now time.Time
}

func (c fakeClock) Now() time.Time {
	return c.now
}

测试可以固定时间:

复制代码
func TestIsExpired(t *testing.T) {
	deadline := time.Date(2026, 9, 30, 12, 0, 0, 0, time.UTC)
	clock := fakeClock{now: deadline.Add(time.Second)}

	if !IsExpired(clock, deadline) {
		t.Fatal("IsExpired() = false; want true")
	}
}

这里没有等待真实时间,也没有修改系统时钟,所以测试快速且稳定。网络请求、数据库和消息队列也可以使用相同的思路:先定义业务真正需要的最小接口,再在测试中注入一个可控的替身。不要为了"方便 mock"而把所有类型都抽象成很大的接口,接口应该由使用者定义,并且只包含真正需要的方法。

常用命令

模块根目录下经常用到的测试命令如下:

命令 作用
go test 测试当前包
go test ./... 测试当前模块中的所有包
go test -v 输出每个测试的运行信息
go test -run TestName 运行名称匹配正则的测试
go test -run '^TestX/Case$' 运行某个子测试
go test -count=1 跳过测试缓存,强制重新执行
go test -shuffle=on 随机化测试顺序,帮助发现共享状态问题
go test -failfast 首个测试失败后尽快停止
go test -timeout=30s 设置测试总超时时间
go test -cover 输出语句覆盖率摘要
go test -race ./... 运行竞态检测
go test -json 输出机器可解析的测试事件
go test -bench . -benchmem 执行基准测试并报告内存分配
go test -fuzz=FuzzXxx -fuzztime=10s 执行指定模糊测试

测试结果被缓存时,go test 可能显示缓存结果。怀疑环境、时间或随机数导致结果不可信时,可以使用:

复制代码
go test -count=1 ./...

查看所有测试命令和参数的官方解释,可以执行:

复制代码
go help test
go help testflag

一个完整的测试流程

把上面的例子组合起来,日常开发可以按下面的流程执行:

复制代码
修改业务代码
    │
    ├── gofmt -w .
    ├── go test ./...
    ├── go test -race ./...       (包含并发代码时)
    ├── go test -cover ./...      (检查遗漏路径时)
    └── go test -bench ...        (性能有明确目标时)

测试失败后先读第一条真正的失败信息,不要只看最后的 FAIL。如果失败来自模糊测试保存的语料,先用普通 go test 重现,再修复实现并确认回归测试通过。

常见错误

把"调用过"当成"测试通过"

复制代码
func TestSomething(t *testing.T) {
	DoSomething() // 没有检查返回值或状态
}

这种测试只能证明代码没有在这里直接 panic,不能证明返回值、错误和副作用符合预期。

用错误字符串判断错误类型

复制代码
if err.Error() == "stack is empty" {
	// 文案一改,这个判断就失效
}

应该让代码返回哨兵错误或自定义错误类型,再使用 errors.Is 或 errors.As。本文的 ErrEmpty 就是这种用法。

测试依赖网络、时间和随机数

网络抖动、时区、机器负载和随机种子都会使测试偶发失败。单元测试应尽量使用内存数据和可控依赖;真实网络和数据库放到专门的集成测试中。

为了覆盖率测试内部实现

如果测试强行检查切片容量、私有字段排列或某个临时变量,重构实现时会得到大量无意义的失败。优先验证公开行为,只有内部不变量本身就是需要保证的契约时,才进行白盒断言。

基准测试没有使用 b.N

复制代码
func BenchmarkBad(b *testing.B) {
	for i := 0; i < 1000; i++ {
		work()
	}
}

固定循环次数会让 testing 无法正确调节工作量。基准测试必须使用 b.N,并把不属于被测操作的准备阶段移出计时区间。

并行测试共享可变状态

调用 t.Parallel 后,包级变量、全局环境变量和固定文件名都可能成为隐蔽的共享状态。先让每个测试拥有独立数据,再考虑并行化;最后用 go test -race 检查并发访问。

小结

到这里,我们已经从一个泛型栈出发,完整走过了 Go 单元测试的主要能力:

  • 使用 _test.go 文件和 TestXxx 函数编写普通测试;
  • 使用同包测试和 _test 黑盒测试区分内部实现与公开契约;
  • 使用表格驱动测试和 t.Run 覆盖多个场景;
  • 使用 t.Helper、t.Cleanup、t.TempDir 管理测试代码和资源;
  • 使用 TestMain 做包级初始化与收尾;
  • 使用 BenchmarkXxx、b.N 和 b.RunParallel 测量性能;
  • 使用覆盖率寻找未执行路径,而不是把覆盖率当作质量分数;
  • 使用 FuzzXxx 发现边界输入,并把失败样本保存为回归测试;
  • 使用示例测试、竞态检测和依赖注入,让代码更容易理解和验证。

一个项目不需要一开始就引入复杂的测试框架。先让每个重要函数拥有清楚的输入、输出和错误约定,再用 go test ./... 把这些约定固定下来。测试的价值不在于文件数量,而在于它能否在代码改变后及时告诉我们:行为哪里变了、为什么变了、是否仍然符合设计。

官方资料

相关推荐
喵了几个咪2 小时前
Go 写业务,Rust 扛底盘:一套可落地的混合架构
微服务·架构·golang·rust·多租户·gowind·rushwind
ttwuai2 小时前
Golang Web后台管理框架推荐:Gin、GoFrame与数据面板项目怎么分
前端·golang·gin
魏码不凡2 小时前
Go 编译报错:undefined: webp.Encode 问题排查与解决
开发语言·后端·golang
tachibana24 小时前
与其他语言相比,使用 Go 有什么好处?
开发语言·后端·golang·go
hasty17 小时前
限制写了却没生效:OpenTelemetry Go 的 Unicode 截断边界
开发语言·后端·golang
李游Leo20 小时前
HarmonyOS 7 + ArkUI-Hvigor:动态字体与色彩对比上架预检【鸿蒙心迹】
华为·log4j·harmonyos
EatFan1 天前
Go语言全栈实战:基于 Gin + Vue + JWT + RBAC 从零搭建前后端分离权限管理系统
vue.js·golang·go·vue·gin·jwt·rbac
ZealSinger1 天前
Go slog生产落地LevelVar与共存
开发语言·后端·golang·go
丹宇码农1 天前
Go 与 Python 协程(Coroutine)对比演示项目
开发语言·python·golang