6.6 单元测试进阶
Mock 基础
testify/mock 使用
go
package main
import (
"errors"
"testing"
"github.com/stretchr/testify/mock"
)
// 定义接口
type UserRepository interface {
GetByID(id string) (*User, error)
Save(user *User) error
Delete(id string) error
}
type User struct {
ID string
Name string
Email string
}
// Mock 实现
type MockUserRepository struct {
mock.Mock
}
func (m *MockUserRepository) GetByID(id string) (*User, error) {
args := m.Called(id)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*User), args.Error(1)
}
func (m *MockUserRepository) Save(user *User) error {
args := m.Called(user)
return args.Error(0)
}
func (m *MockUserRepository) Delete(id string) error {
args := m.Called(id)
return args.Error(0)
}
// 业务逻辑
type UserService struct {
repo UserRepository
}
func (s *UserService) GetUser(id string) (*User, error) {
return s.repo.GetByID(id)
}
func (s *UserService) CreateUser(name, email string) (*User, error) {
user := &User{
ID: "123",
Name: name,
Email: email,
}
if err := s.repo.Save(user); err != nil {
return nil, err
}
return user, nil
}
// 测试
func TestUserService_GetUser(t *testing.T) {
// 创建 mock
mockRepo := new(MockUserRepository)
// 设置期望
mockRepo.On("GetByID", "123").Return(&User{
ID: "123",
Name: "Alice",
Email: "alice@example.com",
}, nil)
// 创建服务
service := &UserService{repo: mockRepo}
// 执行测试
user, err := service.GetUser("123")
// 断言
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if user.Name != "Alice" {
t.Errorf("expected name Alice, got %s", user.Name)
}
// 验证期望是否被调用
mockRepo.AssertExpectations(t)
mockRepo.AssertCalled(t, "GetByID", "123")
}
func TestUserService_CreateUser(t *testing.T) {
mockRepo := new(MockUserRepository)
// 设置 Save 返回成功
mockRepo.On("Save", mock.AnythingOfType("*main.User")).Return(nil)
service := &UserService{repo: mockRepo}
user, err := service.CreateUser("Bob", "bob@example.com")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if user.Name != "Bob" {
t.Errorf("expected name Bob, got %s", user.Name)
}
mockRepo.AssertExpectations(t)
}
func TestUserService_GetUser_NotFound(t *testing.T) {
mockRepo := new(MockUserRepository)
// 设置返回错误
mockRepo.On("GetByID", "999").Return(nil, errors.New("user not found"))
service := &UserService{repo: mockRepo}
user, err := service.GetUser("999")
if err == nil {
t.Fatal("expected error, got nil")
}
if user != nil {
t.Error("expected nil user, got non-nil")
}
mockRepo.AssertExpectations(t)
}gomock 使用
go
package main
//go:generate mockgen -source=repository.go -destination=mocks/mock_repository.go -package=mocks
import (
"testing"
"github.com/golang/mock/gomock"
"example.com/project/mocks"
)
func TestUserService(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
// 创建 mock
mockRepo := mocks.NewMockUserRepository(ctrl)
// 设置期望
mockRepo.EXPECT().
GetByID("123").
Return(&User{ID: "123", Name: "Alice"}, nil)
service := &UserService{repo: mockRepo}
user, err := service.GetUser("123")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if user.Name != "Alice" {
t.Errorf("expected Alice, got %s", user.Name)
}
}
// 高级期望匹配
mockRepo.EXPECT().
Save(gomock.Cond(func(x interface{}) bool {
user, ok := x.(*User)
return ok && user.Email != ""
})).
Return(nil)
// 多次调用
mockRepo.EXPECT().
GetByID(gomock.Any()).
Return(&User{}, nil).
Times(3)
// 顺序调用
gomock.InOrder(
mockRepo.EXPECT().First().Return(nil),
mockRepo.EXPECT().Second().Return(nil),
)表格驱动测试进阶
基础表格驱动测试
go
package main
import "testing"
func TestAdd(t *testing.T) {
tests := []struct {
name string
a, b int
expected int
}{
{"正数相加", 1, 2, 3},
{"负数相加", -1, -2, -3},
{"零相加", 0, 0, 0},
{"正负相加", 5, -3, 2},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.a + tt.b
if result != tt.expected {
t.Errorf("expected %d, got %d", tt.expected, result)
}
})
}
}表格驱动测试 + Mock
go
package main
import (
"errors"
"testing"
"github.com/stretchr/testify/mock"
)
func TestUserService_GetUser(t *testing.T) {
tests := []struct {
name string
userID string
mockSetup func(*MockUserRepository)
expectUser *User
expectError error
}{
{
name: "成功获取用户",
userID: "123",
mockSetup: func(m *MockUserRepository) {
m.On("GetByID", "123").Return(&User{
ID: "123", Name: "Alice",
}, nil)
},
expectUser: &User{ID: "123", Name: "Alice"},
expectError: nil,
},
{
name: "用户不存在",
userID: "999",
mockSetup: func(m *MockUserRepository) {
m.On("GetByID", "999").Return(nil, errors.New("not found"))
},
expectUser: nil,
expectError: errors.New("not found"),
},
{
name: "数据库错误",
userID: "123",
mockSetup: func(m *MockUserRepository) {
m.On("GetByID", "123").Return(nil, errors.New("db error"))
},
expectUser: nil,
expectError: errors.New("db error"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockRepo := new(MockUserRepository)
tt.mockSetup(mockRepo)
service := &UserService{repo: mockRepo}
user, err := service.GetUser(tt.userID)
if tt.expectError != nil {
if err == nil {
t.Fatal("expected error, got nil")
}
} else {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if user.Name != tt.expectUser.Name {
t.Errorf("expected %s, got %s", tt.expectUser.Name, user.Name)
}
}
mockRepo.AssertExpectations(t)
})
}
}模糊测试 (Fuzzing)
go
package main
import (
"testing"
)
// 要测试的函数
func ParseInt(s string) (int, error) {
var result int
for _, c := range s {
if c < '0' || c > '9' {
return 0, errors.New("invalid digit")
}
result = result*10 + int(c-'0')
}
return result, nil
}
// 模糊测试 (Go 1.18+)
func FuzzParseInt(f *testing.F) {
// 添加种子语料
f.Add("123")
f.Add("0")
f.Add("999")
f.Add("-1") // 无效输入
f.Fuzz(func(t *testing.T, s string) {
result, err := ParseInt(s)
// 验证:如果无错误,结果应该是有效的
if err == nil {
// 验证解析结果
expected := stringsToInt(s)
if result != expected {
t.Errorf("ParseInt(%q) = %d, want %d", s, result, expected)
}
}
})
}
// 辅助函数
func stringsToInt(s string) int {
// 简化实现
return 0
}
// 模糊测试配置
func FuzzParseInt(f *testing.F) {
f.Add("123")
f.Add("")
f.Fuzz(func(t *testing.T, s string) {
// 跳过过长输入
if len(s) > 100 {
t.Skip()
}
result, err := ParseInt(s)
// 验证 panic
defer func() {
if r := recover(); r != nil {
t.Errorf("ParseInt(%q) panicked: %v", s, r)
}
}()
_ = result
_ = err
})
}测试套件 (testify/suite)
go
package main
import (
"testing"
"github.com/stretchr/testify/suite"
)
type UserServiceSuite struct {
suite.Suite
service *UserService
repo *MockUserRepository
}
// SetupSuite 在所有测试之前运行
func (s *UserServiceSuite) SetupSuite() {
s.repo = new(MockUserRepository)
s.service = &UserService{repo: s.repo}
}
// SetupTest 在每个测试之前运行
func (s *UserServiceSuite) SetupTest() {
s.repo.ExpectedCalls = nil
}
// TearDownTest 在每个测试之后运行
func (s *UserServiceSuite) TearDownTest() {
s.repo.AssertExpectations(s.T())
}
// TearDownSuite 在所有测试之后运行
func (s *UserServiceSuite) TearDownSuite() {
}
// 测试方法必须以 Test 开头
func (s *UserServiceSuite) TestGetUser_Success() {
s.repo.On("GetByID", "123").Return(&User{
ID: "123", Name: "Alice",
}, nil)
user, err := s.service.GetUser("123")
s.Require().NoError(err)
s.Require().NotNil(user)
s.Equal("Alice", user.Name)
}
func (s *UserServiceSuite) TestGetUser_NotFound() {
s.repo.On("GetByID", "999").Return(nil, errors.New("not found"))
user, err := s.service.GetUser("999")
s.Error(err)
s.Nil(user)
}
func (s *UserServiceSuite) TestCreateUser_Success() {
s.repo.On("Save", mock.Anything).Return(nil)
user, err := s.service.CreateUser("Bob", "bob@example.com")
s.Require().NoError(err)
s.Equal("Bob", user.Name)
}
// 运行测试套件
func TestUserServiceSuite(t *testing.T) {
suite.Run(t, new(UserServiceSuite))
}集成测试
数据库集成测试
go
package main
import (
"database/sql"
"os"
"testing"
_ "github.com/lib/pq"
)
var testDB *sql.DB
// TestMain 控制测试生命周期
func TestMain(m *testing.M) {
// 设置测试数据库
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
dsn = "postgres://localhost/testdb?sslmode=disable"
}
var err error
testDB, err = sql.Open("postgres", dsn)
if err != nil {
panic(err)
}
// 运行迁移
if err := runMigrations(testDB); err != nil {
panic(err)
}
// 运行测试
code := m.Run()
// 清理
testDB.Close()
os.Exit(code)
}
func TestUserRepository(t *testing.T) {
// 每个测试使用事务
tx, err := testDB.Begin()
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
repo := NewUserRepository(tx)
t.Run("Save and Get", func(t *testing.T) {
user := &User{Name: "Test", Email: "test@example.com"}
err := repo.Save(user)
if err != nil {
t.Fatal(err)
}
got, err := repo.GetByID(user.ID)
if err != nil {
t.Fatal(err)
}
if got.Name != user.Name {
t.Errorf("expected %s, got %s", user.Name, got.Name)
}
})
// 事务不回滚,测试数据自动清理
}使用 testcontainers
go
package main
import (
"context"
"database/sql"
"testing"
"time"
"github.com/docker/go-connections/nat"
"github.com/stretchr/testify/suite"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
_ "github.com/lib/pq"
)
type PostgresTestSuite struct {
suite.Suite
container testcontainers.Container
db *sql.DB
}
func (s *PostgresTestSuite) SetupSuite() {
ctx := context.Background()
// 启动 PostgreSQL 容器
req := testcontainers.ContainerRequest{
Image: "postgres:14",
ExposedPorts: []string{"5432/tcp"},
Env: map[string]string{
"POSTGRES_USER": "test",
"POSTGRES_PASSWORD": "test",
"POSTGRES_DB": "testdb",
},
WaitingFor: wait.ForLog("database system is ready").WithOccurrence(2).WithStartupTimeout(60 * time.Second),
}
container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: req,
Started: true,
})
if err != nil {
s.T().Fatalf("failed to start container: %v", err)
}
s.container = container
// 获取连接信息
host, _ := container.Host(ctx)
port, _ := container.MappedPort(ctx, "5432")
// 连接数据库
dsn := fmt.Sprintf("postgres://test:test@%s:%s/testdb?sslmode=disable", host, port.Port())
db, err := sql.Open("postgres", dsn)
if err != nil {
s.T().Fatalf("failed to connect to database: %v", err)
}
// 等待数据库就绪
for i := 0; i < 30; i++ {
if err := db.Ping(); err == nil {
break
}
time.Sleep(time.Second)
}
s.db = db
// 运行迁移
s.runMigrations()
}
func (s *PostgresTestSuite) TearDownSuite() {
if s.db != nil {
s.db.Close()
}
if s.container != nil {
s.container.Terminate(context.Background())
}
}
func (s *PostgresTestSuite) runMigrations() {
// 运行 schema 创建
_, err := s.db.Exec(`
CREATE TABLE users (
id VARCHAR PRIMARY KEY,
name VARCHAR NOT NULL,
email VARCHAR UNIQUE NOT NULL
)
`)
if err != nil {
s.T().Fatalf("failed to run migrations: %v", err)
}
}
func (s *PostgresTestSuite) TestUserRepository() {
repo := NewUserRepository(s.db)
user := &User{ID: "1", Name: "Test", Email: "test@example.com"}
err := repo.Save(user)
s.Require().NoError(err)
got, err := repo.GetByID("1")
s.Require().NoError(err)
s.Equal("Test", got.Name)
}
func TestPostgresSuite(t *testing.T) {
suite.Run(t, new(PostgresTestSuite))
}HTTP 测试
httptest 使用
go
package main
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestHandler(t *testing.T) {
// 创建测试服务器
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("Hello"))
}))
defer server.Close()
// 发送请求
resp, err := http.Get(server.URL)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("expected status 200, got %d", resp.StatusCode)
}
}
// 测试 Gin 路由
func TestGinHandler(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.Default()
r.GET("/users/:id", func(c *gin.Context) {
c.JSON(200, gin.H{"id": c.Param("id")})
})
// 创建请求
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/users/123", nil)
r.ServeHTTP(w, req)
// 断言
if w.Code != 200 {
t.Errorf("expected 200, got %d", w.Code)
}
if !strings.Contains(w.Body.String(), "123") {
t.Errorf("expected body to contain 123, got %s", w.Body.String())
}
}测试检查清单
[ ] 使用表格驱动测试覆盖多场景
[ ] Mock 外部依赖
[ ] 测试命名清晰
[ ] 每个测试独立可并行
[ ] 使用 testify 断言
[ ] 集成测试使用 testcontainers
[ ] 模糊测试覆盖边界情况
[ ] 测试覆盖率达标
[ ] 基准测试验证性能
[ ] CI 集成测试