SafetyCulture的代码库由许多微服务组成,其中许多是使用Go编写的。这些微服务与其他微服务通信,并且可能连接到一个或多个数据存储。
来源: https://github.com/marcusolsson/gophers/blob/master/gopherdata-gopher.png
Mocking是一种重要的工具,可以帮助我们在不使用真实的[集成]客户端的情况下测试代码路径。
在Go(和其他语言)中,使用接口实现模拟是一个非常普遍的模式。
TL;DR
包应返回具体(通常是指针或结构体)类型:这样,可以在不需要进行大量重构的情况下添加新方法来实现。
我们仍然利用Go的隐式接口来帮助测试/模拟,而且可以在不使用测试/模拟框架的情况下完成所有操作。
关键要点:
- 避免使用大型接口
- 仅使用所需的接口/方法
- 使用“nil”和“empty”结构来模拟你的正常路径
- 使用函数属性自定义模拟
反模式
在我们的代码库中,很常见看到以下代码:
type Store interface {
ListUsers() ([]*User, error)
ListTeamsByUser(userID int) ([]*Team, error)
ListAddressesByUser(userID int) (*[]Address, error)
// ...etc GetUser(id int) (*User, error)
GetTeam(id int) (*Team, error)
// ...etc CreateUser(user *User) (int, error)
CreateTeam(userID int, team *Team) (int, error)
// ...etc SetUserDefaultAddress(userID int, addressID int) error
// ...etc
}type store struct {
db sql.DB
}func NewStore(conn string) Store {
db := initDB(conn)
return &store{db:db}
}
上面所示的是一个具有大量方法和返回“Store”接口的构造函数的接口。
但我们知道这是一种反模式(稍后我们将解决此问题):
不要在API的实现方定义“用于模拟”的接口;相反,设计API以便可以使用实现的公共API进行测试。
好的
暂时忽略它是反模式;乍一看,上述模式似乎很有道理,我们可以通过传递相同的接口轻松地在需要时使用我们的“Store”接口:
SomeHandler(ctx context.Context, store db.Store, userID int) error {
user, err := store.GetUser(userID)
if err != nil {
return err
}
// ...
team, err := store.GetTeam(user.DefaultTeam)
if err != nil {
return err
}
// ...
}SomeOtherHandler(ctx context.Context, store db.Store, firstname, lastname string) error {
user := User{Firstname: firstname, Lastname: lastname}
if err := store.CreateUser(&user); err != nil {
return err
}
// ...
}
不好的
这种“方便”是有代价的;隐藏的问题是,为了测试上述两个函数,我们需要模拟整个“Store”接口,即使我们只使用子集的函数(一些_方法体由于篇幅而被忽略_):
type storeMock struct {
getUser *User
getUserErr error
getTeam *Team
getTeamErr error
// ...
}func (m *storeMock) ListUsers() ([]*User, error) {}
func (m *storeMock) ListTeamsByUser(userID int) ([]*Team, error) {}
func (m *storeMock) ListAddressesByUser(userID int) (*[]Address, error) {}func (m *storeMock) GetUser(id int) (*User, error) {
return m.getUser, m.getUserErr
}func (m *storeMock) GetTeam(id int) (*Team, error) {
return m.getTeam, m.getTeamErr
}func (m *storeMock) CreateUser(user *User) (int, error) {}
func (m *storeMock) CreateTeam(userID int, team *Team) (int, error) {}
func (m *storeMock) SetUserDefaultAddress(userID int, addressID int) error {}
为每个要测试的方法创建一个模拟将非常痛苦且难以维护,因此我们倾向于为所有测试使用单个模拟结构。
每次添加新的接口方法时,我们都需要更新我们的模拟甚至可能不得不更新我们的测试。
丑陋的
我们的许多团队都转向自动生成这些模拟,这些模拟与测试框架配合使用。
这解决了维护自己的测试模拟的问题,但仍可能导致“难以维护”的测试。
让我们通过一个示例来逐步了解我们可能会遇到的一些问题:
// handler.go
SomeHandler(ctx context.Context, store db.Store, userID int) error {
user, err := store.GetUser(userID)
if err != nil {
return err
}
// ...
team, err := store.GetTeam(user.DefaultTeam)
if err != nil {
return err
}
// ...
}// handler_test.go
func TestSomeHandler(t *testing.T) {
tests := [...]struct {
name string
store func() db.Store
shouldErr bool
}{
{
"HappyPath",
func() db.Store {
mock := &dbmock.Store{}
mock.On("GetUser", mock.Anything).Return(&db.User{}, nil)
mock.On("GetTeam", mock.Anything).Return(&db.Team{}, nil)
return mock
},
false,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T){
err := SomeHandler(context.Background(), tt.store(), 0)
if tt.shouldErr && err == nil {
t.Error("expected error but got <nil>")
}
})
}
上面的测试是使用模拟的典型示例(简化版)。在此示例中,我们仅测试跳过错误路径的“Happy Path”,但我们可以添加更多测试,并使用类似的模式,通过模拟模拟的输入/输出测试所有代码路径。
问题一
我们向接口添加一个方法:
type Store interface {
ListUsers() ([]*User, error)
ListTeamsByUser(userID int) ([]*Team, error)
// ...etc AddUserToTeam(teamID, userID int) error
}
运行我们的测试将失败,并显示类似于以下错误:
FAIL: cannot use *dbmock.Store as db.Store; missing method AddUserToTeam(teamID, userID int) error
如果我们自动生成了模拟,这很容易解决,我们只需要重新生成即可,但是如果我们手动创建模拟,则需要添加此函数。
问题二
我们现在需要将此方法添加到SomeHandler
的函数体中:
SomeHandler(ctx context.Context, store db.Store, userID int) error {
user, err := store.GetUser(userID)
// ... err := store.AddUserToTeam(ateamID, userID)
if err != nil {
return err
} team, err := store.GetTeam(user.DefaultTeam)
// ...
}
再次运行我们的测试,我们将获得类似于以下错误的内容:
mock: I don't know what to return because the method call was unexpected.
Either do Mock.On("AddUserToTeam").Return(...) first, or remove the AddUserToTeam() call.
This method was unexpected
我们无法删除调用,因此我想我们需要在我们的测试中模拟该方法。但是这里的问题是:我们必须更新到达该函数的所有代码路径的所有测试,如果你有大量测试(应该有),则这变得重复:
{
"HappyPath",
func() db.Store {
mock := &dbmock.Store{}
mock.On("GetUser", mock.Anything).Return(&db.User{}, nil)
mock.On("AddUserToTeam", mock.Anything, mock.Anything).Return(nil)
mock.On("GetTeam", mock.Anything).Return(&db.Team{}, nil)
return mock
},
false,
},
问题三
如果我们重构了我们的“Store”接口会发生什么?由于静态类型和编译器,Go中的重构通常很简单(非常牵强的例子,但你应该明白):
type Store interface {
// ...etc --- GetTeam(id int) (*Team, error)
+++ GetSquad(id int) (*Team, error) // ...etc
}
这导致与以前相似的错误:
mock: I don't know what to return because the method call was unexpected.
Either do Mock.On("GetSquad").Return(...) first, or remove the GetSquad() call.
This method was unexpected
由于这个mocking framework使用字符串,因此我们无法利用Go重构工具,而必须查找/替换"GetTeam"
与新的方法签名。
真实实现
让我们首先删除大型的“Store”接口并返回我们存储的真实实现,以便我们可以开始“接受接口,返回结构”并摆脱我们的反模式:
type Store struct {
db sql.DB
}func NewStore(conn string) *Store {
db := initDB(conn)
return &store{db:db}
}
我们仍然需要使用接口来帮助模拟;接口应描述所需函数或方法的行为,从而减少行为和行为实现之间的耦合。
包之间的松散耦合最终导致更易于维护和阅读的代码。
因此,与其创建大型接口,我们创建仅包含所需函数的小型接口(1或2个方法),并在需要多个接口时组合接口。
type UserGetter interface {
GetUser(id int) (*db.User, error)
}type TeamGetter interface {
GetTeam(id int) (*db.Team, error)
}type UserTeamGetter interface {
UserGetter
TeamGetter
}type UserCreator interface {
CreateUser(user *db.User) (int, error)
}SomeHandler(ctx context.Context, store UserTeamGetter, userID int) error {
user, err := store.GetUser(userID)
if err != nil {
return err
}
// ...
team, err := store.GetTeam(user.DefaultTeam)
if err != nil {
return err
}
// ...
}SomeOtherHandler(ctx context.Context, store UserCreator, firstname, lastname string) error {
user := User{Firstname: firstname, Lastname: lastname}
if err := store.CreateUser(&user); err != nil {
return err
}
// ...
}
只使用它们需要的接口的功能使得测试更容易,而且我们不需要更改使用真实存储实现的调用程序代码。
store := db.NewStore(conn)
SomeHandler(ctx, store, userID)
正常路径
在许多测试用例中,我们需要通过模拟函数进行一般的正常路径,因此为了减少重复的代码,我们可以使“nil”结构(或空结构即&myMock{}
)成为快乐路径的模拟:
type userTeamMock struct {}
func (*userTeamMock) GetUser(id int) (*db.User, error) {
return &db.User{Firstname: "foo", Lastname: "bar"}, nil
}
func (*userTeamMock) GetTeam(id int) (*db.Team, error) {
return &db.Team{Name: "foobar"}, nil
}
func TestSomeHandler(t *testing.T) {
tests := [...]struct {
name string
store *userTeamMock
shouldErr bool
}{
{
"HappyPath",
nil,
false,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T){
err := SomeHandler(context.Background(), tt.store, 0)
if tt.shouldErr && err == nil {
t.Error("expected error but got <nil>")
}
})
}
模拟函数
创建模拟的原因是我们可以测试更多的代码分支而不仅仅是正常情况。我们可以使用函数属性自定义模拟来返回任何我们想要模拟的内容。
type userTeamMock struct {
getUserFn func(id int) (*db.User, error)
getTeamFn func(id int) (*db.Team, error)
}
func (m *userTeamMock) GetUser(id int) (*db.User, error) {
if m != nil && m.getUserFn != nil {
return m.getUserFn(id)
} return &db.User{Firstname: "foo", Lastname: "bar"}, nil
}
func (*userTeamMock) GetTeam(id int) (*db.Team, error) {
if m != nil && m.getTeamFn != nil {
return m.getTeamFn(id)
} return &db.Team{Name: "foobar"}, nil
}
现在使用我的模拟变得简单多了。如果userTeamMock
是nil
或empty
,我的正常情况将始终起作用,我们可以在需要的时候覆盖它:
{
"GetUserError",
&userTeamMock{
getUserFn: func GetUser(id int) (*db.User, error) {
return nil, errors.New("user error")
}
},
true,
},
{
"GetTeamError",
&userTeamMock{
getTeamFn: func GetTeam(id int) (*db.Team, error) {
return nil, errors.New("team error")
}
},
true,
},
{
"HappyPath",
nil,
false,
},
有了我们的新模拟,就不需要自动生成模拟或使用复杂的测试框架了,Go编程语言可以直接进行轻松的测试和模拟。
消除问题
现在我们返回具体的Store
指针类型,任何添加的新方法都不需要对我们的测试进行大规模的重构,因为它们只是使用它们需要的接口。 第一个问题得到解决。
如果我们在函数中添加一个需要的方法,我们将不得不将它添加到我们的接口中:
type UserTeamGetAdder interface {
UserTeamGetter
AddUserToTeam(teamID, userID int) error
}SomeHandler(ctx context.Context, store UserTeamGetAdder, userID int) error {
// ...
}
这将导致我们的测试失败,但Go编译器会指引你:
cannot use &(store literal) (value of type *userTeamMock) as UserTeamGetAdder value in argument to SomeHandler: missing method AddUserToTeam
我们只需要将方法添加到我们的模拟中,而不是重构所有的测试用例:
func (m *userTeamMock) AddUserToTeam(teamID, userID int) (*db.User, error) {
if m != nil && m.getUserFn != nil {
return m.addUserToTeamFn(teamID, userID)
} return &db.User{Firstname: "foo", Lastname: "bar"}, nil
}
我们的所有测试现在都会通过,而不需要改变任何测试代码,我们只需要添加更多的测试用例。 第二个问题得到解决。
我们还避免了任何魔法字符串,通过利用编译器错误(和其他Go工具,如gopls LSP)而不是运行时错误,使任何所需的重构变得容易。 第三个问题得到解决。
最后的思考
我们喜欢Go编程语言的一些原因是它易于学习、简洁、可维护和易于阅读。
但有时作为软件工程师,我们仍然会因为许多我们沿途继承的设计模式而使自己感到困难。
Go的简单性挑战我们以不同的方式思考,并质疑我们是否真的需要框架X或框架Y;也许Go提供的标准库就足够了?
“简单是复杂的”
- Rob Pike
译自:https://medium.com/safetycultureengineering/flexible-mocking-for-testing-in-go-f952869e34f5
评论(0)