Go 测试进阶:从单元测试到基准测试
“我的代码能跑就行,测试是浪费时间。”
这句话我听过无数次。每次听到,我都想把这个人的手机拿过来,翻翻他的 Git 提交历史——我敢打赌,里面一定有不少 fix bug、fix again、really fix this time 这样的提交信息。
不写测试的代码,就像没有安全带的赛车——跑得快的前提是你不出事。一旦出事,就是车毁人亡。
Go 语言把测试放在了极其重要的位置。testing 是标准库的一部分,go test 是工具链内置的命令,连测试覆盖率工具都是现成的。你不需要装任何第三方框架就能写出一流的测试。
在第 18 篇文章里,我们学习了测试的基础。今天,我们要进入"进阶区"——表驱动测试、子测试、测试辅助、Mock 模式、集成测试、基准测试、模糊测试、覆盖率分析、CI/CD 集成……把 Go 的测试武器库全部装满。
表驱动测试:Go 社区的黄金模式
如果只学一种测试模式,那就学表驱动测试。这是 Go 社区最推崇的测试模式,几乎所有标准库的测试都在用它。
核心思想很简单:把多个测试用例组织成一张"表",用循环遍历执行。
基础表驱动
来看一个例子。假设你写了一个计算器:
package calc
import "errors"
func Add(a, b int) int { return a + b }
func Subtract(a, b int) int { return a - b }
func Multiply(a, b int) int { return a * b }
func Divide(a, b float64) (float64, error) {
if b == 0 {
return 0, errors.New("division by zero")
}
return a / b, nil
}
新手可能会这样写测试:
// ❌ 冗长、重复、难维护
func TestAdd1(t *testing.T) {
if Add(1, 2) != 3 {
t.Error("1+2 should be 3")
}
}
func TestAdd2(t *testing.T) {
if Add(-1, 1) != 0 {
t.Error("-1+1 should be 0")
}
}
func TestAdd3(t *testing.T) {
if Add(0, 0) != 0 {
t.Error("0+0 should be 0")
}
}
// ... 还有几十个类似的函数
表驱动测试这样写:
// ✅ 简洁、清晰、易扩展
func TestAdd(t *testing.T) {
tests := []struct {
name string
a, b int
expected int
}{
{"两个正数", 1, 2, 3},
{"一正一负", -1, 1, 0},
{"两个零", 0, 0, 0},
{"两个负数", -3, -5, -8},
{"大数相加", 999999, 1, 1000000},
}
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)
}
})
}
}
子测试:t.Run 的威力
t.Run 创建的每个子测试都是独立的。好处非常明显:
# 只运行某个子测试
$ go test -v -run "TestAdd/两个正数"
=== RUN TestAdd/两个正数
--- PASS: TestAdd/两个正数 (0.00s)
PASS
# 查看所有子测试
$ go test -v -run "TestAdd"
=== RUN TestAdd
=== RUN TestAdd/两个正数
=== RUN TestAdd/一正一负
=== RUN TestAdd/两个零
=== RUN TestAdd/两个负数
=== RUN TestAdd/大数相加
--- PASS: TestAdd (0.00s)
--- PASS: TestAdd/两个正数 (0.00s)
--- PASS: TestAdd/一正一负 (0.00s)
...
一个子测试失败,不影响其他子测试继续执行。这在调试时非常有用——你可以一次看到所有失败的情况,而不是修一个跑一个。
测试带错误的函数
表驱动同样适合测试错误路径:
func TestDivide(t *testing.T) {
tests := []struct {
name string
a, b float64
expected float64
expectErr bool
}{
{"正常除法", 10, 2, 5.0, false},
{"除以小数", 10, 0.5, 20.0, false},
{"负数除法", -10, 2, -5.0, false},
{"除以零", 10, 0, 0, true},
{"零除以数", 0, 5, 0.0, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := Divide(tt.a, tt.b)
if tt.expectErr {
if err == nil {
t.Errorf("Divide(%v, %v) 期望错误但没有", tt.a, tt.b)
}
return
}
if err != nil {
t.Fatalf("Divide(%v, %v) 意外错误: %v", tt.a, tt.b, err)
}
if result != tt.expected {
t.Errorf("Divide(%v, %v) = %v; want %v",
tt.a, tt.b, result, tt.expected)
}
})
}
}
并行子测试
对于互相独立的测试用例,可以用 t.Parallel() 并行执行:
func TestAdd(t *testing.T) {
tests := []struct {
name string
a, b int
expected int
}{
// ... 大量测试用例
}
for _, tt := range tests {
tt := tt // Go 1.22 之前需要这一行
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)
}
})
}
}
⚠️ 注意 tt := tt 这一行。在 Go 1.22 之前,for range 的循环变量在每次迭代中是同一个变量,闭包捕获的是它的引用。不加这行,所有并行子测试可能用的是最后一次迭代的值。Go 1.22 修复了这个问题。
测试辅助函数:t.Helper() 的妙用
当多个测试有重复的设置逻辑时,提取辅助函数是自然的选择。但这里有个坑:
// ❌ 错误报告指向辅助函数内部,不好定位
func createUser(t *testing.T, name string) *User {
user, err := NewUser(name)
if err != nil {
t.Fatalf("创建用户失败: %v", err)
// 报错指向这一行,但你不知道是哪个测试调用的
}
return user
}
t.Helper() 解决了这个问题:
// ✅ 错误报告指向调用辅助函数的地方
func createUser(t *testing.T, name string) *User {
t.Helper() // 标记为辅助函数
user, err := NewUser(name)
if err != nil {
t.Fatalf("创建用户失败: %v", err)
// 现在报错指向调用 createUser 的那一行
}
return user
}
func TestUserProfile(t *testing.T) {
user := createUser(t, "张三") // 如果有错,报错指向这一行
// ...
}
func TestUserDeletion(t *testing.T) {
user := createUser(t, "李四") // 如果有错,报错指向这一行
// ...
}
t.Cleanup:自动资源清理
测试中创建的资源(数据库连接、临时文件等)需要在测试结束后清理。t.Cleanup 让这个变得优雅:
func setupTestDB(t *testing.T) *DB {
t.Helper()
db, err := NewDB(":memory:")
if err != nil {
t.Fatalf("创建测试数据库失败: %v", err)
}
// 注册清理函数,测试结束时自动调用
t.Cleanup(func() {
db.Close()
})
// 初始化测试数据
db.Exec("INSERT INTO users (name, age) VALUES ('Alice', 25)")
db.Exec("INSERT INTO users (name, age) VALUES ('Bob', 30)")
return db
}
func TestGetUser(t *testing.T) {
db := setupTestDB(t)
// 直接用 db,不用操心清理
user, err := db.GetUser("Alice")
if err != nil {
t.Fatal(err)
}
if user.Age != 25 {
t.Errorf("age = %d; want 25", user.Age)
}
}
t.Cleanup 比 defer 更好用,因为它可以注册多个清理函数,而且在子测试中注册的清理函数只会在子测试结束时执行,不会影响父测试。
测试 Fixtures:组织测试数据
复杂的测试往往需要大量的测试数据。把这些数据直接写在测试函数里会让代码变得臃肿。
从文件加载 Fixture
package myapp
import (
"encoding/json"
"os"
"path/filepath"
"runtime"
"testing"
)
// loadFixture 从 testdata 目录加载测试数据
func loadFixture(t *testing.T, name string, v any) {
t.Helper()
// runtime.Caller 获取当前文件路径,从而定位 testdata 目录
_, filename, _, _ := runtime.Caller(0)
dir := filepath.Dir(filename)
path := filepath.Join(dir, "testdata", name)
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("加载 fixture %s 失败: %v", name, err)
}
if err := json.Unmarshal(data, v); err != nil {
t.Fatalf("解析 fixture %s 失败: %v", name, err)
}
}
func TestProcessOrder(t *testing.T) {
var order Order
loadFixture(t, "order_valid.json", &order)
result, err := ProcessOrder(order)
if err != nil {
t.Fatal(err)
}
if result.Status != "completed" {
t.Errorf("status = %s; want completed", result.Status)
}
}
testdata 目录有个特殊属性:Go 工具链会忽略它,不会编译里面的文件。这是专门用来放测试数据的约定目录。
myapp/
├── myapp.go
├── myapp_test.go
└── testdata/
├── order_valid.json
├── order_invalid.json
└── order_empty.json
黄金文件(Golden Files)测试
对于复杂的输出(比如 JSON 响应、HTML 页面),手写期望值太麻烦。黄金文件测试的思路是:第一次运行时生成"黄金文件",后续运行时与黄金文件比较。
package myapp
import (
"flag"
"os"
"path/filepath"
"runtime"
"testing"
)
// -update 标志:更新黄金文件
var update = flag.Bool("update", false, "更新黄金文件")
func getTestDataDir(t *testing.T) string {
_, filename, _, _ := runtime.Caller(0)
return filepath.Join(filepath.Dir(filename), "testdata")
}
func TestGenerateReport(t *testing.T) {
report := GenerateReport([]Record{
{Name: "Alice", Score: 95},
{Name: "Bob", Score: 87},
})
goldenFile := filepath.Join(getTestDataDir(t), "report.golden")
if *update {
// 更新黄金文件
os.WriteFile(goldenFile, []byte(report), 0644)
}
// 读取黄金文件
expected, err := os.ReadFile(goldenFile)
if err != nil {
t.Fatalf("读取黄金文件失败: %v", err)
}
if report != string(expected) {
t.Errorf("报告内容与黄金文件不匹配:\nGot:\n%s\n\nWant:\n%s",
report, string(expected))
}
}
使用方式:
# 第一次运行:生成黄金文件
go test -update
# 后续运行:与黄金文件比较
go test
Mock 模式:隔离依赖的艺术
单元测试的核心原则是隔离——你只测你写的代码,不测数据库、不测外部 API、不测文件系统。但现实中,代码总有依赖。怎么办?Mock 它。
基于接口的 Mock
Go 的接口是天然的 Mock 入口。只要依赖是通过接口注入的,就可以用假实现替换真实现。
package user
import "context"
// UserRepository 定义数据访问接口
type UserRepository interface {
FindByID(ctx context.Context, id int) (*User, error)
Save(ctx context.Context, user *User) error
Delete(ctx context.Context, id int) error
}
// EmailSender 定义邮件发送接口
type EmailSender interface {
Send(ctx context.Context, to string, subject, body string) error
}
// UserService 依赖接口而不是具体实现
type UserService struct {
repo UserRepository
email EmailSender
}
func NewUserService(repo UserRepository, email EmailSender) *UserService {
return &UserService{repo: repo, email: email}
}
func (s *UserService) Register(ctx context.Context, name, email string) (*User, error) {
user := &User{
Name: name,
Email: email,
}
if err := s.repo.Save(ctx, user); err != nil {
return nil, err
}
if err := s.email.Send(ctx, email, "欢迎", "欢迎加入!"); err != nil {
// 邮件发送失败不回滚用户(实际业务可能需要)
return user, nil
}
return user, nil
}
手动 Mock
package user_test
import (
"context"
"errors"
"testing"
)
// MockUserRepository 假的用户仓库
type MockUserRepository struct {
users map[int]*User
saveErr error
nextID int
}
func NewMockUserRepository() *MockUserRepository {
return &MockUserRepository{
users: make(map[int]*User),
nextID: 1,
}
}
func (m *MockUserRepository) FindByID(ctx context.Context, id int) (*User, error) {
user, ok := m.users[id]
if !ok {
return nil, errors.New("user not found")
}
return user, nil
}
func (m *MockUserRepository) Save(ctx context.Context, user *User) error {
if m.saveErr != nil {
return m.saveErr
}
user.ID = m.nextID
m.nextID++
m.users[user.ID] = user
return nil
}
func (m *MockUserRepository) Delete(ctx context.Context, id int) error {
delete(m.users, id)
return nil
}
// MockEmailSender 假的邮件发送器
type MockEmailSender struct {
sentEmails []SentEmail
sendErr error
}
type SentEmail struct {
To string
Subject string
Body string
}
func (m *MockEmailSender) Send(ctx context.Context, to string, subject, body string) error {
if m.sendErr != nil {
return m.sendErr
}
m.sentEmails = append(m.sentEmails, SentEmail{
To: to,
Subject: subject,
Body: body,
})
return nil
}
使用 Mock 写测试
func TestUserService_Register(t *testing.T) {
t.Run("正常注册", func(t *testing.T) {
repo := NewMockUserRepository()
email := &MockEmailSender{}
service := NewUserService(repo, email)
user, err := service.Register(context.Background(), "张三", "zhang@example.com")
if err != nil {
t.Fatalf("注册失败: %v", err)
}
// 验证用户被保存
if user.ID == 0 {
t.Error("用户 ID 未分配")
}
if user.Name != "张三" {
t.Errorf("name = %s; want 张三", user.Name)
}
// 验证邮件被发送
if len(email.sentEmails) != 1 {
t.Fatalf("期望发送 1 封邮件,实际发送了 %d 封", len(email.sentEmails))
}
if email.sentEmails[0].To != "zhang@example.com" {
t.Errorf("邮件接收者 = %s; want zhang@example.com", email.sentEmails[0].To)
}
})
t.Run("保存失败", func(t *testing.T) {
repo := NewMockUserRepository()
repo.saveErr = errors.New("数据库连接失败")
email := &MockEmailSender{}
service := NewUserService(repo, email)
_, err := service.Register(context.Background(), "张三", "zhang@example.com")
if err == nil {
t.Fatal("期望错误但没有")
}
// 验证邮件没有被发送
if len(email.sentEmails) != 0 {
t.Error("保存失败时不应该发送邮件")
}
})
t.Run("邮件发送失败", func(t *testing.T) {
repo := NewMockUserRepository()
email := &MockEmailSender{sendErr: errors.New("SMTP 错误")}
service := NewUserService(repo, email)
user, err := service.Register(context.Background(), "张三", "zhang@example.com")
if err != nil {
t.Fatalf("邮件失败不应该返回错误: %v", err)
}
// 但用户应该被保存了
saved, _ := repo.FindByID(context.Background(), user.ID)
if saved == nil {
t.Error("用户应该被保存")
}
})
}
使用 testify/mock 简化
如果你觉得手写 Mock 太麻烦,可以用 testify/mock 这样的库:
import "github.com/stretchr/testify/mock"
type MockRepo struct {
mock.Mock
}
func (m *MockRepo) FindByID(ctx context.Context, id int) (*User, error) {
args := m.Called(ctx, id)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*User), args.Error(1)
}
func TestGetUser(t *testing.T) {
repo := new(MockRepo)
// 设置期望
expectedUser := &User{ID: 1, Name: "Alice"}
repo.On("FindByID", mock.Anything, 1).Return(expectedUser, nil)
user, err := repo.FindByID(context.Background(), 1)
if err != nil {
t.Fatal(err)
}
if user.Name != "Alice" {
t.Errorf("name = %s; want Alice", user.Name)
}
// 验证期望被满足
repo.AssertExpectations(t)
}
集成测试:用真实环境验证
单元测试用 Mock 隔离依赖,集成测试则用真实依赖验证整个流程。两者互补,不可偏废。
用构建标签隔离集成测试
集成测试通常比较慢,不应该和单元测试混在一起跑。用构建标签隔离:
//go:build integration
package myapp
import (
"database/sql"
"os"
"testing"
_ "github.com/lib/pq"
)
func getTestDB(t *testing.T) *sql.DB {
t.Helper()
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
t.Skip("TEST_DATABASE_URL 未设置,跳过集成测试")
}
db, err := sql.Open("postgres", dsn)
if err != nil {
t.Fatalf("连接数据库失败: %v", err)
}
t.Cleanup(func() {
db.Close()
})
return db
}
func TestUserFlow_Integration(t *testing.T) {
db := getTestDB(t)
// 清理测试数据
t.Cleanup(func() {
db.Exec("DELETE FROM users WHERE name = 'test_user'")
})
repo := NewPostgresUserRepo(db)
service := NewUserService(repo, NewRealEmailSender())
// 测试完整流程
user, err := service.Register(context.Background(), "test_user", "test@example.com")
if err != nil {
t.Fatalf("注册失败: %v", err)
}
// 从数据库验证
saved, err := repo.FindByID(context.Background(), user.ID)
if err != nil {
t.Fatalf("从数据库查询失败: %v", err)
}
if saved.Name != "test_user" {
t.Errorf("数据库中的 name = %s; want test_user", saved.Name)
}
}
运行方式:
# 只运行单元测试(默认)
go test ./...
# 运行集成测试
TEST_DATABASE_URL="postgres://localhost/testdb" go test -tags=integration ./...
# 运行所有测试
TEST_DATABASE_URL="postgres://localhost/testdb" go test -tags=integration ./...
使用 TestMain 做全局设置
TestMain 在所有测试之前运行一次,适合做全局的初始化和清理:
package myapp
import (
"fmt"
"os"
"testing"
)
func TestMain(m *testing.M) {
// 全局设置(只运行一次)
fmt.Println("正在启动测试环境...")
// 例如:启动 Docker 容器中的测试数据库
if err := startTestDatabase(); err != nil {
fmt.Fprintf(os.Stderr, "启动测试数据库失败: %v\n", err)
os.Exit(1)
}
// 运行所有测试
code := m.Run()
// 全局清理
fmt.Println("正在清理测试环境...")
stopTestDatabase()
os.Exit(code)
}
基准测试:性能的显微镜
基准测试回答一个关键问题:这段代码有多快?
基础基准测试
package stringutil
import (
"strings"
"testing"
)
// ConcatWithPlus 用 + 拼接字符串
func ConcatWithPlus(parts []string) string {
result := ""
for _, p := range parts {
result += p
}
return result
}
// ConcatWithBuilder 用 strings.Builder 拼接
func ConcatWithBuilder(parts []string) string {
var b strings.Builder
for _, p := range parts {
b.WriteString(p)
}
return b.String()
}
// ConcatWithJoin 用 strings.Join 拼接
func ConcatWithJoin(parts []string) string {
return strings.Join(parts, "")
}
func BenchmarkConcatWithPlus(b *testing.B) {
parts := make([]string, 100)
for i := range parts {
parts[i] = "hello"
}
b.ResetTimer() // 重置计时器,排除准备数据的开销
for i := 0; i < b.N; i++ {
ConcatWithPlus(parts)
}
}
func BenchmarkConcatWithBuilder(b *testing.B) {
parts := make([]string, 100)
for i := range parts {
parts[i] = "hello"
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
ConcatWithBuilder(parts)
}
}
func BenchmarkConcatWithJoin(b *testing.B) {
parts := make([]string, 100)
for i := range parts {
parts[i] = "hello"
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
ConcatWithJoin(parts)
}
}
运行基准测试:
$ go test -bench=. -benchmem
BenchmarkConcatWithPlus-8 50000 33218 ns/op 82432 B/op 99 allocs/op
BenchmarkConcatWithBuilder-8 500000 2421 ns/op 512 B/op 1 allocs/op
BenchmarkConcatWithJoin-8 1000000 1103 ns/op 512 B/op 1 allocs/op
结论一目了然:strings.Join 最快,strings.Builder 次之,+ 拼接慢 30 倍,而且内存分配次数是其他两种的 99 倍。
子基准测试
和子测试类似,基准测试也支持子基准:
func BenchmarkConcat(b *testing.B) {
sizes := []int{10, 100, 1000}
for _, size := range sizes {
b.Run(fmt.Sprintf("size=%d", size), func(b *testing.B) {
parts := make([]string, size)
for i := range parts {
parts[i] = "hello"
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
ConcatWithJoin(parts)
}
})
}
}
$ go test -bench=BenchmarkConcat -benchmem
BenchmarkConcat/size=10-8 5000000 248 ns/op 64 B/op 1 allocs/op
BenchmarkConcat/size=100-8 1000000 1103 ns/op 512 B/op 1 allocs/op
BenchmarkConcat/size=1000-8 200000 9234 ns/op 5120 B/op 1 allocs/op
并行基准测试
func BenchmarkConcurrentMap(b *testing.B) {
m := NewConcurrentMap()
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
m.Set(fmt.Sprintf("key-%d", i), i)
i++
}
})
}
b.RunParallel 会在多个 goroutine 中运行基准测试,测试并发性能。
基准测试最佳实践
func BenchmarkExpensiveOperation(b *testing.B) {
// 1. 准备工作放在 b.ResetTimer() 之前
data := generateLargeDataset()
b.ResetTimer()
for i := 0; i < b.N; i++ {
// 2. 循环体内只放被测代码
ProcessData(data)
}
// 3. 清理工作用 b.Cleanup
b.Cleanup(func() {
cleanUp(data)
})
}
func BenchmarkWithAllocations(b *testing.B) {
b.ReportAllocs() // 强制报告内存分配(即使没用 -benchmem)
for i := 0; i < b.N; i++ {
_ = make([]int, 1000)
}
}
模糊测试(Fuzz Testing)回顾
在第 63 篇文章中我们详细讲了模糊测试,这里快速回顾一下核心用法:
package myapp
import (
"strings"
"testing"
)
// 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)
}
// FuzzReverse 模糊测试
func FuzzReverse(f *testing.F) {
// 种子语料库
f.Add("Hello, World!")
f.Add("你好世界")
f.Add("")
f.Add("a")
f.Fuzz(func(t *testing.T, input string) {
reversed := Reverse(input)
doubleReversed := Reverse(reversed)
// 性质 1:反转两次应该回到原值
if doubleReversed != input {
t.Errorf("Reverse(Reverse(%q)) = %q", input, doubleReversed)
}
// 性质 2:长度应该不变
if len([]rune(reversed)) != len([]rune(input)) {
t.Errorf("len mismatch: %d vs %d",
len([]rune(reversed)), len([]rune(input)))
}
})
}
运行模糊测试:
# 默认模式:只运行种子语料
go test -run FuzzReverse
# 模糊模式:自动生成随机输入
go test -fuzz=FuzzReverse -fuzztime=30s
模糊测试能发现你做梦都想不到的边界情况。强烈建议对每个解析器、编解码器都写一个模糊测试。
测试覆盖率分析
覆盖率不是目的,但它是发现"盲区"的有效工具。
基本覆盖率
# 查看覆盖率百分比
$ go test -cover
ok myapp 0.025s coverage: 78.3% of statements
# 生成详细报告
$ go test -coverprofile=coverage.out
# 在浏览器中查看(彩色高亮哪些代码被覆盖了)
$ go tool cover -html=coverage.out
浏览器会打开一个漂亮的页面,绿色表示已覆盖,红色表示未覆盖。
函数级覆盖率
$ go tool cover -func=coverage.out
myapp/user.go:15: NewUser 100.0%
myapp/user.go:25: Validate 85.7%
myapp/user.go:50: Save 100.0%
myapp/user.go:70: Delete 66.7%
myapp/email.go:10: SendEmail 0.0%
total: (statements) 78.3%
一眼就能看到哪些函数没有被测试覆盖。
在 CI 中强制覆盖率
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.23'
- name: Run tests with coverage
run: |
go test -coverprofile=coverage.out -covermode=atomic ./...
- name: Check coverage threshold
run: |
coverage=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | sed 's/%//')
echo "Coverage: ${coverage}%"
if (( $(echo "$coverage < 80" | bc -l) )); then
echo "覆盖率低于 80%!"
exit 1
fi
- name: Upload coverage
uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage.out
CI/CD 集成:完整的测试流水线
一个完整的 Go 项目 CI/CD 流水线应该包含这些步骤:
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.23'
- name: golangci-lint
uses: golangci/golangci-lint-action@v6
with:
version: latest
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: test
POSTGRES_DB: testdb
ports:
- 5432:5432
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.23'
- name: Unit tests
run: go test -race -coverprofile=coverage.out ./...
- name: Integration tests
env:
TEST_DATABASE_URL: postgres://postgres:test@localhost:5432/testdb?sslmode=disable
run: go test -tags=integration -race ./...
- name: Benchmarks
run: go test -bench=. -benchmem -run=^$ ./...
build:
needs: [lint, test]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.23'
- name: Build
run: go build -o myapp ./cmd/myapp
测试脚本:一键跑全部
#!/bin/bash
# scripts/test.sh
set -e
echo "🔍 Running linter..."
golangci-lint run ./...
echo "🧪 Running unit tests..."
go test -race -coverprofile=coverage.out ./...
echo "📊 Coverage report:"
go tool cover -func=coverage.out | grep total
echo "🏗️ Running integration tests..."
TEST_DATABASE_URL="postgres://localhost/testdb" \
go test -tags=integration -race ./...
echo "⚡ Running benchmarks..."
go test -bench=. -benchmem -run=^$ -benchtime=1s ./...
echo "✅ All tests passed!"
测试组织:大型项目的最佳实践
目录结构
myapp/
├── cmd/
│ └── myapp/
│ └── main.go
├── internal/
│ ├── user/
│ │ ├── user.go
│ │ ├── user_test.go # 单元测试
│ │ ├── repository.go
│ │ ├── repository_test.go
│ │ ├── service.go
│ │ └── service_test.go
│ └── order/
│ ├── order.go
│ └── order_test.go
├── integration/
│ └── user_flow_test.go # 集成测试
├── testdata/
│ └── fixtures/
└── scripts/
└── test.sh
测试命名约定
// 函数名_场景_期望结果
func TestUserService_Register_WithValidInput_CreatesUser(t *testing.T) {}
func TestUserService_Register_WithEmptyName_ReturnsError(t *testing.T) {}
func TestUserService_Register_WhenDBFails_ReturnsError(t *testing.T) {}
// 或者用子测试:
func TestUserService_Register(t *testing.T) {
t.Run("valid input creates user", func(t *testing.T) { ... })
t.Run("empty name returns error", func(t *testing.T) { ... })
t.Run("db failure returns error", func(t *testing.T) { ... })
}
测试分层策略
┌─────────────────────────────┐
│ E2E 测试(少量,慢) │ 测试完整用户流程
├─────────────────────────────┤
│ 集成测试(适量,中速) │ 测试组件间的交互
├─────────────────────────────┤
│ 单元测试(大量,快) │ 测试单个函数/方法
└─────────────────────────────┘
测试金字塔:
- 底层大量单元测试:快速、隔离、易维护
- 中层适量集成测试:验证组件间的协作
- 顶层少量 E2E 测试:验证关键用户流程
测试中的常见反模式
1. 测试依赖执行顺序
// ❌ 错误:测试之间有依赖
var userID int
func TestCreateUser(t *testing.T) {
user := createUser("Alice")
userID = user.ID // 保存到包级变量
}
func TestGetUser(t *testing.T) {
user := getUser(userID) // 依赖上一个测试的结果
}
每个测试应该独立,可以单独运行,也可以乱序运行。
2. 断言太少
// ❌ 错误:只检查了不 panic,没检查结果
func TestCreateUser(t *testing.T) {
user, _ := CreateUser("Alice")
_ = user // 什么都没检查
}
// ✅ 正确:检查所有重要字段
func TestCreateUser(t *testing.T) {
user, err := CreateUser("Alice")
if err != nil {
t.Fatal(err)
}
if user.Name != "Alice" {
t.Errorf("name = %s; want Alice", user.Name)
}
if user.ID == 0 {
t.Error("ID should be assigned")
}
}
3. 忽略错误
// ❌ 错误:用 _ 忽略了错误
result, _ := doSomething()
// ✅ 正确:总是检查错误
result, err := doSomething()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
小结
今天我们系统学习了 Go 测试的进阶技术:
测试模式:
- 表驱动测试:Go 社区的黄金模式
- 子测试:
t.Run让测试更清晰 - 测试辅助:
t.Helper()+t.Cleanup() - 测试 Fixtures:
testdata目录和黄金文件
隔离与集成:
5. Mock 模式:基于接口的依赖隔离
6. 集成测试:构建标签隔离 + TestMain 全局设置
性能与质量:
7. 基准测试:BenchmarkXxx + b.RunParallel
8. 模糊测试:自动生成随机输入发现隐藏 Bug
9. 覆盖率分析:发现测试盲区
工程化:
10. CI/CD 集成:完整的测试流水线
11. 测试组织:目录结构和命名约定
12. 反模式:避免常见的测试陷阱
测试的核心原则:
- 测试是投资,不是成本。现在花时间写测试,将来少花时间 debug
- 每个测试只验证一件事
- 测试应该快速、独立、可重复
- 不要只测成功路径,错误路径同样重要
- 基准测试回答"有多快",模糊测试回答"有多健壮"
Go 的测试工具简洁而强大,不需要任何第三方框架就能构建完整的测试体系。把这篇文章的知识用起来,你的代码质量会有质的飞跃。
练习时间
- 表驱动改造:把你现有的一个测试文件改造成表驱动模式
- Mock 练习:为一个依赖外部 API 的函数写 Mock 测试
- 基准测试:对比
sync.Mutex和sync.RWMutex在不同读写比例下的性能 - 模糊测试:为你的 JSON 解析器写一个模糊测试
- CI 流水线:为你的项目配置完整的 GitHub Actions 测试流水线
参考资料:
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。