Go 测试:让代码更可靠的秘密武器

全面掌握 Go 语言的测试技术:单元测试、表驱动测试、基准测试、示例测试和测试覆盖率

Go 测试:让代码更可靠的秘密武器

“我的代码能跑就行,为什么要写测试?”

如果你有过这样的想法,那你还没经历过这样的场景:凌晨三点,客户报告生产环境出了一个 bug,你翻遍了代码也找不到问题在哪;或者你修改了一个看似无关的函数,结果整个系统都崩溃了。

写测试不是为了凑代码行数,而是为了让你晚上能睡个好觉

Go 语言把测试放在了非常重要的位置——testing 是标准库的一部分,go test 命令内置在工具链中。这意味着你不需要安装任何第三方框架就能写测试。今天我们就来全面学习 Go 的测试技术。

第一个测试

Go 的测试文件有严格的命名规则:

  • 测试文件以 _test.go 结尾
  • 测试函数以 Test 开头,参数是 *testing.T

来看一个简单的例子。假设我们有一个 mathutil 包:

// mathutil.go
package mathutil

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

func Max(a, b int) int {
	if a > b {
		return a
	}
	return b
}

对应的测试文件:

// mathutil_test.go
package mathutil

import "testing"

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

func TestMax(t *testing.T) {
	result := Max(5, 3)
	if result != 5 {
		t.Errorf("Max(5, 3) = %d; want 5", result)
	}
}

运行测试:

$ go test
PASS
ok  	example/mathutil	0.001s

如果要看到详细输出:

$ go test -v
=== RUN   TestAdd
--- PASS: TestAdd (0.00s)
=== RUN   TestMax
--- PASS: TestMax (0.00s)
PASS
ok  	example/mathutil	0.001s

测试失败时的报告

让我们看一个失败的测试:

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

输出:

--- FAIL: TestAddFail (0.00s)
    mathutil_test.go:10: Add(2, 3) = 5; want 6
FAIL

注意 t.Errorft.Fatalf 的区别:

  • t.Errorf:报告错误但继续执行
  • t.Fatalf:报告错误并立即终止测试

表驱动测试(Table-Driven Tests)

这是 Go 社区强烈推荐的测试模式。把多个测试用例组织成表格,用循环执行:

func TestMax(t *testing.T) {
	tests := []struct {
		name     string
		a, b     int
		expected int
	}{
		{"first is larger", 5, 3, 5},
		{"second is larger", 2, 7, 7},
		{"equal values", 4, 4, 4},
		{"negative numbers", -5, -3, -3},
		{"zero and positive", 0, 5, 5},
		{"zero and negative", 0, -3, 0},
	}

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

t.Run 创建了一个子测试(subtest)。好处是:

  1. 每个子测试有独立的名字,输出更清晰
  2. 一个子测试失败不影响其他子测试
  3. 可以单独运行某个子测试

运行结果:

=== RUN   TestMax
=== RUN   TestMax/first_is_larger
=== RUN   TestMax/second_is_larger
=== RUN   TestMax/equal_values
=== RUN   TestMax/negative_numbers
=== RUN   TestMax/zero_and_positive
=== RUN   TestMax/zero_and_negative
--- PASS: TestMax (0.00s)
    --- PASS: TestMax/first_is_larger (0.00s)
    --- PASS: TestMax/second_is_larger (0.00s)
    ...

单独运行某个子测试:

go test -v -run "TestMax/first_is_larger"

测试辅助函数

当多个测试需要相同的设置逻辑时,可以提取成辅助函数:

func setupTestDB(t *testing.T) *DB {
	t.Helper()  // 标记为辅助函数,错误报告时会指向调用者
	
	db, err := NewDB(":memory:")
	if err != nil {
		t.Fatalf("failed to setup test db: %v", err)
	}
	
	t.Cleanup(func() {
		db.Close()  // 测试结束时清理
	})
	
	return db
}

func TestUserCreation(t *testing.T) {
	db := setupTestDB(t)
	
	user, err := db.CreateUser("张三")
	if err != nil {
		t.Fatalf("CreateUser failed: %v", err)
	}
	
	if user.Name != "张三" {
		t.Errorf("user.Name = %s; want 张三", user.Name)
	}
}

t.Helper() 的作用是让错误报告指向调用辅助函数的地方,而不是辅助函数内部。这在调试时非常有用。

测试并发代码

测试并发代码比较棘手,但 Go 提供了一些工具来帮助你。

func TestConcurrentAccess(t *testing.T) {
	counter := NewSafeCounter()
	
	var wg sync.WaitGroup
	for i := 0; i < 100; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			counter.Increment()
		}()
	}
	
	wg.Wait()
	
	if counter.Value() != 100 {
		t.Errorf("counter.Value() = %d; want 100", counter.Value())
	}
}

运行测试时加上 -race 可以检测数据竞态:

go test -race ./...

基准测试(Benchmark)

基准测试用来测量代码的性能。基准测试函数以 Benchmark 开头,参数是 *testing.B

func BenchmarkAdd(b *testing.B) {
	for i := 0; i < b.N; i++ {
		Add(100, 200)
	}
}

func BenchmarkMax(b *testing.B) {
	for i := 0; i < b.N; i++ {
		Max(100, 200)
	}
}

运行基准测试:

$ go test -bench=.
BenchmarkAdd-8   	1000000000	         0.2795 ns/op
BenchmarkMax-8   	1000000000	         0.2793 ns/op

-8 表示用了 8 个 CPU 核心。ns/op 是每次操作的纳秒数。

并行基准测试

func BenchmarkMaxParallel(b *testing.B) {
	b.RunParallel(func(pb *testing.PB) {
		for pb.Next() {
			Max(100, 200)
		}
	})
}

内存分配统计

$ go test -bench=. -benchmem
BenchmarkAdd-8   	1000000000	         0.2795 ns/op	       0 B/op	       0 allocs/op

B/op 是每次操作分配的字节数,allocs/op 是分配次数。这对于优化性能非常有用。

示例测试(Example Tests)

示例测试既是文档,也是测试。函数以 Example 开头:

func ExampleMax() {
	fmt.Println(Max(5, 3))
	fmt.Println(Max(2, 7))
	// Output:
	// 5
	// 7
}

// Output: 注释告诉测试框架预期的输出是什么。如果实际输出不匹配,测试会失败。

示例测试会出现在 go doc 生成的文档中,既是测试又是示例代码。

Mock 和依赖注入

在单元测试中,我们经常需要"假装"某些依赖存在。这就是 Mock 的作用。

通过接口注入依赖

// 定义接口
type UserStore interface {
	GetUser(id int) (*User, error)
}

// 服务依赖接口
type UserService struct {
	store UserStore
}

func NewUserService(store UserStore) *UserService {
	return &UserService{store: store}
}

func (s *UserService) GetUserAge(id int) (int, error) {
	user, err := s.store.GetUser(id)
	if err != nil {
		return 0, err
	}
	return user.Age, nil
}

手动创建 Mock

// Mock 实现
type MockUserStore struct {
	users map[int]*User
}

func (m *MockUserStore) GetUser(id int) (*User, error) {
	user, ok := m.users[id]
	if !ok {
		return nil, errors.New("user not found")
	}
	return user, nil
}

func TestGetUserAge(t *testing.T) {
	store := &MockUserStore{
		users: map[int]*User{
			1: {ID: 1, Name: "张三", Age: 25},
		},
	}

	service := NewUserService(store)

	age, err := service.GetUserAge(1)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if age != 25 {
		t.Errorf("age = %d; want 25", age)
	}

	// 测试不存在的用户
	_, err = service.GetUserAge(999)
	if err == nil {
		t.Error("expected error for non-existent user")
	}
}

测试覆盖率

Go 内置了测试覆盖率工具:

# 查看覆盖率
go test -cover

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

浏览器会打开一个漂亮的 HTML 报告,显示哪些代码被测试覆盖了,哪些没有。

💡 小贴士:覆盖率是一个有用的指标,但不要盲目追求 100%。有些代码(比如错误处理的边缘情况)很难测试,也不值得测试。关注核心逻辑的覆盖更重要。

测试 HTTP Handler

Go 提供了 httptest 包来方便地测试 HTTP handler:

func TestUsersHandler(t *testing.T) {
	req, err := http.NewRequest("GET", "/users", nil)
	if err != nil {
		t.Fatal(err)
	}

	// 创建一个 ResponseRecorder 来记录响应
	rr := httptest.NewRecorder()
	handler := http.HandlerFunc(usersHandler)

	// 调用 handler
	handler.ServeHTTP(rr, req)

	// 检查状态码
	if status := rr.Code; status != http.StatusOK {
		t.Errorf("status = %d; want %d", status, http.StatusOK)
	}

	// 检查响应体
	var users []User
	if err := json.Unmarshal(rr.Body.Bytes(), &users); err != nil {
		t.Fatalf("failed to parse response: %v", err)
	}

	if len(users) == 0 {
		t.Error("expected non-empty users list")
	}
}

集成测试

对于需要数据库、外部服务等资源的测试,可以用构建标签隔离:

//go:build integration
// +build integration

package mypackage

import "testing"

func TestWithRealDB(t *testing.T) {
	// 连接真实数据库的测试...
}

普通 go test 不会运行它。运行集成测试:

go test -tags=integration

测试最佳实践

1. 一个测试只验证一件事

// ❌ 不好:测试做了太多事
func TestUser(t *testing.T) {
	// 创建用户、修改用户、删除用户、登录...
}

// ✅ 好:拆分成多个小测试
func TestCreateUser(t *testing.T) { ... }
func TestUpdateUser(t *testing.T) { ... }
func TestDeleteUser(t *testing.T) { ... }

2. 测试命名要清晰

// ❌ 不好:看不出测试什么
func Test1(t *testing.T) { ... }

// ✅ 好:清晰的意图
func TestCreateUser_WithValidInput_ReturnsUser(t *testing.T) { ... }
func TestCreateUser_WithEmptyName_ReturnsError(t *testing.T) { ... }

3. 遵循 AAA 模式

func TestMax(t *testing.T) {
	// Arrange(准备)
	a, b := 5, 3

	// Act(执行)
	result := Max(a, b)

	// Assert(断言)
	if result != 5 {
		t.Errorf("Max(%d, %d) = %d; want 5", a, b, result)
	}
}

4. 测试错误情况

不要只测试成功路径,也要测试失败路径:

func TestDivide(t *testing.T) {
	// 成功情况
	result, err := Divide(10, 2)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if result != 5 {
		t.Errorf("Divide(10, 2) = %d; want 5", result)
	}

	// 失败情况:除以 0
	_, err = Divide(10, 0)
	if err == nil {
		t.Error("expected error when dividing by zero")
	}
}

实战:测试一个完整的包

让我们为一个字符串工具包写完整的测试:

// 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.ToLower(s)
	return s == Reverse(s)
}

// 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)
}
// stringutil/stringutil_test.go
package stringutil

import "testing"

func TestReverse(t *testing.T) {
	tests := []struct {
		name     string
		input    string
		expected string
	}{
		{"english word", "hello", "olleh"},
		{"chinese", "你好", "好你"},
		{"empty", "", ""},
		{"single char", "a", "a"},
		{"palindrome", "racecar", "racecar"},
	}

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

func TestIsPalindrome(t *testing.T) {
	tests := []struct {
		input    string
		expected bool
	}{
		{"racecar", true},
		{"hello", false},
		{"Racecar", true},  // 不区分大小写
		{"", true},
	}

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

func BenchmarkReverse(b *testing.B) {
	for i := 0; i < b.N; i++ {
		Reverse("Hello, World!")
	}
}

func ExampleReverse() {
	fmt.Println(Reverse("Hello"))
	// Output: olleH
}

小结

今天我们全面学习了 Go 的测试技术:

  1. 基础测试TestXxx 函数、t.Errorft.Fatalf
  2. 表驱动测试:用结构体切片组织测试用例
  3. 子测试t.Run 创建命名子测试
  4. 辅助函数t.Helper()t.Cleanup()
  5. 基准测试BenchmarkXxx-bench-benchmem
  6. 示例测试ExampleXxx,既是文档又是测试
  7. 测试覆盖率-cover-coverprofile
  8. HTTP 测试httptest
  9. 最佳实践:AAA 模式、命名规范、错误测试

测试是软件工程的重要组成部分。Go 的测试工具简洁强大,让你能轻松写出可靠的代码。

练习时间

  1. TDD 练习:先写测试,再实现一个 Stack 数据结构
  2. 基准测试:对比 strings.Join+ 拼接字符串的性能
  3. 测试 HTTP API:为你之前写的 HTTP 服务器写完整的测试
  4. Mock 练习:用接口和 mock 测试一个依赖外部服务的函数
  5. 覆盖率:为一个包写测试,目标是达到 80% 以上的覆盖率

下一篇预告

下一篇文章,我们将学习 Go Modules——Go 现代的依赖管理方案。这是构建真实 Go 项目必备的技能。我们会学习:

  • 模块初始化和版本管理
  • 依赖添加和升级
  • 私有仓库支持
  • 常见命令和最佳实践

我们下篇见!👋


参考资料:

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页