刚写 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 等结果,异步调度和实际逻辑可能还绑在一起。
不需要为了测试把每一层都抽象一遍。先从最常变化、最不稳定的边界开始:数据库、网络、时间、随机数和全局状态。让核心业务能够在内存里快速跑起来,剩下真正需要验证基础设施的部分,再交给集成测试。