测试体系
测试类型总览
| 类型 | 命令 | 文件命名 | 函数签名 | 目的 |
|---|
| 单元测试 | go test | xxx_test.go | func TestXxx(t *testing.T) | 验证函数/方法正确性 |
| 基准测试 | go test -bench | xxx_test.go | func BenchmarkXxx(b *testing.B) | 测量性能 |
| 模糊测试 | go test -fuzz | xxx_test.go | func FuzzXxx(f *testing.F) | 自动发现边界 case |
| 示例测试 | go test | xxx_test.go | func ExampleXxx() | 文档 + 可执行示例 |
| 集成测试 | go test -tags=integration | xxx_test.go | func TestIntegration(t *testing.T) | 跨组件验证 |
单元测试
基本结构
// user_service_test.go
package service
import (
"context"
"testing"
)
func TestGetUser(t *testing.T) {
// 准备
repo := &MockUserRepo{
users: map[string]*entity.User{
"1": {ID: "1", Name: "小徐"},
},
}
svc := NewUserService(repo)
// 执行
user, err := svc.GetByID(context.Background(), "1")
// 断言
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if user.Name != "小徐" {
t.Errorf("expected name=小徐, got %s", user.Name)
}
}
表驱动测试(推荐模式)
func TestCreateUser(t *testing.T) {
tests := []struct {
name string
input *dto.CreateUserReq
wantErr bool
errMsg string
}{
{
name: "正常创建",
input: &dto.CreateUserReq{Name: "小徐", Age: 28},
wantErr: false,
},
{
name: "名字为空",
input: &dto.CreateUserReq{Name: "", Age: 28},
wantErr: true,
errMsg: "name is required",
},
{
name: "年龄为负",
input: &dto.CreateUserReq{Name: "小徐", Age: -1},
wantErr: true,
errMsg: "age must be positive",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc := NewUserService(&MockUserRepo{})
_, err := svc.Create(context.Background(), tt.input)
if tt.wantErr {
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), tt.errMsg) {
t.Errorf("error message = %q, want contain %q", err.Error(), tt.errMsg)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
}
}
t.Run 子测试
func TestUserValidation(t *testing.T) {
t.Run("valid_user", func(t *testing.T) {
// 子测试可以并行
t.Parallel()
// ...
})
t.Run("invalid_email", func(t *testing.T) {
t.Parallel()
// ...
})
t.Run("empty_name", func(t *testing.T) {
t.Parallel()
// ...
})
}
# 运行特定子测试
go test -run TestUserValidation/valid_user ./...
go test -run TestUserValidation/invalid_email ./...
测试辅助函数
// t.Helper() 标记辅助函数,失败时报告调用者位置
func assertEqual(t *testing.T, got, want interface{}) {
t.Helper()
if !reflect.DeepEqual(got, want) {
t.Errorf("got %v, want %v", got, want)
}
}
// 用法
func TestSomething(t *testing.T) {
assertEqual(t, result, expected)
}
setup / teardown
func TestMain(m *testing.M) {
// 全局 setup
setupTestDB()
setupTestRedis()
// 运行所有测试
code := m.Run()
// 全局 teardown
teardownTestDB()
teardownTestRedis()
os.Exit(code)
}
Mock 与依赖注入
使用接口 + 手写 Mock
// 定义接口
type UserRepository interface {
FindByID(ctx context.Context, id string) (*entity.User, error)
Create(ctx context.Context, user *entity.User) error
}
// 手写 Mock
type MockUserRepository struct {
users map[string]*entity.User
saved []*entity.User
err error
}
func (m *MockUserRepository) FindByID(ctx context.Context, id string) (*entity.User, error) {
if m.err != nil {
return nil, m.err
}
return m.users[id], nil
}
func (m *MockUserRepository) Create(ctx context.Context, user *entity.User) error {
m.saved = append(m.saved, user)
return nil
}
使用 mockgen 生成 Mock
//go:generate mockgen -source=user_repository.go -destination=mock_user_repository.go -package=mock
// 使用 testify/mock
type MockUserRepository struct {
mock.Mock
}
func (m *MockUserRepository) FindByID(ctx context.Context, id string) (*entity.User, error) {
args := m.Called(ctx, id)
return args.Get(0).(*entity.User), args.Error(1)
}
// 测试中使用
func TestGetUser(t *testing.T) {
mockRepo := new(MockUserRepository)
mockRepo.On("FindByID", mock.Anything, "1").Return(&entity.User{ID: "1", Name: "小徐"}, nil)
svc := NewUserService(mockRepo)
user, err := svc.GetByID(context.Background(), "1")
assert.NoError(t, err)
assert.Equal(t, "小徐", user.Name)
mockRepo.AssertExpectations(t)
}
httptest(HTTP 测试)
func TestUserHandler(t *testing.T) {
// 准备 handler
handler := NewUserHandler(mockSvc)
// 创建测试服务器
ts := httptest.NewServer(handler)
defer ts.Close()
// 发送请求
resp, err := http.Get(ts.URL + "/api/v1/users/1")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
// 断言
if resp.StatusCode != http.StatusOK {
t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusOK)
}
var user dto.UserDTO
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
t.Fatal(err)
}
if user.Name != "小徐" {
t.Errorf("name = %s, want 小徐", user.Name)
}
}
测试 HTTP Handler(不启动服务器)
func TestCreateUserHandler(t *testing.T) {
body := strings.NewReader(`{"name":"小徐","age":28}`)
req := httptest.NewRequest("POST", "/api/v1/users", body)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.CreateUser(w, req)
resp := w.Result()
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusCreated)
}
}
基准测试
func BenchmarkStringConcat(b *testing.B) {
parts := []string{"Hello", " ", "World", "!"}
b.ResetTimer() // 排除准备阶段
for i := 0; i < b.N; i++ {
_ = strings.Join(parts, "")
}
}
// 对比不同实现
func BenchmarkStringBuilder(b *testing.B) {
parts := []string{"Hello", " ", "World", "!"}
b.ResetTimer()
for i := 0; i < b.N; i++ {
var sb strings.Builder
for _, p := range parts {
sb.WriteString(p)
}
_ = sb.String()
}
}
$ go test -bench=. -benchmem ./...
# 输出示例:
# BenchmarkStringConcat-8 30000000 45.2 ns/op 32 B/op 1 allocs/op
# BenchmarkStringBuilder-8 50000000 28.1 ns/op 24 B/op 1 allocs/op
# 列含义:
# -8 GOMAXPROCS
# 30000000 执行次数
# 45.2 ns/op 每次操作耗时
# 32 B/op 每次操作内存分配
# 1 allocs/op 每次操作分配次数
子基准测试对比
func BenchmarkSort(b *testing.B) {
sizes := []int{100, 1000, 10000}
for _, size := range sizes {
b.Run(fmt.Sprintf("size_%d", size), func(b *testing.B) {
data := generateRandomSlice(size)
b.ResetTimer()
for i := 0; i < b.N; i++ {
sorted := make([]int, len(data))
copy(sorted, data)
sort.Ints(sorted)
}
})
}
}
模糊测试(Go 1.18+)
func FuzzParseURL(f *testing.F) {
// 种子语料
f.Add("http://example.com/path")
f.Add("https://example.com:8080/path?q=1")
f.Add("ftp://localhost")
f.Fuzz(func(t *testing.T, raw string) {
u, err := url.Parse(raw)
if err != nil {
return // 解析失败是正常的
}
// 往返测试:解析后再格式化,再解析,结果应一致
reformatted := u.String()
u2, err := url.Parse(reformatted)
if err != nil {
t.Fatalf("re-parse failed: %v", err)
}
if u.Host != u2.Host || u.Path != u2.Path {
t.Errorf("roundtrip mismatch: %q -> %q", u.String(), u2.String())
}
})
}
# 运行模糊测试
go test -fuzz=FuzzParseURL -fuzztime=30s ./internal/urlutil
# 模糊测试发现的 crash case 会保存到 testdata/fuzz/
# 后续 go test 会自动运行这些 case 作为回归测试
示例测试
func ExampleJoin() {
result := strings.Join([]string{"a", "b", "c"}, "-")
fmt.Println(result)
// Output: a-b-c
}
// 无 Output 注释的示例不会被测试执行,仅用于文档
func ExampleUsage() {
// 不会被 go test 验证
}
// Example 前缀的函数用于包级文档
func Example() {
fmt.Println("包的示例")
// Output: 包的示例
}
测试覆盖率
# 生成覆盖率报告
go test -coverprofile=coverage.out ./...
go tool cover -func=coverage.out
# 输出示例:
# github.com/care-mate/internal/service/user_service.go:18: GetByID 85.7%
# github.com/care-mate/internal/service/user_service.go:35: Create 100.0%
# github.com/care-mate/internal/service/user_service.go:52: Update 66.7%
# total: 82.3%
# HTML 可视化
go tool cover -html=coverage.out -o coverage.html
| 覆盖率目标 | 范围 | 说明 |
|---|
| 80%+ | 核心业务逻辑 | service/repository 层 |
| 60%+ | handler 层 | HTTP 处理器 |
| 90%+ | 工具函数 | pkg/ 下的纯函数 |
| 30%+ | 基础设施层 | DB/Redis 等外部依赖 |
第三方测试库对比
| 库 | 用途 | 示例 | 推荐度 |
|---|
testify | 断言 + Mock | assert.Equal(t, got, want) | ★★★★★ |
gomock | Mock 生成 | mockgen 生成接口 mock | ★★★★☆ |
testcontainers-go | 集成测试容器 | 启动真实 PostgreSQL/Redis | ★★★★☆ |
go-sqlmock | 数据库 Mock | Mock sql.DB 查询 | ★★★★☆ |
httpmock | HTTP Mock | Mock 外部 API 调用 | ★★★☆☆ |
faker | 测试数据生成 | 随机生成结构体数据 | ★★★☆☆ |
ginkgo | BDD 测试框架 | Describe("...", func(){...}) | ★★☆☆☆(Go 社区更偏好标准库风格) |