Go 测试哲学与文化
Go 语言的测试文化根植于其设计语言之中:简单、直接、务实。与许多语言依赖第三方测试框架不同,Go 在标准库中直接提供了轻量但功能完整的测试支持。testing 包的设计体现了 Go 社区的核心价值观——少即是多。
testing 包的设计意图
Rob Pike 和 Go 团队在设计 testing 包时遵循了几个核心原则:
- 零依赖启动:不需要安装任何额外工具或框架,一个
.go文件加上go test命令即可运行测试 - 约定优于配置:测试文件以
_test.go结尾,测试函数以Test开头,基准测试函数以Benchmark开头,示例函数以Example开头。这些约定使得工具链可以自动发现所有测试 - 与语言原生集成:测试就是普通的 Go 代码,测试函数是普通的 Go 函数。你可以使用所有 Go 语言特性和标准库来编写测试
- 显式优于隐式:没有 before/after 钩子(如 JUnit 的
@BeforeEach),没有隐式的测试套件。每个测试函数独立、完整、自包含 - 快速反馈:
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.B 的 N 字段由框架自动调整,以确保测试运行时间足够长以获得可靠的统计结果。
运行基准测试:
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% 覆盖率可能带来以下问题:
- 测试代码膨胀:为了覆盖 trivial 代码而编写无价值的测试
- 虚假安全感:高覆盖率不等于正确性。测试可能通过了但逻辑仍然错误
- 维护成本:每个代码变更都要更新大量脆弱的测试
- 忽视集成测试:过度关注单元覆盖率可能忽略跨组件集成的测试
推荐的覆盖率策略:
- 核心业务逻辑:优先覆盖,目标 80%+
- 错误处理路径:特别关注边界条件和异常分支,这些往往是生产 bug 的出处
- 工具/基础设施代码:适度覆盖,通常不需要极端高
- 第三方封装/胶水代码:重点测试与外部系统交互的逻辑
- 自动生成代码(如 protobuf):可以排除在覆盖率统计之外
排除不计入覆盖率的代码
Go 1.20+ 支持通过 // coverage:ignore 类似的方式标记,但标准做法是在测试时使用 -coverpkg 控制统计范围,或通过 .codecov.yml 等配置排除文件:
# 只统计特定包的覆盖
go test -coverpkg=./pkg/... -coverprofile=coverage.out ./...
Mock 的必要性与边界
Mock 是单元测试中隔离被测单元的核心技术。但 Mock 不是万能药,了解什么时候 mock、什么时候用真实依赖,是测试设计的关键决策。
什么时候使用 Mock
适合使用 Mock 的场景:
- 外部依赖不可控:数据库、API 服务、消息队列等可能不可用或状态不确定
- 测试边缘条件:需要模拟超时、网络断开、资源耗尽等难以在实际环境中复现的场景
- 测试速度:真实依赖(如数据库)的初始化可能很慢,Mock 可以大幅缩短测试反馈周期
- 并发测试:需要精确控制并发事件的顺序
- 开发早期:下游服务尚未就绪时,用 Mock 定义接口契约
什么时候不用 Mock(集成测试)
不适合使用 Mock 的场景:
- 测试 SQL 语句正确性:Mock 数据库无法验证 SQL 语法是否正确、查询是否高效
- 测试序列化/反序列化:应该用真实的数据格式
- 测试 ORM 框架使用:Mock ORM 失去了其测试意义
- 验收测试/端到端测试:需要验证整个系统的行为
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 的局限性
- 需要手写 Mock 实现:每个接口都需要写一个 Mock 结构体,样板代码较多
- 依赖方法名字符串:
On("SendEmail", ...)是字符串,没有编译时检查,重命名方法后测试仍可编译但会失败 - 缺少事后期望设置:
Mock.On通常在执行前调用,不如 gomock 灵活 - 无代码生成:无法自动生成 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/mock | gomock |
|---|---|---|
| 代码生成 | 否 | 是(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 的毒瘤。检测和处理策略:
- 多次运行:使用
-count=N参数
go test -count=100 -run TestRaceCondition ./...
- 使用 gotestsum:更友好的测试输出和重试机制
gotestsum --rerun-fails=3 ./...
- 标记已知 Flaky:用
t.Skip暂时跳过,并创建跟踪 issue
func TestFlakyFeature(t *testing.T) {
t.Skip("TODO(#123): fix flaky test")
}
- 根因分析: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 集成实践。
关键要点回顾:
- 表驱动测试是 Go 的核心测试模式:它简洁、可扩展、易于维护,是每个 Go 开发者都应该掌握的惯用法
- 覆盖率是指导而非目标:合理的覆盖率策略关注核心业务路径和边界条件,而非追求数字上的完美
- Mock 只在必要时使用:优先尝试用真实依赖(如内存数据库)测试;Mock 适合测试外部服务交互、并发控制和异常路径
- testify/mock 与 gomock 各有所长:简单项目用 testify/mock,大型项目用 gomock 的代码生成器
- 测试是质量工程的一部分:将测试纳入 CI 门禁(覆盖率门槛、竞态检测、Flaky Test 管理),让质量内建于开发流程中
测试不是开发结束后的任务,而是贯穿整个开发过程的活动。Go 语言通过其简洁的测试框架设计,降低了编写测试的认知负担,使得开发者可以更自然地将测试融入日常开发。希望本文的内容能帮助你在 Go 项目中建立起高质量的测试体系,为代码重构和业务演进提供坚实的安全保障。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。