Go 项目难测试,问题通常出在代码结构

刚写 Go 的时候,我也会把"测试难写"归结到工具上:是不是 Mock 库不够顺手,断言库不够强,或者测试框架功能太少。

写过一些业务项目后再回头看,真正让测试变麻烦的,往往不是 testing 包。更常见的原因是业务代码和数据库、Redis、时间、网络、全局变量绑得太紧。一个函数明明只想验证价格计算,运行它却要先准备数据库、缓存和配置;测试自然很难写,也很难跑得稳定。

测试是否好写,其实能反过来说明代码的边界是否清楚。下面是我在开发 Go 项目时会特别留意的几个地方。

不要在业务代码里创建依赖

如果 Service 自己创建数据库或 Redis 客户端,测试时就很难把它们换掉:

go 复制代码
func CreateOrder() error {
	db := gorm.Open(...)
	redisClient := redis.NewClient(...)
	// ...
	return nil
}

更合适的做法是让 Service 只声明自己需要什么,由外部把具体实现传进来:

go 复制代码
type OrderRepository interface {
	Create(ctx context.Context, order *Order) error
	FindByID(ctx context.Context, id int64) (*Order, error)
}

type OrderService struct {
	repo OrderRepository
}

func NewOrderService(repo OrderRepository) *OrderService {
	return &OrderService{repo: repo}
}

生产环境可以传入 GORM 实现,测试时则传一个很轻的 Fake:

go 复制代码
type FakeOrderRepository struct {
	CreateFunc func(ctx context.Context, order *Order) error
}

func (f *FakeOrderRepository) Create(
	ctx context.Context,
	order *Order,
) error {
	return f.CreateFunc(ctx, order)
}

Wire 之类的工具可以帮忙组装依赖,但业务代码不应该知道 Wire 的存在。Service 只关心接口,不关心对象最后是手动创建的,还是由工具生成的。

接口写在使用它的地方

有些项目会先给 Repository 定义一个很大的接口:

go 复制代码
type UserRepository interface {
	Create()
	Update()
	Delete()
	Find()
	List()
	Count()
	Export()
	Import()
}

如果某个 Service 只需要查询用户,测试替身却要把其余方法全部补齐。这些空实现没有提供价值,只是在增加维护成本。

Go 的接口由实现方隐式满足,所以通常可以在使用方定义一个更小的接口:

go 复制代码
type UserFinder interface {
	FindByID(ctx context.Context, id int64) (*User, error)
}

接口越贴近实际用途,依赖关系越容易看懂,Fake 也越容易写。这里的重点并不是把每个方法都拆成一个接口,而是别让调用方依赖自己根本用不到的能力。

少用全局状态

下面这种写法在小项目里很方便:

go 复制代码
var DB *gorm.DB
var RedisClient *redis.Client
var Config *Config

项目一大,问题就会逐渐出现。一个测试替换了全局变量,另一个测试可能正好也在使用它;测试顺序会影响结果,t.Parallel() 也不敢随便开。即使每次都记得恢复,测试之间仍然共享了一块可变状态。

把依赖放进结构体,调用关系会明确很多:

go 复制代码
type UserService struct {
	repo  UserRepository
	cache UserCache
}

func NewUserService(
	repo UserRepository,
	cache UserCache,
) *UserService {
	return &UserService{
		repo:  repo,
		cache: cache,
	}
}

这会多写一点构造代码,但换来的是每个测试都能拥有一套独立依赖,不用猜某个全局对象之前被谁改过。

时间、随机数和 ID 也算依赖

数据库和网络比较显眼,time.Now()uuid.New() 这类调用却很容易藏进业务代码里。它们同样会让结果变得不可预测。

例如,直接在函数里读取当前时间:

go 复制代码
func CreateUser() User {
	return User{CreatedAt: time.Now()}
}

测试只能判断时间是否落在某个范围内。这样的断言既啰嗦,也可能在运行较慢时偶尔失败。可以给业务代码传入一个时钟:

go 复制代码
type Clock interface {
	Now() time.Time
}

type UserService struct {
	clock Clock
}

func (s *UserService) CreateUser() User {
	return User{CreatedAt: s.clock.Now()}
}

测试使用固定时间即可:

go 复制代码
type FakeClock struct {
	Time time.Time
}

func (f FakeClock) Now() time.Time {
	return f.Time
}

订单号、验证码和 UUID 也可以用同样的方法处理。并不是所有 time.Now() 都值得抽象,但只要某个值会影响业务判断或测试断言,就应该考虑让它可替换。

异步代码不要靠 Sleep 碰运气

测试异步任务时,最省事的写法通常是先等一会儿:

go 复制代码
go service.Run()
time.Sleep(time.Second)

if result != expected {
	t.Fatal("failed")
}

这个测试在自己的电脑上可能一直正常,到了负载更高的 CI 环境却偶尔失败。把等待时间调大可以降低失败概率,但也会拖慢整套测试。

如果确实需要验证 goroutine,应该使用 channel、WaitGroup 等同步机制,并且设置超时:

go 复制代码
done := make(chan struct{})

go func() {
	service.Run()
	close(done)
}()

select {
case <-done:
	// 断言结果
case <-time.After(time.Second):
	t.Fatal("timeout")
}

不过很多时候,真正值得测试的是异步任务里的业务逻辑,而不是"是否成功启动了一个 goroutine"。可以把两者分开:

go 复制代码
func (s *Service) HandleTask(ctx context.Context) error {
	// 同步执行的业务逻辑
	return nil
}

func (s *Service) RunAsync(ctx context.Context) {
	go s.HandleTask(ctx)
}

主要覆盖 HandleTask,异步调度只保留少量集成测试,测试会简单不少。

把计算和副作用分开

一个函数如果同时校验参数、查商品、计算价格、扣库存、写缓存、发消息和发短信,测试就必须一次处理所有分支。问题不在于函数行数多,而在于多个职责被锁在了一起。

我更喜欢把流程整理成三段:

text 复制代码
获取数据
  ↓
执行业务计算
  ↓
保存结果并通知外部系统

中间的业务计算尽量写成只依赖输入和输出的函数:

go 复制代码
func CalculateDiscount(amount int64, level int) int64 {
	if level >= 5 {
		return amount * 90 / 100
	}
	return amount
}

它的测试不需要 Mock:

go 复制代码
func TestCalculateDiscount(t *testing.T) {
	got := CalculateDiscount(10000, 5)

	if got != 9000 {
		t.Fatalf("got %d, want %d", got, 9000)
	}
}

数据库、Redis、HTTP 和消息队列都属于副作用,留在流程边缘会更好处理。这样不是为了追求"纯函数含量",而是避免把关键业务规则埋进一串 GORM 调用里。

同样,HTTP Handler 最好只负责解析请求、调用 Service、转换错误和返回响应。Handler 里如果直接查询数据库并处理业务状态,接口测试很快就会变成一套笨重的端到端测试。

错误、Context 和事务也要有边界

业务代码经常需要根据错误类型决定后续行为。依赖错误字符串会让这种判断很脆弱:

go 复制代码
if err.Error() == "user not found" {
	// ...
}

文案稍微调整,调用方和测试都可能失效。可以定义明确的错误值,再通过 %w 保留错误链:

go 复制代码
var ErrUserNotFound = errors.New("user not found")

return fmt.Errorf("find user %d: %w", id, ErrUserNotFound)

调用方用 errors.Is 判断;需要携带余额、订单号等额外数据时,再定义错误类型并使用 errors.As

访问数据库、缓存和网络的方法也应该接收 context.Context

go 复制代码
func (r *UserRepo) FindByID(
	ctx context.Context,
	id int64,
) (*User, error)

除了在生产环境中传递超时和取消信号,这也让测试可以验证请求取消后,操作是否真的停止。

事务则要避免和具体 ORM 绑定得太深。Service 直接调用 *gorm.DB.Transaction,等于让业务层知道了基础设施的实现细节。可以把事务收窄成业务真正需要的能力:

go 复制代码
type TransactionManager interface {
	WithinTransaction(
		ctx context.Context,
		fn func(ctx context.Context) error,
	) error
}

Service 只描述哪些操作需要处于同一个事务里,GORM 的开启、提交和回滚留给基础设施层处理。测试时则可以用一个立即执行回调的实现,专注验证业务流程。

最后

我现在更愿意把测试当成代码结构的一次检查。如果测试必须启动很多外部服务,往往说明业务逻辑和基础设施还没有分开;如果一个 Fake 要实现十几个无关方法,接口可能定义得太大;如果测试只能用 Sleep 等结果,异步调度和实际逻辑可能还绑在一起。

不需要为了测试把每一层都抽象一遍。先从最常变化、最不稳定的边界开始:数据库、网络、时间、随机数和全局状态。让核心业务能够在内存里快速跑起来,剩下真正需要验证基础设施的部分,再交给集成测试。

相关推荐
妙码生花1 小时前
从 PHP 到 AI + Golang,程序员自救转型手记(三十五):控制台静态页面、深入研究时间格式化方案
后端·go·ai编程
妙码生花1 天前
从 PHP 到 AI + Golang,程序员自救转型手记(三十四):后台初始化请求实现
前端·javascript·go
妙码生花1 天前
从 PHP 到 AI + Golang,程序员自救转型手记(三十三):权限规则管理器
后端·go·gin
博观而约取厚积而薄发1 天前
Pytest 从入门到精通,一篇就够(超详细实战教程)
python·测试工具·单元测试·自动化·pytest
我才是银古1 天前
从零构建 Windows 桌面 RPA 框架:Go 与 .NET 的跨语言协奏
c#·go·rpa
gis开发之家1 天前
《Vue3 从入门到大神29篇》单元测试与 E2E 测试 —— 保障 Vue3 项目的质量
前端·javascript·vue.js·单元测试·前端框架·vue3
ArixBit1 天前
用Go手撸 Agent 框架#8:手写一个最小 Agent,先让 loop 跑起来
go·agent
ysu_03142 天前
06 | 200个单元测试C项目也能TDD
c语言·单元测试·tdd