Go 测试覆盖率与 Mock 技术:从单元测试到可度量质量工程

深入讲解 Go 测试体系的覆盖率分析、Mock 框架对比与使用、表驱动测试进阶,以及将测试纳入 CI 质量门禁的完整工程实践

Go 测试哲学与文化

Go 语言的测试文化根植于其设计语言之中:简单、直接、务实。与许多语言依赖第三方测试框架不同,Go 在标准库中直接提供了轻量但功能完整的测试支持。testing 包的设计体现了 Go 社区的核心价值观——少即是多。

testing 包的设计意图

Rob Pike 和 Go 团队在设计 testing 包时遵循了几个核心原则:

  1. 零依赖启动:不需要安装任何额外工具或框架,一个 .go 文件加上 go test 命令即可运行测试
  2. 约定优于配置:测试文件以 _test.go 结尾,测试函数以 Test 开头,基准测试函数以 Benchmark 开头,示例函数以 Example 开头。这些约定使得工具链可以自动发现所有测试
  3. 与语言原生集成:测试就是普通的 Go 代码,测试函数是普通的 Go 函数。你可以使用所有 Go 语言特性和标准库来编写测试
  4. 显式优于隐式:没有 before/after 钩子(如 JUnit 的 @BeforeEach),没有隐式的测试套件。每个测试函数独立、完整、自包含
  5. 快速反馈go test 默认只运行当前包的测试,编译速度快,反馈迅速

这种设计使得 Go 的测试门槛极低——从第一天写 Go 代码起,开发者就在写可测试的代码。没有繁琐的配置,没有复杂的注解,只有纯粹的 Go 代码和清晰的输出。

测试与 Go 工程文化的融合

在 Go 生态中,测试不是后期添加的附属品,而是与生产代码同等重要的组成部分。Go 标准库自身有超过 50% 的代码是测试代码,这为整个社区树立了标杆。

Go 的测试文化强调:

  • 测试是文档:一个好的测试用例清晰地说明了函数的预期行为和使用方式
  • 测试是契约:接口的测试定义了实现必须满足的条件
  • 测试是重构的安全网:有充分测试覆盖的代码才敢放心重构
  • 测试驱动设计:通过编写测试来思考 API 设计,往往能得出更简洁、更易用的接口

单元测试基础回顾

在深入高级主题之前,让我们快速回顾 Go 单元测试的基础知识。

testing.T 接口

func TestAdd(t *testing.T) {
    result := Add(2, 3)
    if result != 5 {
        t.Errorf("Add(2, 3) = %d; want 5", result)
    }
}

testing.T 是每个测试函数的参数,提供了丰富的断言和辅助方法:

  • t.Errorf(string, ...):记录错误但继续执行当前测试函数
  • t.Fatalf(string, ...):记录错误并立即终止当前测试函数
  • t.Logf(string, ...)t.Log(...):记录日志信息(仅在 -v 或测试失败时显示)
  • t.Skip(string):跳过当前测试
  • t.Parallel():标记当前测试可与其它测试并行执行
  • t.Helper():标记当前函数为辅助函数,使错误信息指向调用者
  • t.TempDir():创建临时目录,测试结束后自动清理
  • t.Setenv(key, value):设置环境变量,测试结束后自动恢复

testing.B 与基准测试

func BenchmarkFibonacci(b *testing.B) {
    for i := 0; i < b.N; i++ {
        Fibonacci(20)
    }
}

基准测试用于度量代码性能。testing.BN 字段由框架自动调整,以确保测试运行时间足够长以获得可靠的统计结果。

运行基准测试:

go test -bench=. -benchmem

-benchmem 参数还会报告内存分配统计。

TestMain 与测试前准备

func TestMain(m *testing.M) {
    // 在所有测试之前执行
    setup()

    // 运行测试
    code := m.Run()

    // 在所有测试之后执行
    teardown()

    os.Exit(code)
}

TestMain 提供了在测试套件级别执行初始化和清理的机会。但 Go 推荐尽量让每个测试自包含,避免共享状态。

表驱动测试的进阶写法

表驱动测试(Table-Driven Test)是 Go 社区最推崇、最具特色的测试模式。它将测试用例组织为结构体切片,然后在循环中执行每个用例。

基础表驱动测试

func TestAdd(t *testing.T) {
    tests := []struct {
        name     string
        a, b     int
        expected int
    }{
        {"positive", 1, 2, 3},
        {"negative", -1, -2, -3},
        {"mixed", -1, 2, 1},
        {"zero", 0, 0, 0},
    }

    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.Parallel() 结合实现并行执行:

func TestDivide(t *testing.T) {
    tests := []struct {
        name        string
        a, b        int
        expected    int
        expectError bool
    }{
        {"normal", 10, 2, 5, false},
        {"divide by zero", 10, 0, 0, true},
        {"negative", -10, 2, -5, false},
    }

    for _, tt := range tests {
        tt := tt // 捕获循环变量(Go < 1.22 需要)
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel() // 子测试并行执行
            result, err := Divide(tt.a, tt.b)
            if tt.expectError {
                if err == nil {
                    t.Errorf("Divide(%d, %d) expected error", tt.a, tt.b)
                }
                return
            }
            if err != nil {
                t.Errorf("Divide(%d, %d) unexpected error: %v", tt.a, tt.b, err)
                return
            }
            if result != tt.expected {
                t.Errorf("Divide(%d, %d) = %d; want %d", tt.a, tt.b, result, tt.expected)
            }
        })
    }
}

注意:Go 1.22 之前,由于循环变量共享,需要在循环内部复制 tt := tt。Go 1.22 修复了这个问题。

动态构造用例

在某些场景下,测试用例不是静态定义的,而是根据输入数据或配置动态构造的:

func TestValidation(t *testing.T) {
    // 从文件加载测试数据
    data, err := os.ReadFile("testdata/validation_cases.json")
    if err != nil {
        t.Fatalf("load test data: %v", err)
    }

    var cases []struct {
        Input    string `json:"input"`
        Expected bool   `json:"expected"`
    }
    if err := json.Unmarshal(data, &cases); err != nil {
        t.Fatalf("parse test data: %v", err)
    }

    for i, c := range cases {
        t.Run(fmt.Sprintf("case_%d", i), func(t *testing.T) {
            result := Validate(c.Input)
            if result != c.Expected {
                t.Errorf("Validate(%q) = %v; want %v", c.Input, result, c.Expected)
            }
        })
    }
}

golden 文件模式

对于输出较长的测试(如序列化、渲染等),将期望输出存储在文件中(golden file),测试时与文件内容对比:

func TestJSONMarshal(t *testing.T) {
    obj := MyStruct{...}
    got, _ := json.MarshalIndent(obj, "", "  ")

    golden := filepath.Join("testdata", "TestJSONMarshal.golden")
    if *update { // 通过 flag 控制更新 golden 文件
        os.WriteFile(golden, got, 0644)
        return
    }

    want, _ := os.ReadFile(golden)
    if !bytes.Equal(got, want) {
        t.Errorf("output mismatch\ngot:\n%s\nwant:\n%s", got, want)
    }
}

表驱动测试 vs 其他测试模式

Go 社区推崇表驱动测试的原因是:

  • 一行一个用例:添加测试用例就是添加一行代码,几乎零成本
  • 统一错误处理:共享的错误报告逻辑确保一致性
  • 结构清晰:用例与执行逻辑分离,意图明确
  • 易于扩展:添加边界条件和边缘场景非常简单

与之对比,其他语言常见的做法是每组测试一个函数:

@Test
public void testAddPositive() { ... }

@Test
public void testAddNegative() { ... }

这种方式在 Go 中完全可用,但通常被认为冗余且难以扩展。Go 的表驱动模式在表达能力相当的情况下,显著减少了样板代码。

代码覆盖率

代码覆盖率是衡量测试充分性的常用指标。Go 内置了支持代码覆盖率的功能,使用非常方便。

go test -cover

# 显示覆盖率百分比
go test -cover ./...

# 输出每个函数的覆盖率
go test -coverprofile=coverage.out ./...

# 仅测试特定包
go test -coverprofile=coverage.out ./pkg/service

-coverprofile 参数将覆盖率数据写入指定文件,格式如下:

mode: set
myapp/service/user.go:10.2,15.16 3 1
myapp/service/user.go:17.2,19.12 2 0
...

每行格式为:文件名:开始行.列,结束行.列 语句数 执行次数

HTML 可视化

# 生成 HTML 报告
go tool cover -html=coverage.out -o coverage.html

# 直接在浏览器打开
go tool cover -html=coverage.out

HTML 报告用颜色标记代码:绿色(覆盖)、红色(未覆盖)、灰色(不计入覆盖,如类型声明和注释)。这是定位测试缺口最直观的方式。

函数级覆盖分析

# 查看每个函数的覆盖率
go tool cover -func=coverage.out

输出格式:

myapp/service/user.go:10: CreateUser     87.5%
myapp/service/user.go:45: DeleteUser     60.0%
myapp/service/user.go:80: GetUser        100.0%

覆盖率目标的合理设定

覆盖率是重要指标,但不是唯一指标。盲目追求 100% 覆盖率可能带来以下问题:

  1. 测试代码膨胀:为了覆盖 trivial 代码而编写无价值的测试
  2. 虚假安全感:高覆盖率不等于正确性。测试可能通过了但逻辑仍然错误
  3. 维护成本:每个代码变更都要更新大量脆弱的测试
  4. 忽视集成测试:过度关注单元覆盖率可能忽略跨组件集成的测试

推荐的覆盖率策略:

  • 核心业务逻辑:优先覆盖,目标 80%+
  • 错误处理路径:特别关注边界条件和异常分支,这些往往是生产 bug 的出处
  • 工具/基础设施代码:适度覆盖,通常不需要极端高
  • 第三方封装/胶水代码:重点测试与外部系统交互的逻辑
  • 自动生成代码(如 protobuf):可以排除在覆盖率统计之外

排除不计入覆盖率的代码

Go 1.20+ 支持通过 // coverage:ignore 类似的方式标记,但标准做法是在测试时使用 -coverpkg 控制统计范围,或通过 .codecov.yml 等配置排除文件:

# 只统计特定包的覆盖
go test -coverpkg=./pkg/... -coverprofile=coverage.out ./...

Mock 的必要性与边界

Mock 是单元测试中隔离被测单元的核心技术。但 Mock 不是万能药,了解什么时候 mock、什么时候用真实依赖,是测试设计的关键决策。

什么时候使用 Mock

适合使用 Mock 的场景:

  1. 外部依赖不可控:数据库、API 服务、消息队列等可能不可用或状态不确定
  2. 测试边缘条件:需要模拟超时、网络断开、资源耗尽等难以在实际环境中复现的场景
  3. 测试速度:真实依赖(如数据库)的初始化可能很慢,Mock 可以大幅缩短测试反馈周期
  4. 并发测试:需要精确控制并发事件的顺序
  5. 开发早期:下游服务尚未就绪时,用 Mock 定义接口契约

什么时候不用 Mock(集成测试)

不适合使用 Mock 的场景:

  1. 测试 SQL 语句正确性:Mock 数据库无法验证 SQL 语法是否正确、查询是否高效
  2. 测试序列化/反序列化:应该用真实的数据格式
  3. 测试 ORM 框架使用:Mock ORM 失去了其测试意义
  4. 验收测试/端到端测试:需要验证整个系统的行为

Go 社区流行的一个观点是:「能不用 Mock 就不用 Mock」。特别是使用 SQLite in-memory、testcontainers 等技术后,越来越多场景可以用真实依赖替代 Mock。

testify/mock 使用详解

stretchr/testify 是 Go 生态中最流行的测试辅助库,其中的 mock 包提供了轻量级的 Mock 框架。

基本使用

package main

import (
    "testing"

    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/mock"
)

// 定义接口
type EmailService interface {
    SendEmail(to, subject, body string) error
}

// Mock 实现
type MockEmailService struct {
    mock.Mock
}

func (m *MockEmailService) SendEmail(to, subject, body string) error {
    args := m.Called(to, subject, body)
    return args.Error(0)
}

// 被测服务
type UserService struct {
    email EmailService
}

func (s *UserService) RegisterUser(email string) error {
    return s.email.SendEmail(email, "Welcome", "Thanks for registering!")
}

// 测试
func TestUserService_RegisterUser(t *testing.T) {
    mockEmail := new(MockEmailService)

    // 设置期望
    mockEmail.On("SendEmail", "user@example.com", "Welcome", mock.Anything).
        Return(nil).
        Once()

    svc := &UserService{email: mockEmail}
    err := svc.RegisterUser("user@example.com")

    assert.NoError(t, err)
    mockEmail.AssertExpectations(t)
}

Expect 与 Assert

testify/mock 的核心是 On 方法设置期望,以及测试结束时 AssertExpectations 验证期望是否全部满足。

常用匹配器:

  • mock.Anything:匹配任意参数
  • mock.AnythingOfType("string"):匹配任意该类型的参数
  • mock.MatchedBy(func):用自定义函数匹配参数

常用调用次数断言:

  • .Once():期望恰好调用一次
  • .Twice():恰好两次
  • .Times(n):恰好 n 次
  • .Return(v):指定返回值
  • .Run(func):设置调用时执行的函数(可用于动态返回值或副作用)

testify/mock 的局限性

  1. 需要手写 Mock 实现:每个接口都需要写一个 Mock 结构体,样板代码较多
  2. 依赖方法名字符串On("SendEmail", ...) 是字符串,没有编译时检查,重命名方法后测试仍可编译但会失败
  3. 缺少事后期望设置Mock.On 通常在执行前调用,不如 gomock 灵活
  4. 无代码生成:无法自动生成 Mock 代码

golang/mock (gomock) 使用详解

golang/mock(现位于 go.uber.org/mock)是 Go 生态中另一个主流 Mock 框架。与 testify/mock 不同的是,gomock 通过代码生成工具 mockgen 自动生成 Mock 代码。

安装与使用

go install go.uber.org/mock/mockgen@latest

生成 Mock 代码

给定接口定义:

//go:generate mockgen -source=service.go -destination=mocks/mock_service.go -package=mocks
type UserRepository interface {
    FindByID(id int) (*User, error)
    Save(user *User) error
    Delete(id int) error
}

运行 go generate 或直接使用 mockgen

mockgen -source=service.go -destination=mocks/mock_service.go -package=mocks

生成的 Mock 代码包含 MockUserRepository 结构体,实现了所有接口方法。

在测试中使用 gomock

package main

import (
    "testing"

    "go.uber.org/mock/gomock"
)

func TestUserService_GetUser(t *testing.T) {
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()

    mockRepo := NewMockUserRepository(ctrl)

    // 设置期望
    mockRepo.EXPECT().
        FindByID(123).
        Return(&User{ID: 123, Name: "Alice"}, nil).
        Times(1)

    svc := &UserService{repo: mockRepo}
    user, err := svc.GetUser(123)

    if err != nil {
        t.Errorf("unexpected error: %v", err)
    }
    if user.Name != "Alice" {
        t.Errorf("expected Alice, got %s", user.Name)
    }
}

gomock 的高级匹配器

mockRepo.EXPECT().
    FindByID(gomock.Any()).     // 匹配任意 int
    Return(&User{}, nil)

mockRepo.EXPECT().
    Save(gomock.Eq(&User{Name: "Bob"})).  // 匹配相等的 User
    Return(nil)

mockRepo.EXPECT().
    Delete(gomock.Eq(100)).
    DoAndReturn(func(id int) error {
        // 自定义副作用和返回值
        t.Logf("Delete called with %d", id)
        return nil
    })

// 按调用顺序匹配
mockRepo.EXPECT().FindByID(1).Return(nil, nil)
mockRepo.EXPECT().FindByID(2).Return(nil, nil).After(gomock.Any())

gomock 与 testify/mock 对比

特性testify/mockgomock
代码生成是(mockgen)
编译时检查弱(字符串方法名)强(生成代码)
样板代码多(手写字段)少(自动生成)
维护工作量接口变更需手动修改重新生成即可
学习曲线平坦略陡
社区流行度极高

选择建议

  • 简单项目或少量接口:testify/mock 足够,无需引入代码生成工具链
  • 大型项目或频繁变更的接口:gomock 的代码生成虽然前期有设置成本,但长期维护更省事
  • 两者可以共存:同一项目中可以根据场景选择

自研轻量 mock 方案

在某些场景下,手写简单的 Mock 比引入框架更快、更清晰。

接口 + 手工 Stub

type PaymentGateway interface {
    Charge(amount float64, currency string) (string, error)
}

// 手工 Stub
type StubPaymentGateway struct {
    ShouldFail bool
    ChargeID   string
}

func (s *StubPaymentGateway) Charge(amount float64, currency string) (string, error) {
    if s.ShouldFail {
        return "", errors.New("charge failed")
    }
    return s.ChargeID, nil
}

// 测试中直接使用
func TestOrderService(t *testing.T) {
    gateway := &StubPaymentGateway{ChargeID: "ch_123"}
    svc := NewOrderService(gateway)
    // ...
}

Spy 模式

Spy 是一种特殊的 Mock,它记录调用信息供事后验证:

type SpyEmailSender struct {
    Calls []struct {
        To      string
        Subject string
    }
}

func (s *SpyEmailSender) Send(to, subject, body string) error {
    s.Calls = append(s.Calls, struct {
        To      string
        Subject string
    }{To: to, Subject: subject})
    return nil
}

手工 Mock 和 Spy 的优势是:

  • 零依赖
  • 完全控制行为
  • 代码简单直接,没有学习成本
  • 对于简单接口,可能比框架更快

劣势是:

  • 接口方法多了代码量大
  • 没有内置的参数匹配和调用次数验证
  • 每次接口变更都需要手动更新 Stub

测试替身类型对比

在测试术语中,替代真实依赖的对象总称为「测试替身」(Test Doubles)。根据用途不同,可分为以下几类:

Dummy

Dummy 是指仅用于满足参数列表但不会被实际使用的对象。通常用 nil 或空实现:

func TestCreateUser(t *testing.T) {
    // Logger 在这个测试中不重要,传入一个 dummy
    user, err := CreateUser("Alice", &dummyLogger{})
    // ...
}

type dummyLogger struct{}
func (d *dummyLogger) Log(string) {}

Fake

Fake 是一个有实际工作实现但简化了真实依赖的对象。例如内存数据库代替真实数据库:

type FakeUserStore struct {
    users map[int]*User
}

func (f *FakeUserStore) Save(u *User) error {
    f.users[u.ID] = u
    return nil
}

Stub

Stub 是预先编程了固定响应的测试替身。它不对特定输入做验证,只是按预设返回:

type StubPaymentGateway struct {
    FixedChargeID string
}

func (s *StubPaymentGateway) Charge(float64, string) (string, error) {
    return s.FixedChargeID, nil
}

Spy

Spy 记录了调用信息,用于事后验证:

type SpyLogger struct {
    Messages []string
}

func (s *SpyLogger) Log(msg string) {
    s.Messages = append(s.Messages, msg)
}

Mock

Mock 是带有预定义期望(expectations)的测试替身。Mock 会验证它是否按预期被调用:

// testify/mock 或 gomock 提供的就是 Mock
mockEmail.On("SendEmail", "a@b.com", "Welcome", mock.Anything).Return(nil).Once()

如何选择

场景推荐替身
参数占位Dummy
简单固定返回值Stub
需要验证调用次数/参数Mock
需要查看调用历史Spy
需要简化但真实的实现Fake

Go 的哲学鼓励简单:在大多数情况下,Stub 和 Fake 已经足够。只有在需要严格验证调用行为时,才引入 Mock 框架。

测试与 CI/CD 集成

将测试嵌入 CI/CD 流水线是现代软件工程的必备实践。

go test -race

数据竞态(race condition)是并发程序中最难调试的 bug 类型。Go 内置了竞态检测器:

go test -race ./...

开启 -race 后,编译器会在所有内存访问处插入检测代码。运行时,如果检测到两个 goroutine 未同步地访问同一块内存且至少一个是写操作,就会报告竞态。运行速度会慢 5-20 倍,因此通常只在 CI 中运行,本地开发用常规测试。

覆盖率门禁

在 CI 中设置覆盖率门槛,低于阈值的构建失败:

#!/bin/bash
COVERAGE=$(go test -coverprofile=coverage.out ./... | grep -v "no test files" | awk '{sum+=$NF} END {print sum/NR}')
THRESHOLD=70.0

if (( $(echo "$COVERAGE < $THRESHOLD" | bc -l) )); then
    echo "Coverage $COVERAGE% is below threshold $THRESHOLD%"
    exit 1
fi

或使用专门的工具如 gover

go install github.com/modocache/gover@latest
gover

Flaky Test 检测

不稳定测试(Flaky Test,时而过时而不失败的测试)是 CI 的毒瘤。检测和处理策略:

  1. 多次运行:使用 -count=N 参数
go test -count=100 -run TestRaceCondition ./...
  1. 使用 gotestsum:更友好的测试输出和重试机制
gotestsum --rerun-fails=3 ./...
  1. 标记已知 Flaky:用 t.Skip 暂时跳过,并创建跟踪 issue
func TestFlakyFeature(t *testing.T) {
    t.Skip("TODO(#123): fix flaky test")
}
  1. 根因分析:Flaky 测试通常暴露的是代码中的真正竞态问题,而不是测试本身的问题

完整实战:微服务模块全套单元测试

让我们为一个模拟的用户服务模块编写全套单元测试。该模块包含用户 CRUD 和认证功能。

被测代码

// user.go
package user

import (
    "errors"
    "fmt"
    "time"
)

var (
    ErrUserNotFound = errors.New("user not found")
    ErrInvalidInput = errors.New("invalid input")
)

type User struct {
    ID        int
    Email     string
    Name      string
    CreatedAt time.Time
}

type Repository interface {
    FindByID(id int) (*User, error)
    FindByEmail(email string) (*User, error)
    Save(user *User) error
    Delete(id int) error
    List(offset, limit int) ([]*User, error)
}

type Hasher interface {
    Hash(password string) (string, error)
    Compare(password, hash string) bool
}

type Notifier interface {
    SendWelcomeEmail(email, name string) error
}

type Service struct {
    repo     Repository
    hasher   Hasher
    notifier Notifier
}

func NewService(repo Repository, hasher Hasher, notifier Notifier) *Service {
    return &Service{
        repo:     repo,
        hasher:   hasher,
        notifier: notifier,
    }
}

func (s *Service) CreateUser(email, name, password string) (*User, error) {
    if email == "" || name == "" || password == "" {
        return nil, ErrInvalidInput
    }

    existing, _ := s.repo.FindByEmail(email)
    if existing != nil {
        return nil, fmt.Errorf("email already registered: %s", email)
    }

    hash, err := s.hasher.Hash(password)
    if err != nil {
        return nil, fmt.Errorf("hash password: %w", err)
    }

    user := &User{
        Email:     email,
        Name:      name,
        CreatedAt: time.Now(),
    }

    if err := s.repo.Save(user); err != nil {
        return nil, fmt.Errorf("save user: %w", err)
    }

    _ = s.notifier.SendWelcomeEmail(email, name)

    return user, nil
}

func (s *Service) Authenticate(email, password string) (*User, error) {
    user, err := s.repo.FindByEmail(email)
    if err != nil {
        return nil, ErrUserNotFound
    }

    if !s.hasher.Compare(password, user.Name+"hash") { // 简化示意
        return nil, errors.New("invalid password")
    }

    return user, nil
}

func (s *Service) GetUser(id int) (*User, error) {
    user, err := s.repo.FindByID(id)
    if err != nil {
        return nil, ErrUserNotFound
    }
    return user, nil
}

func (s *Service) DeleteUser(id int) error {
    if err := s.repo.Delete(id); err != nil {
        return fmt.Errorf("delete user: %w", err)
    }
    return nil
}

func (s *Service) ListUsers(page, pageSize int) ([]*User, error) {
    if page < 1 {
        page = 1
    }
    if pageSize < 1 || pageSize > 100 {
        pageSize = 20
    }
    offset := (page - 1) * pageSize
    return s.repo.List(offset, pageSize)
}

Mock 实现

// mocks_test.go
package user

import "testing"

// MockRepository 的 testify/mock 实现
type MockRepository struct {
    mock.Mock
}

func (m *MockRepository) FindByID(id int) (*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 *MockRepository) FindByEmail(email string) (*User, error) {
    args := m.Called(email)
    if args.Get(0) == nil {
        return nil, args.Error(1)
    }
    return args.Get(0).(*User), args.Error(1)
}

func (m *MockRepository) Save(user *User) error {
    args := m.Called(user)
    return args.Error(0)
}

func (m *MockRepository) Delete(id int) error {
    args := m.Called(id)
    return args.Error(0)
}

func (m *MockRepository) List(offset, limit int) ([]*User, error) {
    args := m.Called(offset, limit)
    if args.Get(0) == nil {
        return nil, args.Error(1)
    }
    return args.Get(0).([]*User), args.Error(1)
}

type MockHasher struct {
    mock.Mock
}

func (m *MockHasher) Hash(password string) (string, error) {
    args := m.Called(password)
    return args.String(0), args.Error(1)
}

func (m *MockHasher) Compare(password, hash string) bool {
    args := m.Called(password, hash)
    return args.Bool(0)
}

type MockNotifier struct {
    mock.Mock
}

func (m *MockNotifier) SendWelcomeEmail(email, name string) error {
    args := m.Called(email, name)
    return args.Error(0)
}

完整单元测试

// user_test.go
package user

import (
    "errors"
    "testing"

    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/mock"
)

func TestService_CreateUser(t *testing.T) {
    tests := []struct {
        name        string
        email       string
        fullName    string
        password    string
        setupMocks  func(*MockRepository, *MockHasher, *MockNotifier)
        expectError bool
        errorMsg    string
    }{
        {
            name:     "success",
            email:    "alice@example.com",
            fullName: "Alice",
            password: "secret",
            setupMocks: func(repo *MockRepository, hasher *MockHasher, notifier *MockNotifier) {
                repo.On("FindByEmail", "alice@example.com").Return(nil, errors.New("not found")).Once()
                hasher.On("Hash", "secret").Return("hashed_secret", nil).Once()
                repo.On("Save", mock.AnythingOfType("*user.User")).Return(nil).Once()
                notifier.On("SendWelcomeEmail", "alice@example.com", "Alice").Return(nil).Once()
            },
            expectError: false,
        },
        {
            name:        "empty email",
            email:       "",
            fullName:    "Alice",
            password:    "secret",
            setupMocks:  func(r *MockRepository, h *MockHasher, n *MockNotifier) {},
            expectError: true,
            errorMsg:    "invalid input",
        },
        {
            name:     "email already registered",
            email:    "bob@example.com",
            fullName: "Bob",
            password: "secret",
            setupMocks: func(repo *MockRepository, hasher *MockHasher, notifier *MockNotifier) {
                repo.On("FindByEmail", "bob@example.com").Return(&User{ID: 1, Email: "bob@example.com"}, nil).Once()
            },
            expectError: true,
            errorMsg:    "already registered",
        },
        {
            name:     "hash failed",
            email:    "charlie@example.com",
            fullName: "Charlie",
            password: "secret",
            setupMocks: func(repo *MockRepository, hasher *MockHasher, notifier *MockNotifier) {
                repo.On("FindByEmail", "charlie@example.com").Return(nil, errors.New("not found")).Once()
                hasher.On("Hash", "secret").Return("", errors.New("hash error")).Once()
            },
            expectError: true,
            errorMsg:    "hash password",
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            repo := new(MockRepository)
            hasher := new(MockHasher)
            notifier := new(MockNotifier)

            tt.setupMocks(repo, hasher, notifier)

            svc := NewService(repo, hasher, notifier)
            user, err := svc.CreateUser(tt.email, tt.fullName, tt.password)

            if tt.expectError {
                assert.Error(t, err)
                if tt.errorMsg != "" {
                    assert.Contains(t, err.Error(), tt.errorMsg)
                }
                assert.Nil(t, user)
            } else {
                assert.NoError(t, err)
                assert.NotNil(t, user)
                assert.Equal(t, tt.email, user.Email)
            }

            repo.AssertExpectations(t)
            hasher.AssertExpectations(t)
            notifier.AssertExpectations(t)
        })
    }
}

func TestService_GetUser(t *testing.T) {
    tests := []struct {
        name        string
        id          int
        mockSetup   func(*MockRepository)
        expectUser  *User
        expectError error
    }{
        {
            name: "found",
            id:   1,
            mockSetup: func(repo *MockRepository) {
                repo.On("FindByID", 1).Return(&User{ID: 1, Email: "a@b.com", Name: "Alice"}, nil).Once()
            },
            expectUser:  &User{ID: 1, Email: "a@b.com", Name: "Alice"},
            expectError: nil,
        },
        {
            name: "not found",
            id:   999,
            mockSetup: func(repo *MockRepository) {
                repo.On("FindByID", 999).Return(nil, errors.New("not found")).Once()
            },
            expectUser:  nil,
            expectError: ErrUserNotFound,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            repo := new(MockRepository)
            tt.mockSetup(repo)

            svc := NewService(repo, nil, nil)
            user, err := svc.GetUser(tt.id)

            if tt.expectError != nil {
                assert.ErrorIs(t, err, tt.expectError)
            } else {
                assert.NoError(t, err)
            }
            assert.Equal(t, tt.expectUser, user)
            repo.AssertExpectations(t)
        })
    }
}

func TestService_DeleteUser(t *testing.T) {
    repo := new(MockRepository)
    repo.On("Delete", 1).Return(nil).Once()

    svc := NewService(repo, nil, nil)
    err := svc.DeleteUser(1)

    assert.NoError(t, err)
    repo.AssertExpectations(t)
}

func TestService_ListUsers(t *testing.T) {
    tests := []struct {
        name      string
        page      int
        pageSize  int
        mockSetup func(*MockRepository)
        expectLen int
    }{
        {
            name:     "default pagination",
            page:     0,
            pageSize: -1,
            mockSetup: func(repo *MockRepository) {
                repo.On("List", 0, 20).Return([]*User{{ID: 1}}, nil).Once()
            },
            expectLen: 1,
        },
        {
            name:     "custom page",
            page:     2,
            pageSize: 10,
            mockSetup: func(repo *MockRepository) {
                repo.On("List", 10, 10).Return(make([]*User, 5), nil).Once()
            },
            expectLen: 5,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            repo := new(MockRepository)
            tt.mockSetup(repo)

            svc := NewService(repo, nil, nil)
            users, err := svc.ListUsers(tt.page, tt.pageSize)

            assert.NoError(t, err)
            assert.Len(t, users, tt.expectLen)
            repo.AssertExpectations(t)
        })
    }
}

覆盖率报告生成

运行测试并生成覆盖率报告:

cd user/
go test -coverprofile=coverage.out -v ./...
go tool cover -html=coverage.out -o coverage.html
go tool cover -func=coverage.out

测试运行指南

# 运行所有测试
go test ./...

# 运行特定测试
go test -run TestService_CreateUser ./...

# 详细输出
go test -v ./...

# 竞态检测
go test -race ./...

# 覆盖率
go test -cover ./...
go test -coverprofile=coverage.out ./...

# 基准测试
go test -bench=. -benchmem ./...

进阶测试技巧

Fuzz 测试

Go 1.18 引入了原生 Fuzz 测试,自动构造随机输入以发现边界情况:

func FuzzParseUserInput(f *testing.F) {
    // 种子语料库
    f.Add("alice@example.com", "Alice", "password123")
    f.Add("", "", "")

    f.Fuzz(func(t *testing.T, email, name, password string) {
        // Fuzz 测试不应 panic 或挂起
        user, err := ParseUserInput(email, name, password)
        if err != nil {
            // 错误是可接受的
            return
        }
        _ = user
    })
}

运行 Fuzz 测试:

go test -fuzz=FuzzParseUserInput -fuzztime=30s ./...

子测试中的 setup/teardown

func TestDatabaseOperations(t *testing.T) {
    db := setupTestDB(t)
    defer teardownTestDB(db)

    t.Run("insert", func(t *testing.T) {
        // ...
    })

    t.Run("select", func(t *testing.T) {
        // ...
    })
}

测试辅助函数

func assertUserEqual(t *testing.T, expected, actual *User) {
    t.Helper() // 标记为辅助函数,错误信息指向调用者
    if expected == nil && actual == nil {
        return
    }
    if expected == nil || actual == nil {
        t.Errorf("user mismatch: expected=%v, actual=%v", expected, actual)
        return
    }
    assert.Equal(t, expected.ID, actual.ID)
    assert.Equal(t, expected.Email, actual.Email)
    assert.Equal(t, expected.Name, actual.Name)
}

环境相关测试

func TestWithEnvironment(t *testing.T) {
    if testing.Short() {
        t.Skip("skipping test in short mode")
    }

    // t.TempDir() 创建临时目录,自动清理
    dir := t.TempDir()
    file := filepath.Join(dir, "test.txt")

    // t.Setenv() 设置环境变量,测试后自动恢复
    t.Setenv("MYAPP_CONFIG", file)

    // ...
}

完整可运行代码示例

示例 1:表驱动测试基础

package main

import "testing"

func Abs(x int) int {
    if x < 0 {
        return -x
    }
    return x
}

func TestAbs(t *testing.T) {
    tests := []struct {
        input    int
        expected int
    }{
        {0, 0},
        {1, 1},
        {-1, 1},
        {-100, 100},
    }

    for _, tt := range tests {
        result := Abs(tt.input)
        if result != tt.expected {
            t.Errorf("Abs(%d) = %d; want %d", tt.input, result, tt.expected)
        }
    }
}

示例 2:使用 testify 的断言

package main

import (
    "testing"

    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
)

func Divide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, assert.AnError
    }
    return a / b, nil
}

func TestDivide(t *testing.T) {
    result, err := Divide(10, 2)
    require.NoError(t, err)
    assert.InDelta(t, 5.0, result, 0.001)

    _, err = Divide(10, 0)
    assert.Error(t, err)
}

示例 3:基准测试与性能比较

package main

import (
    "strings"
    "testing"
)

func ConcatWithPlus(values []string) string {
    result := ""
    for _, v := range values {
        result += v
    }
    return result
}

func ConcatWithBuilder(values []string) string {
    var b strings.Builder
    for _, v := range values {
        b.WriteString(v)
    }
    return b.String()
}

func BenchmarkConcatWithPlus(b *testing.B) {
    values := []string{"hello", " ", "world", "!"}
    for i := 0; i < b.N; i++ {
        ConcatWithPlus(values)
    }
}

func BenchmarkConcatWithBuilder(b *testing.B) {
    values := []string{"hello", " ", "world", "!"}
    for i := 0; i < b.N; i++ {
        ConcatWithBuilder(values)
    }
}
go test -bench=BenchmarkConcat -benchmem

示例 4:接口与手工 Stub 测试

package main

import (
    "testing"
)

type DataStore interface {
    Get(key string) (string, error)
    Set(key, value string) error
}

type Cache struct {
    store DataStore
}

func (c *Cache) GetOrSet(key, value string) (string, error) {
    if v, err := c.store.Get(key); err == nil {
        return v, nil
    }
    if err := c.store.Set(key, value); err != nil {
        return "", err
    }
    return value, nil
}

// 手工 Stub
type StubDataStore struct {
    data map[string]string
}

func (s *StubDataStore) Get(key string) (string, error) {
    v, ok := s.data[key]
    if !ok {
        return "", assert.AnError
    }
    return v, nil
}

func (s *StubDataStore) Set(key, value string) error {
    s.data[key] = value
    return nil
}

import "github.com/stretchr/testify/assert"

func TestCache_GetOrSet(t *testing.T) {
    store := &StubDataStore{data: make(map[string]string)}
    cache := &Cache{store: store}

    // 第一次获取,缓存未命中
    v, err := cache.GetOrSet("key1", "value1")
    assert.NoError(t, err)
    assert.Equal(t, "value1", v)

    // 第二次获取,缓存命中
    v, err = cache.GetOrSet("key1", "value2")
    assert.NoError(t, err)
    assert.Equal(t, "value1", v) // 返回已缓存的旧值
}

示例 5:覆盖率驱动的测试补全

package main

import (
    "errors"
    "testing"

    "github.com/stretchr/testify/assert"
)

// 该函数最初缺少部分边界测试
func CalculateDiscount(price float64, isVIP bool) (float64, error) {
    if price < 0 {
        return 0, errors.New("price cannot be negative")
    }
    if price == 0 {
        return 0, nil
    }

    discount := 0.1
    if isVIP {
        discount = 0.2
        if price > 1000 {
            discount = 0.3
        }
    }

    return price * (1 - discount), nil
}

func TestCalculateDiscount(t *testing.T) {
    tests := []struct {
        name        string
        price       float64
        isVIP       bool
        expected    float64
        expectError bool
    }{
        {"negative price", -10, false, 0, true},
        {"zero price", 0, false, 0, false},
        {"normal non-VIP", 100, false, 90, false},
        {"normal VIP", 100, true, 80, false},
        {"big VIP", 2000, true, 1400, false}, // 初始可能被遗漏
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            result, err := CalculateDiscount(tt.price, tt.isVIP)
            if tt.expectError {
                assert.Error(t, err)
                return
            }
            assert.NoError(t, err)
            assert.InDelta(t, tt.expected, result, 0.01)
        })
    }
}

总结

本文系统地探讨了 Go 测试体系的各个方面,从基础的单元测试到覆盖率分析、Mock 技术、表驱动测试进阶,再到 CI/CD 集成实践。

关键要点回顾:

  1. 表驱动测试是 Go 的核心测试模式:它简洁、可扩展、易于维护,是每个 Go 开发者都应该掌握的惯用法
  2. 覆盖率是指导而非目标:合理的覆盖率策略关注核心业务路径和边界条件,而非追求数字上的完美
  3. Mock 只在必要时使用:优先尝试用真实依赖(如内存数据库)测试;Mock 适合测试外部服务交互、并发控制和异常路径
  4. testify/mock 与 gomock 各有所长:简单项目用 testify/mock,大型项目用 gomock 的代码生成器
  5. 测试是质量工程的一部分:将测试纳入 CI 门禁(覆盖率门槛、竞态检测、Flaky Test 管理),让质量内建于开发流程中

测试不是开发结束后的任务,而是贯穿整个开发过程的活动。Go 语言通过其简洁的测试框架设计,降低了编写测试的认知负担,使得开发者可以更自然地将测试融入日常开发。希望本文的内容能帮助你在 Go 项目中建立起高质量的测试体系,为代码重构和业务演进提供坚实的安全保障。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 熔断、降级与限流:Go 微服务韧性设计完全指南
  2. 事件溯源与 CQRS 在 Go 中的实践:复杂业务系统的架构升级
  3. TinyGo 嵌入式开发与物联网实战:微控制器编程完全指南