接口何时冗余:一种用于 Go 测试的精简依赖注入方法

0 阅读3分钟

为 Go 测试注入简单依赖的实用模式,避免不必要的样板代码。

如果你写过 Go 的单元测试,你可能遇到过一些看似微不足道的外部依赖——比如当前时间——导致一个简单的函数变得难以测试。

通常的建议是把这个依赖隐藏在一个接口后面。这种做法很多时候是正确的,但对于单次调用的依赖来说,可能会感觉过于繁重。

本文对比了两种实用的方法,并展示了一个小巧、符合 Go 语言习惯的模式,这个模式也出现在标准库中。


问题:测试 time.Now()

假设你想检查一个折扣码是否仍然有效:

package store

import "time"

func IsDiscountActive(expiryDate time.Time) bool {
 return time.Now().Before(expiryDate)
}

这个函数很难进行确定性测试,因为结果取决于测试运行的时刻。你需要一个可控的 "当前时间" 概念。替代方案包括传递一个时间值、注入一个时钟,或者在更复杂的情况下使用像 github.com/benbjohnson/clock 这样的小型库。


接口方法(适用于多方法协作对象)

type Clock interface {
 Now() time.Time
}

type RealClock struct{}

func (RealClock) Now() time.Time {
 return time.Now()
}

type Service struct {
 clock Clock
}

func (s *Service) IsDiscountActive(expiryDate time.Time) bool {
 return s.clock.Now().Before(expiryDate)
}

这种方法可行,并且当你的依赖是一个拥有多个方法或行为不断演变的真正协作对象时,它具有很好的扩展性。但缺点是,对于一个简单的函数调用来说,它增加了额外的样板代码。


一个更轻量的选项:函数字段注入

type Service struct {
 now func() time.Time
}

func NewService() *Service {
 return &Service{
  now: time.Now,
 }
}

func (s *Service) IsDiscountActive(expiryDate time.Time) bool {
 return s.now().Before(expiryDate)
}

func TestIsDiscountActive(t *testing.T) {
 service := NewService()
 fakeNow := time.Date(202582418000, time.UTC)
 service.now = func() time.Time {
  return fakeNow
 }
 if !service.IsDiscountActive(fakeNow.Add(1 * time.Hour)) {
  t.Error("expected active")
 }
 if service.IsDiscountActive(fakeNow.Add(-1 * time.Hour)) {
  t.Error("expected expired")
 }
}

这种方法将依赖关系保持得非常 "精准"。

一个小改进是通过默认值使其零值(zero value)可用:

type ZeroService struct {
 now func() time.Time
}

func (s *ZeroService) IsDiscountActive(expiryDate time.Time) bool {
 nowFn := time.Now
 if s.now != nil {
  nowFn = s.now
 }
 return nowFn().Before(expiryDate)
}

有了这个变体,你可以直接构造 &ZeroService{};测试时可以设置 s.now。

注意:只在设置阶段修改函数字段。如果实例在多个 goroutine 之间共享,后续修改它们可能会导致数据竞争(data races)。将注入的函数视为构造后即不可变。


为什么这是符合语言习惯的:标准库也这样做

Go 的标准库中有几个类型暴露了可注入的函数字段,并带有定义好的 nil 默认值。一个突出的例子是 net/http.Transport.DialContext——如果为 nil,传输器将使用 net 包的默认拨号函数。这让测试可以注入行为,而无需使用复杂的抽象。

type Transport struct {
 // DialContext specifies the dial function for creating unencrypted TCP connections.
 // If DialContext is nil (and the deprecated Dial below is also nil),
 // then the transport dials using package net.
 DialContext func(ctx context.Context, network, addr string) (net.Conn, error)
}

标准库中的测试使用这个 "接缝(seam)" 来实现取消、失败和自定义拨号。如果你的测试文件与源文件在同一个包中(而不是 package foo_test),它们还可以访问未导出的标识符,这有助于保持注入字段的未导出状态。


经验法则

  • 当依赖是一个多方法协作对象或可能增长时,使用接口。
  • 当依赖是一个单次调用(例如 time.Now、os.ReadFile)时,使用函数字段。
  • 优先考虑零值可用性;并做好文档说明。
  • 避免在构造后修改注入的函数;这容易引发数据竞争。

最后一些想法:这种函数字段技术并不是要打破规则;它只是为了选择合适的工具来完成工作。它通过简化测试和减少样板代码为我提供了很大的帮助,我希望它对你也有用。


References
pkg.go.dev/net/http
go.dev/src/net/htt…
pkg.go.dev/testing
pkg.go.dev/github.com/…

推荐阅读