如何用 Go 快速编写单元测试
摄影:Veri Ivanova
我们都喜欢单元测试,因为它能帮助我们保持软件的可运行性。但我们又都讨厌单元测试,因为它不会凭空出现——需要有人来编写。而且,编写单元测试往往需要花费大量时间才能涵盖最简单的情况。
但我找到了一种不疼(好吧,疼得更少)的方法。我会像一份简单的图文指南一样与你们分享。
分离层
这个原则并不新鲜,但仍然很有用。如今它有不同的名称——六边形架构、洋葱架构、关注点分离等等。其核心思想是,应用程序的不同部分(我指的是逻辑上不同的部分)应该被分离成独立的部分。
这非常重要,因为你无法测试你正在构建的整个应用。好吧,技术上可以。但这将耗费大量的时间,而且从长远来看,这将是一场噩梦。
相反,要让它尽可能独立。但应用程序,或者至少是微服务,不可能独立于自身!它可以,我们称之为依赖注入。而且在 Go 中,这从未像现在这样简单,因为我们……
鸭子类型接口
这意味着,如果某个类型具有接口中描述的方法,我们就可以通过该接口使用它。因此,无需在项目开始时进行大量的书面工作,也无需绘制包含所有可能的交互和关系的庞大 UML —— 只需为您的层编写一个包含其最低要求的接口,并将依赖项传递给它即可。
例如:您有一个业务逻辑包,需要将一些数据存储到数据库中。无需在此包中创建或获取数据库连接,只需定义一个
Repository
接口Storage
(或任何更合适的名称),并将您想要对数据库执行的所有操作(保存、更新、读取、删除、增加计数器等)放入其中即可。然后,您只需放置可以执行这些操作的数据库对象即可,它将包含精确的数据库查询语言代码和特定于数据库的逻辑。您还需要在业务逻辑层之外处理数据库连接和可能的连接错误。该层将独立于数据库层。
注入非托管资源
有很多非托管资源,例如随机数生成器、时间、基于随机的哈希值等等。它们不需要外部注入,因为它们没有外部依赖。但它们仍然会对测试结果造成不可预测的影响。因此,与其在测试中绕过它们,不如使用相同的方法——将它们作为独立的依赖项注入。但由于它们不是从外部提供的,所以从内部注入!
例子:
type Example struct {}
func NewExample() *Example {
...
return &Example{}
}
func (e *Example) TimeToGo() string {
now := time.Now()
return fmt.Sprintf("its time to go! %s", now.String())
}
这里你无法预测TimeToGo
测试中方法的响应——每次time.Now()
都会返回一个新值。但你可以掌控它:
type Example struct {
now func() time.Time
}
func NewExample() *Example {
...
return &Example{time.Now}
}
func (e *Example) TimeToGo() string {
return fmt.Sprintf("its time to go! %s", e.now().String())
}
会像以前一样工作,但time.Now
现在由你掌控!你可以在测试中轻松模拟它:
nowMock := func() time.Time {
t, _ := time.Parse(time.Kitchen, time.Kitchen)
return t
}
e := Example{nowMock}
if e.TimeToGo() != "its time to go! 0000-01-01 15:04:00 +0000 UTC" {
t.Errorf("test failed")
}
因此,请尽量避免在逻辑实体中引入任何依赖关系。即使是像当前时间或随机值这样小的依赖关系,也可以将它们隐藏在接口或函数类型下。
80/20
100% 的覆盖率是所有开源开发者的梦想。看到项目 readme 里有个绿色的徽章真是太好了!但在高效的项目中,情况就不一样了。
通常情况下,你没有足够的时间或资源来实现 100% 的测试覆盖率。即使你做到了,在实际开发阶段,你也会多次修改逻辑,因此测试变更的数量将会非常庞大。
但实际上,80/20 原则在这里也适用:对“最热门”或最重要的代码的 20% 覆盖率将覆盖应用程序 80% 的使用和数据流。这意味着,从“最热门”的代码覆盖率开始。如果您有时间和动力,可以针对不太重要的部分编写测试,然后慢慢提高覆盖率。
例如,如果您正在构建一个网络搜索服务,请先测试搜索引擎。如果它确实按预期运行,您可以接下来添加自动完成、翻译和实时预览。但如果没有可靠的核心功能,您就会失败。
不要写你的代码
现在,你已经准备好了应用程序中一个隔离良好且非常重要的部分。你需要将测试融入到这个框架中。幸运的是,你的应用使用的是接口,而不是具体的实现。这意味着,我们可以在测试中模拟它们!
但编写模拟代码就像挖盐矿一样费劲。我们没必要抢走机器人的工作——让它们做它们该做的事情吧!
所以我不写测试模拟,而是生成它们。为此,我使用 Mockery。
假设我们有一个数据库接口:
package repo
type Storage interface {
GetOrder(id string) (*Order, error)
CreateOrder(order Order) error
DeleteOrder(id string) error
}
以及使用它的代码:
type AccountManager struct {
storage repo.Storage
}
我们可以通过调用以下命令来生成模拟:
# if the interface is in the internal/repo package
mockery -name=Storage -dir=internal/repo
它会生成一个完美适配你接口的模拟文件!它将存储在/mocks
接口包目录对应的目录中(你可以通过指定-output
参数来更改它)。之后你只需要在测试中使用它即可:
var testOrder repo.Order
//...
storageMock := new(mocks.Storage)
storageMock.On("GetOrder", "12345").
Return(&testOrder, nil)
// and then inject it into your code under the test
am := AccountManager{storageMock}
// execute test cases with mocked dependency ...
我通常使用 go-generate 来运行模拟生成:
//go:generate mockery -name=Storage
type Storage interface { ... }
然后运行go generate ./...
。或者我只是将它们作为列表放入Makefile
带有绝对路径的文件中。或者,您甚至可以递归地为目录中所有导出的接口生成模拟。有关更多信息,请参阅库详细信息。
使用快捷方式
编写测试用例是一项艰苦而枯燥的工作。你必须准备大量的数据样本,进行繁琐的配置环境,准备 HTTP 请求和响应编写器、模拟服务器、存根数据等基础设施。而且,编写每个获取到的数据和所需的检查以及相应的错误信息也非常繁琐。
但是你可以使用快捷方式来节省一些时间。我指的是像 Testify 这样的测试库,它可以将测试中重复的部分包装到简洁易用的辅助函数中!
假设您有一个常见的 HTTP 响应,如下所示:
w := httptest.NewRecorder()
//... function under test call ...
// assertion
gotBody, _ := ioutil.ReadAll(rr.Body)
gotStatus := rr.Result().StatusCode
if string(gotBody) != tt.bodyWant {
t.Errorf("wrong response body %s want %s", string(gotBody), tt.bodyWant)
}
if gotStatus != tt.statusWant {
t.Errorf("wrong respose status %d want %d", gotStatus, tt.statusWant)
}
很简单吧?但当你需要重复100多次时,就会变得有点无聊了……你不用等到无聊透顶!只需使用快捷方式:
import "github.com/stretchr/testify/assert"
w := httptest.NewRecorder()
//... function under test call ...
// assertion
gotBody, _ := ioutil.ReadAll(rr.Body)
gotStatus := rr.Result().StatusCode
assert.Equal(t, string(gotBody), tt.bodyWant, "wrong response body")
assert.Equal(t, gotStatus, tt.statusWant,. "wrong response status")
它可能以更有趣的方式使用。假设你有一个方法,它返回一个指针和一个错误(Go 中的常见模式)。所以你不想在发生错误时断言返回值,因为这会导致 nil 指针被解引用。所以你必须构建一个混乱的条件语句,包含 nil 检查和错误检查等等……你不必:
want := "we want to get this exact value"
got, err := GiveMeMyData(input)
// if error is nill, NotNil will return true
// otherwise it will write an error message to testing.T
if assert.NotNil(t, err, "unexpected function error") {
// here we assert the expected value
// but only if the error is nil!
assert.Equal(t, got, want, "unexpected function output)
}
您可以通过传递断言结构使其变得更短:testing.T
assert := assert.New(t)
/// now you don't have to pass t to each function, 500 milliseconds saved!
assert.Equal(123, 123, "they should be equal")
它可能看起来很简单,但相信我,它能让你在单元测试期间节省足够的时间喝一杯咖啡。甚至可以煮一杯咖啡!
让测试有意义
我喜欢测试,但当测试输出时,很难追踪正在发生的事情、哪个测试失败了以及失败的原因。当你有 10 或 20 个测试用例时还好,但如果你有数百个测试(或者至少是测试集,例如,如果你使用表格测试),仅凭测试输出就很难理解哪里出了问题。
为了使其更具可读性,您必须提供适当的描述。但是您真的希望您的测试用例看起来像这样吗?
if got != want {
t.Errorf("by calling a function A when there is no data in the DB table a_data and at the same time there is no incoming messages from the MQ and no idle workers in the worker pool, it returns %s but we want %s", got, want)
}
我希望你不会。否则请停止阅读。因为这是我最喜欢的部分。
为了提高测试输出的可读性并使其易于导航,可以使用BDD方法进行测试。我使用它有很多原因:
- 它有助于将测试构建为一系列步骤或一个故事;
- 阅读测试输出很不错,因为它完整地记录了测试失败的情况;
- 您可以将测试用例构建为一棵树,从根部常见输入到不同的结果;
- 可以跳出单元测试的框架,为真实的业务场景编写多步骤(或不多步骤)测试。因此,与其测试应用程序的抽象部分,不如直接覆盖实际业务流程,就像在现实生活中一样!这也是优先关注真正必要的测试的好方法,因为您可以打开规范并为其编写测试!
那么,让我们仔细看看。对于这种测试,我使用了Ginkgo库。它与Gomega测试匹配器库配对,其中包含大量测试辅助函数和包装器(更多快捷方式!)。
我本身并不喜欢 BDD,但我很喜欢在测试中使用这种方法。所以,我来给你看几个例子。
您需要设置一个测试套件。通常我会在一个包中执行一次。
上面的例子可能看起来像这样:
import (
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)
var _ = Describe("Function A", func() {
When("I call the function", func() {
Context("and there is no data in the DB table a_data", func() {
Context("and there is no incoming messages from the MQ", func() {
Context("and no idle workers in the worker pool", func() {
It("should return 123", func() {
got, _ := A("123")
Expect(got).To(Equal("123"))
})
It("should work without errors", func() {
_, err := A("123")
Expect(err).To(BeNil())
})
})
})
})
})
})
看起来更像是真实的规格,对吧?
错误输出将如下所示:
您还可以在任何步骤中添加一些数据,并且每次测试到达测试树的这个节点时都会添加这些数据。
伪代码示例:
Describe some test entity
├─With data X
│ ├─And with data B
│ │ └─And with data C
│ │ ├─It should do this
│ │ └─And this
│ └─But with data D
│ └─It should do this
└─With data Y
├─And with data E
│ └─It should do this
└─And with data E*
└─It should error
所以这是一个完整的故事,你可以用 Ginkgo 来描述它!例如,在这种情况下,该节点And with data B
将被执行 3 次:
- 使用数据 X ->使用数据 B -> 使用数据 C -> 它应该这样做
- 数据 X ->数据 B -> 数据 C -> 以及这个
- 对于数据 X ->对于数据 B -> 但是对于数据 D -> 它应该这样做
你可以用它BeforeEach()
来为每个步骤设置一些上下文(设置一些变量、函数、填充模拟等等)。你也可以用它来AftrrEach()
在节点结束时进行清理。
该库有许多其他有用的功能 -BeforeSuite
以及AfterSuite
许多变体来帮助您更好地组织测试。
因此这里的主要思想是将其用作测试过程的有意义的描述,以及每个步骤的测试顺序和上下文。
整合起来
那么如何快速做到这一点?让我们总结一下:
- 通过 DI 分离层;
- 使用接口来实现这一点;
- 从最热门的 20% 代码开始;
- 使用生成的模拟来模拟这些接口;
- 使用快捷方式来加速简单的测试;
- 使用 BDD 测试套件来描述和组织复杂的测试。
最后,它可能看起来像这样:
import (
"errors"
"temp"
"temp/mocks"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
"github.com/stretchr/testify/mock"
)
var _ = Describe("Function A", func() {
var (
storageMock *mocks.Storage
testOrder temp.Order
testErr error
)
BeforeEach(func() {
storageMock = new(mocks.Storage)
testOrder = temp.Order("my test order")
testErr = errors.New("test error")
})
When("we want to get some order", func() {
Context("and the order exists", func() {
BeforeEach(func() {
storageMock.On("GetOrder", "12345").
Return(&testOrder, nil)
})
It("should return my test order without error", func() {
sk := temp.NewStorekeeper(storageMock)
got, err := sk.GetMyOrder("12345")
Expect(*got).To(BeEquivalentTo("my test order"))
Expect(err).To(BeNil())
})
})
Context("and there is no order", func() {
BeforeEach(func() {
storageMock.On("GetOrder", mock.Anything).
Return(nil, testErr)
})
It("should return empty order with error", func() {
sk := temp.NewStorekeeper(storageMock)
got, err := sk.GetMyOrder("12345")
Expect(got).To(BeNil())
Expect(err).To(HaveOccurred())
})
})
})
})
写入速度非常快。而且 CI 日志也非常容易读取!
链接地址:https://dev.to/ilyakaznacheev/how-i-write-my-unit-tests-in-go-quickly-4bd5