Skip to content

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 集成测试