跳转到主要内容

欢迎地鼠!在本文中,我们将介绍接受接口和返回结构的概念,以及这如何帮助改进您的代码,使其更具可测试性和可维护性。

概述


在编写 Go 应用程序时,我想记住的关键事项之一是“我怎样才能使这个特定的功能尽可能地可测试?”。

对于更复杂的应用程序,能够在我们的应用程序中使用所有不同的代码路径可能有点像噩梦,这取决于我们构建某些组件的方式。

在本文中,我将首先演示一种接受指向结构的指针的方法,以及这如何限制我们测试事物的能力。然后,我们将看看如何通过一些细微的更改来改进代码,这些更改应该使我们能够做一些花哨的事情,比如使用 mock 和 fake 对我们的代码进行单元测试。

传递指针


让我们首先看一个不遵循接受接口的包的标准示例,返回结构的咒语。

在这个例子中,我们将在我们的应用程序中定义一个用户包,它需要某种形式的数据库来获取用户,然后,在一些业务逻辑之后,它将能够持久化所做的任何更改。

package user

import (
    "github.com/TutorialEdge/path/to/db"
    "github.com/TutorialEdge/path/to/models"
)

type Service struct {
    Store *db.Database
}

// New - our constructor function that takes in a pointer to a database.
func New(db *db.Database) *Service {
    return &Service{
        Store: db,
    }
}

// GetUser - an example that checks to see if the user has access
func (s *Service) UpdateUserPreferences(ctx context.Context, userID string) (models.User, error) {
    u, err := s.Store.GetUser(ctx, userID)
    if err != nil {
        // handle the error
        return models.User{}, err
    }

    // potentially have some business logic in here that defines what
    // we are allowed to do to a User object

    // persist the changes via the Store interface
    err := s.Store.UpdateUser(ctx, u)
    if err != nil {
        // failed to persist the changes, we can then decide
        // how we want to handle this.
        return models.User{}, err
    }
}

在我们定义的构造函数 func 中,您会注意到我们接受了一个指向 db.Database 结构的指针,我们希望该结构将实现我们的用户包运行所需的方法。

我们还需要一个共享模型包或类似的东西,用户包和 db 包都可以导入,以便访问用户结构定义。这种方法有点必要,因为两个包都需要这个定义,但是如果 db 包试图导入用户包,编译代码时会出现循环依赖错误:

package models

type User struct {
    ID string
    Email string
}

测试限制


现在让我们考虑如何测试我们定义的 UpdateUserPreferences 方法。

好吧,首先,我们需要创建一个新的 *db.Database 结构。如果 db 包定义了与我们在上面的用户包中的构造函数类似的构造函数,这可能相当容易。

但是,让我们想想如果构造函数需要运行 Postgres 数据库才能成功运行会发生什么?

如果我们想测试我们的 UpdateUserPreferences 方法,我们必须确保本地运行的 Postgres 实例可用,并且我们已经设置了运行测试所需的所有必需的环境变量。

你可能会问,“这听起来不太糟糕?我可以测试两个包是否与一个测试一起工作” - 这句话当然是正确的,但是让我们考虑一下如果我们必须从我们的 db 包中综合错误响应,我们的方法会是什么样子。

这种方法增加了测试的复杂性,这最终会影响您彻底测试用户包中所有重要代码路径的能力。

定义接口


让我们看看如何改进上面的代码片段,使其松耦合且更易于测试。

首先,我们将尝试定义任何依赖项必须实现的接口,否则我们的应用程序将无法编译。虽然我们这样做了,但我们也可以将我们的 User 结构定义移动到这个包中:

type Store interface {
    GetUser(ctx context.Context, userID string) (User, error)
    UpdateUser(ctx context.Context, u User) (User, error)
}

type User struct {
    ID string
    Email string
}

接下来,我们需要更新包的构造函数:

func New(store Store) *Service {
    return &Service{
        Store: store,
    }
}


我们需要做的最后一件事(如果您的编辑器尚未为您完成此操作)是删除文件顶部的导入以拉入外部 db 包。

瞧!我们现在已经以一种非常微妙的方式修改了我们的包,但是这种方法释放了我们对这个包进行单元测试之类的能力,并确保无论我们的商店返回什么,我们都在适当地处理它。

让我们看看这个放在一起:

package user

type Store interface {
    GetUser(ctx context.Context, userID string) (User, error)
    UpdateUser(ctx context.Context, u User) (User, error)
}

type User struct {
    ID string
    Email string
}

type Service struct {
    Store Store
}

func New(store Store) *Service {
    return &Service{
        Store: store,
    }
}

// GetUser - an example that checks to see if the user has access
func (s *Service) UpdateUserPreferences(ctx context.Context, userID string) (User, error) {
    u, err := s.Store.GetUser(ctx, userID)
    if err != nil {
        // handle the error
        return User{}, err
    }

    // potentially have some business logic in here that defines what
    // we are allowed to do to a User object

    // persist the changes via the Store interface
    err := s.Store.UpdateUser(ctx, u)
    if err != nil {
        // failed to persist the changes, we can then decide
        // how we want to handle this.
        return User{}, err
    }
}

好处 - 松耦合


使用这种新方法,我们的用户包不再导入或不必关心第一个示例中使用的 db 包。现在所有这些代码都集中在能够更新用户首选项的业务逻辑上。

db 包仍然需要访问用户包才能了解它需要返回的数据的形状,但是我们已经删除了对我们之前需要的那个讨厌的模型包的需求。

顺便说一句 - 将用户结构定义本地化到实际使用它的代码有多好?厉害吧?

嘲笑和伪装


假设我们不想为了运行用户包中的代码而启动数据库。使用这种基于接口的方法,这可以通过使用模拟和伪造来实现。

我们可以使用 golang/mock 等工具来生成 Store 接口的模拟实现,然后在我们的测试中使用这些模拟。

func TestUpdateUserPreferences(t *testing.T) {
    ctrl := mock.NewCtrl()
    mockedStore := mocks.NewStoreMock(ctrl)

    t.Run("happy path - test user pereferences can be updated", func(t *testing.T) {
        // Note - this code is just pseudocode to demonstrate generally how this could be done
        mockedStore.Expect().ToBeCalled().ToReturn(User{ID: "1234", Email: "new@email.com"}, nil)

        // we can then use the created mock to instantiate our userSvc and it will compile as the mockedStore
        // will implement all the methods defined within our `Store` interface.
        userSvc := New(mockedStore)

        // we can then call our method and then run assertions that the business logic
        // defined within this method is working as we expect it to
        u, err := userSvc.UpdateUserPreferences(context.Background(), User{ID: "1234", Email: "new@email.com"})
        assert.NoError(t, err)
        assert.Equal(t, "new@email.com", u.Email)  
    })

    t.Run("sad path - errors can be handled properly", func(t *testing.T) {
        // Note - this code is just pseudocode to demonstrate generally how this could be done
        mockedStore.Expect().ToBeCalled().ToReturn(User{}, errors.New("something bad happened"))

        // we can then use the created mock to instantiate our userSvc and it will compile as the mockedStore
        // will implement all the methods defined within our `Store` interface.
        userSvc := New(mockedStore)

        // we can then call our method and then run assertions that the business logic
        // defined within this method is working as we expect it to
        _, err := userSvc.UpdateUserPreferences(context.Background(), User{ID: "1234", Email: "new@email.com"})
        assert.Error(t, err)
    })
}

通过设置这些期望,我们可以在我们的 UpdateUserPreferences 方法中非常快速地运行快乐路径和悲伤路径,我们根本不需要为此运行 Postgres 实例。

随着您尝试测试的底层代码变得越来越复杂,这种方法的好处变得越来越明显。

好处 - 迁移依赖项


这种方法的另一个好处是能够轻松定义和交换我们的 Store 接口的具体实现。

让我们想象一下,我们公司的产品团队要求我们出于任意原因需要迁移到不同类型的后备商店。

这种方法允许我们实现第二个包,该包在与这种新数据库类型通信时处理所有实现细节。我们不必更新我们的用户包,因为坦率地说,它并不关心这些细节。它真正关心的是它是否会实现定义的接口。

好处 - 依赖不可知论


在上面的代码片段中,示例演示了我们如何将这种方法用于数据库依赖项。值得注意的是,您可以在绝大多数 Go 应用程序开发中使用相同的方法。

例如,如果我需要与下游 API 对话,我可以采用与上面相同的方法。我可以有效地定义一个客户端包来处理与这个外部 API 通信所需的所有实现细节,在我的用户服务中我可以定义另一个接口,例如 APIClient,它将定义这个客户端包需要实现的所有方法。

使用上述方法,您可以在单元测试中使用模拟或伪造来锻炼您的用户包,实际访问这些下游 API,这可能会阻止您消耗 API 积分或达到速率限制。

注意事项


应该注意的是,我们在上面的代码片段中介绍的抽象不一定是“免费的”。

虽然我们在上面的文章中介绍的抽象非常有用并且允许我们测试我们的代码,但如果您需要从代码中榨取最后一盎司的性能,您可能会发现您需要在某些地方。

即使在编写处理支付处理之类的应用程序时,我也从来没有发现移除这些抽象带来的性能优势值得我的测试方法缺乏抽象所带来的限制。

结论


因此,在本文中,我们讨论了为什么接受接口和返回结构的口头禅对于希望在 Go 中编写可测试和高度可维护的服务的 Go 开发人员非常有用。

应该注意的是,与我网站上的所有文章一样,这些概念是我个人的偏好,我会在可能的时间和地点尝试和遵循。总会有特殊情况和边缘情况使这种方法变得不可能。

文章链接