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.Errorf 和 t.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)。好处是:
- 每个子测试有独立的名字,输出更清晰
- 一个子测试失败不影响其他子测试
- 可以单独运行某个子测试
运行结果:
=== 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 的测试技术:
- 基础测试:
TestXxx函数、t.Errorf、t.Fatalf - 表驱动测试:用结构体切片组织测试用例
- 子测试:
t.Run创建命名子测试 - 辅助函数:
t.Helper()、t.Cleanup() - 基准测试:
BenchmarkXxx、-bench、-benchmem - 示例测试:
ExampleXxx,既是文档又是测试 - 测试覆盖率:
-cover、-coverprofile - HTTP 测试:
httptest包 - 最佳实践:AAA 模式、命名规范、错误测试
测试是软件工程的重要组成部分。Go 的测试工具简洁强大,让你能轻松写出可靠的代码。
练习时间
- TDD 练习:先写测试,再实现一个
Stack数据结构 - 基准测试:对比
strings.Join和+拼接字符串的性能 - 测试 HTTP API:为你之前写的 HTTP 服务器写完整的测试
- Mock 练习:用接口和 mock 测试一个依赖外部服务的函数
- 覆盖率:为一个包写测试,目标是达到 80% 以上的覆盖率
下一篇预告
下一篇文章,我们将学习 Go Modules——Go 现代的依赖管理方案。这是构建真实 Go 项目必备的技能。我们会学习:
- 模块初始化和版本管理
- 依赖添加和升级
- 私有仓库支持
- 常见命令和最佳实践
我们下篇见!👋
参考资料:
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。