发布于 2026-01-06 2 阅读
0

使用 Go 语言编写模拟数据库来测试 HTTP API,并实现 100% 的数据库覆盖率。

使用 Go 语言编写模拟数据库来测试 HTTP API,并实现 100% 的数据库覆盖率。

如果您在隔离单元测试数据以避免冲突方面遇到困难,不妨考虑使用模拟数据库!本文将介绍如何使用Gomock为数据库接口生成桩代码,这有助于我们更快、更简洁地编写 API 单元测试,并轻松实现 100% 的代码覆盖率。

以下是:

为什么要嘲讽数据库

在之前的课程中,我们学习了如何在 Go 语言中实现 RESTful HTTP API。在测试这些 API 时,有些人可能会选择连接到真实的数据库,而有些人则可能更喜欢使用模拟数据库。那么我们应该采用哪种方法呢?

替代文字

嗯,我觉得这取决于你。但对我来说,嘲讽更好,原因如下:

  • 首先,它有助于我们更轻松地编写独立测试,因为每个测试都会使用各自独立的模拟数据库来存储数据,因此不会出现冲突。如果使用真实数据库,所有测试都会对同一位置进行数据读写操作,因此很难避免冲突,尤其是在代码库庞大的大型项目中。
  • 其次,由于无需耗费时间与数据库通信并等待查询执行,我们的测试运行速度将大大提升。所有操作都将在内存中并在同一进程内完成。
  • 模拟数据库的第三个也是非常重要的原因是:它允许我们编写覆盖率达到 100% 的测试。借助模拟数据库,我们可以轻松设置和测试一些极端情况,例如意外错误或连接丢失,而这些在使用真实数据库时是无法实现的。

好的,听起来不错。但是仅仅使用模拟数据库来测试我们的API就足够了吗?我们能确信,当接入真实数据库时,我们的代码仍然能够良好运行吗?

替代文字

是的,当然!因为我们与真实数据库交互的代码已经在上一节课中经过了仔细测试。

所以我们只需要确保模拟数据库实现了与真实数据库相同的接口。这样,把它们组装起来之后,一切都能正常工作。

如何模拟数据库

模拟数据库有两种方法。

第一种方法是实现一个虚拟数据库,将数据存储在内存中。如果你学过我的gRPC 课程,那么你肯定已经了解这一点了。

替代文字

例如,这里有一个Store接口,它定义了我们可以对真实数据库执行的操作列表。

然后我们有一个假的数据库MemStore结构,它实现了接口的所有操作Store,但只使用映射来读取和写入数据。

使用虚拟数据库的方法非常简单易行。然而,它需要我们编写大量仅用于测试的代码,这会耗费大量的开发和后续维护时间。

今天我将向大家展示一种更好的模拟数据库的方法,那就是使用存根而不是伪造的数据库。

我们的想法是使用gomock包来生成和构建存根,这些存根会为我们想要测试的每个场景返回硬编码的值。

替代文字

在这个例子中,gomock 已经MockStore为我们生成了一个对象。所以我们只需要调用它的 EXPECT() 函数来构建一个存根,告诉 gomock:这个GetAccount()函数应该被调用一次,并传入这个输入accountID,然后返回这个account对象作为输出。

就这样!设置好存根之后,我们就可以直接使用这个模拟商店来测试 API 了。

如果你现在还不完全理解,别担心。让我们直接进入代码部分,看看它究竟是如何运作的!

安装 gomock

首先,我们需要安装gomock。打开浏览器搜索gomock gomock,然后打开它的GitHub页面

复制这条 go get 命令,并在终端中运行以安装该软件包:



❯ go get github.com/golang/mock/mockgen@v1.4.4


Enter fullscreen mode Exit fullscreen mode

之后,文件夹mockgen中将出现一个二进制文件go/bin


ls -l ~/go/bin
total 341344
...
-rwxr-xr-x  1 quangpham  staff  10440388 Oct 17 18:27 gotests
-rwxr-xr-x  1 quangpham  staff   8914560 Oct 17 18:27 guru
-rwxr-xr-x  1 quangpham  staff   5797544 Oct 17 18:27 impl
-rwxr-xr-x  1 quangpham  staff   7477056 Nov  2 09:21 mockgen


Enter fullscreen mode Exit fullscreen mode

我们将使用此工具生成模拟数据库,因此务必确保它可从任何位置执行。我们通过运行以下命令进行检查:



❯ which mockgen
mockgen not found


Enter fullscreen mode Exit fullscreen mode

这里显示找不到 mockgen。这是因为该文件夹目前go/bin不在环境变量中。PATH

要将其添加到系统中PATH,我将编辑该.zshrc文件,因为我使用的是 zsh。如果您使用的是 bash shell,则应该编辑 .bashrc.bash_profile或 .bashrc.bashrc文件。



❯ vi ~/.zshrc


Enter fullscreen mode Exit fullscreen mode

我用的是 vim,所以我们按下快捷键i进入插入模式。然后将以下export命令添加到文件顶部:



export PATH=$PATH:~/go/bin


Enter fullscreen mode Exit fullscreen mode

按下Esc即可退出插入模式,然后:wq按下即可保存文件并退出 vim。

接下来,我们需要运行以下source命令来重新加载.zshrc文件:


source ~/.zshrc


Enter fullscreen mode Exit fullscreen mode

现在如果我们which mockgen再次运行,可以看到它已经可以在go/bin文件夹中找到了。



❯ which mockgen
/Users/quangpham/go/bin/mockgen


Enter fullscreen mode Exit fullscreen mode

请注意,当我们打开一个新的终端窗口时,该.zshrc文件会自动加载。因此,我们无需source每次打开终端时都运行该命令。

定义商店接口

好了,现在为了使用 mockgen 生成模拟数据库,我们需要稍微更新一下代码。

目前,该api/server.go文件中的NewServer()函数接受一个db.Store对象作为参数:



type Server struct {
    store  *db.Store
    router *gin.Engine
}

func NewServer(store *db.Store) *Server {
    ...
}


Enter fullscreen mode Exit fullscreen mode

db.Store在文件中已定义db/sqlc/store.go。它是一个结构体,始终连接到实际数据库:



type Store struct {
    db *sql.DB
    *Queries
}


Enter fullscreen mode Exit fullscreen mode

因此,为了在 API 服务器测试中使用模拟数据库,我们需要将存储对象替换为接口。我将复制此Store结构体定义并将其类型更改为interface.



type Store interface {
    // TODO: add functions to this interface
}

type SQLStore struct {
    db *sql.DB
    *Queries
}



Enter fullscreen mode Exit fullscreen mode

然后,旧的Store结构体将被重命名为SQLStore。它将是接口的真正实现Store,用于与 SQL 数据库(在本例中为 PostgreSQL)进行通信。

那么这个NewStore()函数不应该返回指针,而应该返回一个Store接口。并且在接口内部,它应该返回该接口的实际数据库实现,也就是SQLStore……



func NewStore(db *sql.DB) Store {
    return &SQLStore{
        db:      db,
        Queries: New(db),
    }
}


Enter fullscreen mode Exit fullscreen mode

store我们还需要将函数的接收器类型execTx()TransferTx()函数本身修改成*SQLStore这样:



func (store *SQLStore) execTx(ctx context.Context, fn func(*Queries) error) error {
    ...
}

func (store *SQLStore) TransferTx(ctx context.Context, arg TransferTxParams) (TransferTxResult, error) {
}


Enter fullscreen mode Exit fullscreen mode

好了,现在我们需要定义一个Store接口可以执行的操作列表。

基本上,它应该具备Queries结构体的所有功能,外加一个执行转账交易的功能。

首先,我要复制这个TransferTx()函数签名,然后把它粘贴到Store界面里:



type Store interface {
    TransferTx(ctx context.Context, arg TransferTxParams) (TransferTxResult, error)
}


Enter fullscreen mode Exit fullscreen mode

当然,对于结构体中的函数Queries,我们也可以采用同样的方法,逐个复制粘贴。但是,这样做非常耗时,因为结构体可能包含大量的函数。

幸运的是,我们用来生成 CRUD 代码的sqlc包还有一个选项,可以发出一个包含结构体所有功能的接口Queries

我们只需要将文件emit_interface中的此设置更改sqlc.yamltrue



version: "1"
packages:
  - name: "db"
    path: "./db/sqlc"
    queries: "./db/query/"
    schema: "./db/migration/"
    engine: "postgresql"
    emit_json_tags: true
    emit_prepared_queries: false
    emit_interface: true
    emit_exact_table_names: false
    emit_empty_slices: true


Enter fullscreen mode Exit fullscreen mode

然后在终端中运行以下命令以重新生成代码:



❯ make sqlc


Enter fullscreen mode Exit fullscreen mode

之后,在该db/sqlc文件夹中,我们可以看到一个名为 . 的新文件querier.go。它包含了生成的Querier接口,其中包含从数据库插入和查询数据的所有函数:



type Querier interface {
    AddAccountBalance(ctx context.Context, arg AddAccountBalanceParams) (Account, error)
    CreateAccount(ctx context.Context, arg CreateAccountParams) (Account, error)
    CreateEntry(ctx context.Context, arg CreateEntryParams) (Entry, error)
    CreateTransfer(ctx context.Context, arg CreateTransferParams) (Transfer, error)
    DeleteAccount(ctx context.Context, id int64) error
    GetAccount(ctx context.Context, id int64) (Account, error)
    GetAccountForUpdate(ctx context.Context, id int64) (Account, error)
    GetEntry(ctx context.Context, id int64) (Entry, error)
    GetTransfer(ctx context.Context, id int64) (Transfer, error)
    ListAccounts(ctx context.Context, arg ListAccountsParams) ([]Account, error)
    ListEntries(ctx context.Context, arg ListEntriesParams) ([]Entry, error)
    ListTransfers(ctx context.Context, arg ListTransfersParams) ([]Transfer, error)
    UpdateAccount(ctx context.Context, arg UpdateAccountParams) (Account, error)
}

var _ Querier = (*Queries)(nil)


Enter fullscreen mode Exit fullscreen mode

这里可以看到它声明了一个空变量,var _ Querier以确保Queries结构体必须实现此Querier接口的所有功能。

现在我们需要做的就是将它嵌入到接口QuerierStore。这样,Store除了TransferTx()我们之前添加的函数之外,接口还将拥有它自身的所有函数:



type Store interface {
    Querier
    TransferTx(ctx context.Context, arg TransferTxParams) (TransferTxResult, error)
}


Enter fullscreen mode Exit fullscreen mode

接下来,我们需要回到文件中,从类型中api/server.go删除 this,因为它不再是结构体指针,而是一个接口:**db.Store



func NewServer(store db.Store) *Server {
    ...
}


Enter fullscreen mode Exit fullscreen mode

请注意,尽管我们将类型从结构体更改Store为接口,但我们的代码仍然可以正常工作,并且我们不必更改main.go文件中的任何内容,因为该db.NewStore()函数现在还返回一个Store接口,SQLStore其中包含连接到真实 SQL 数据库的实际实现。



func main() {
    config, err := util.LoadConfig(".")
    if err != nil {
        log.Fatal("cannot load config:", err)
    }

    conn, err := sql.Open(config.DBDriver, config.DBSource)
    if err != nil {
        log.Fatal("cannot connect to db:", err)
    }

    store := db.NewStore(conn)
    server := api.NewServer(store)

    err = server.Start(config.ServerAddress)
    if err != nil {
        log.Fatal("cannot start server:", err)
    }
}


Enter fullscreen mode Exit fullscreen mode

生成模拟数据库

好了,现在我们有了db.Store接口,我们可以使用 gomock 来生成它的模拟实现。

首先,我将在软件包mock内创建一个新文件夹db。然后,我们打开终端并运行以下命令:



❯ mockgen -help
mockgen has two modes of operation: source and reflect.

Source mode generates mock interfaces from a source file.
It is enabled by using the -source flag. Other flags that
maybe useful in this mode are -imports and -aux_files.
Example:
    mockgen -source=foo.go [other options]

Reflect mode generates mock interfaces by building a program
that uses reflection to understand interfaces. It is enabled
by passing two non-flag arguments: an import path, and a
comma-separated list of symbols.
Example:
    mockgen database/sql/driver Conn,Driver

    -aux_files string
        (source mode) Comma-separated pkg=path pairs of auxiliary Go source files.
    -build_flags string
        (reflect mode) Additional flags for go build.
    -copyright_file string
        Copyright file used to add copyright header
    -debug_parser
        Print out parser results only.
    -destination string
        Output file; defaults to stdout.
    -exec_only string
        (reflect mode) If set, execute this reflection program.
    -imports string
        (source mode) Comma-separated name=path pairs of explicit imports to use.
    -mock_names string
        Comma-separated interfaceName=mockName pairs of explicit mock names to use. Mock names default to 'Mock'+ interfaceName suffix.
    -package string
        Package of the generated code; defaults to the package of the input with a 'mock_' prefix.
    -prog_only
        (reflect mode) Only generate the reflection program; write it to stdout and exit.
    -self_package string
        The full package import path for the generated code. The purpose of this flag is to prevent import cycles in the generated code by trying to include its own package. This can happen if the mock's package is set to one of its inputs (usually the main one) and the output is stdio so mockgen cannot detect the final output package. Setting this flag will then tell mockgen which import to exclude.
  -source string
        (source mode) Input Go source file; enables source mode.
  -version
        Print version.
  -write_package_comment
        Writes package documentation comment (godoc) if true. (default true)


Enter fullscreen mode Exit fullscreen mode

Mockgen 提供了两种生成模拟对象的方法。它source mode可以从单个源文件生成模拟接口。

如果该源文件从其他文件导入包,事情就会变得更加复杂,而这在我们处理实际项目时经常发生。

在这种情况下,最好使用 `<mockgen>` reflect mode,我们只需要提供包的名称和接口,让 mockgen 使用反射自动确定要做什么。

好了,我要跑了:



❯ mockgen github.com/techschool/simplebank/db/sqlc Store


Enter fullscreen mode Exit fullscreen mode

第一个参数是接口的导入路径Store。它基本上就是简单的银行模块名称,github.com/techschool/simplebank后面跟着 `/`,/db/sqlc因为我们的Store接口定义在该db/sqlc文件夹内。

该命令需要传递的第二个参数是接口的名称,Store在本例中为。

我们还应该指定生成的输出文件的目标位置。否则,mockgen 默认会将生成的代码写入标准输出(stdout)。所以,让我们使用选项-destination告诉它将模拟存储代码写入db/mock/store.go文件:



❯ mockgen -destination db/mock/store.go github.com/techschool/simplebank/db/sqlc Store


Enter fullscreen mode Exit fullscreen mode

然后按回车键运行此命令。

现在回到 Visual Studio Code,我们可以看到文件夹store.go内生成了一个新文件db/mock



// Code generated by MockGen. DO NOT EDIT.
// Source: github.com/techschool/simplebank/db/sqlc (interfaces: Store)

// Package mock_sqlc is a generated GoMock package.
package mock_sqlc

import (
    context "context"
    gomock "github.com/golang/mock/gomock"
    db "github.com/techschool/simplebank/db/sqlc"
    reflect "reflect"
)

// MockStore is a mock of Store interface
type MockStore struct {
    ctrl     *gomock.Controller
    recorder *MockStoreMockRecorder
}

// MockStoreMockRecorder is the mock recorder for MockStore
type MockStoreMockRecorder struct {
    mock *MockStore
}

// NewMockStore creates a new mock instance
func NewMockStore(ctrl *gomock.Controller) *MockStore {
    mock := &MockStore{ctrl: ctrl}
    mock.recorder = &MockStoreMockRecorder{mock}
    return mock
}

// EXPECT returns an object that allows the caller to indicate expected use
func (m *MockStore) EXPECT() *MockStoreMockRecorder {
    return m.recorder
}

...


Enter fullscreen mode Exit fullscreen mode

此文件中包含两个重要的结构体:MockStoreMockStoreMockRecorder

MockStore是实现了接口所有必需功能的结构体Store。例如,以下是AddAccountBalance()该函数MockStore,它接受一个上下文和一个AddAccountBalanceParams输入,并返回一个结果Account或一个错误:



// AddAccountBalance mocks base method
func (m *MockStore) AddAccountBalance(arg0 context.Context, arg1 db.AddAccountBalanceParams) (db.Account, error) {
    m.ctrl.T.Helper()
    ret := m.ctrl.Call(m, "AddAccountBalance", arg0, arg1)
    ret0, _ := ret[0].(db.Account)
    ret1, _ := ret[1].(error)
    return ret0, ret1
}


Enter fullscreen mode Exit fullscreen mode

MockStoreMockRecorder还有一个同名且参数数量相同的函数。但是,这些参数的类型不同。它们只是普通interface类型:



// AddAccountBalance indicates an expected call of AddAccountBalance
func (mr *MockStoreMockRecorder) AddAccountBalance(arg0, arg1 interface{}) *gomock.Call {
    mr.mock.ctrl.T.Helper()
    return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddAccountBalance", reflect.TypeOf((*MockStore)(nil).AddAccountBalance), arg0, arg1)
}


Enter fullscreen mode Exit fullscreen mode

稍后我们将看到如何使用此函数来构建存根。其思路是:我们可以指定函数应该被调用多少次AddAccountBalance(),以及每次调用时使用的参数值。

界面的其他所有功能Store都是以相同的方式生成的。

请注意,gomock 为我们生成的当前包名称是mock_sqlc,这看起来不太符合习惯用法,所以我想把它改成其他名称,例如mockdb

我们可以使用选项指示 mockgen 执行此操作-package。我们只需在此命令中添加 `--mockgen -package--common ...mockdb



❯ mockgen -package mockdb -destination db/mock/store.go github.com/techschool/simplebank/db/sqlc Store


Enter fullscreen mode Exit fullscreen mode

现在,代码中的包名已经mockdb按我们想要的方式更改了:



// Code generated by MockGen. DO NOT EDIT.
// Source: github.com/techschool/simplebank/db/sqlc (interfaces: Store)

// Package mockdb is a generated GoMock package.
package mockdb

import (
    context "context"
    gomock "github.com/golang/mock/gomock"
    db "github.com/techschool/simplebank/db/sqlc"
    reflect "reflect"
)

// MockStore is a mock of Store interface
type MockStore struct {
    ctrl     *gomock.Controller
    recorder *MockStoreMockRecorder
}

// MockStoreMockRecorder is the mock recorder for MockStore
type MockStoreMockRecorder struct {
    mock *MockStore
}

...


Enter fullscreen mode Exit fullscreen mode

好的,在开始使用新生成的代码编写 API 测试之前MockStore,我将向代码中添加一个新的模拟命令,Makefile以便我们可以随时轻松地重新生成代码。



...

mock:
    mockgen -package mockdb -destination db/mock/store.go github.com/techschool/simplebank/db/sqlc Store

.PHONY: postgres createdb dropdb migrateup migratedown sqlc test server mock


Enter fullscreen mode Exit fullscreen mode

现在,每当我们想要重新生成模拟商店时,我们只需make mock在终端中运行即可。

为 Get Account API 编写单元测试

好了,现在有了生成的文件MockStore,我们可以开始为我们的 API 编写测试了。

我将在包account_test.go内创建一个新文件api

我们的应用程序中有多个用于管理银行账户的 API。但本次讲解中,我们将只针对最重要的一个 API——获取账户 API——编写测试。您可以根据这些 API 轻松编写其他 API 的测试。

api/account_test.go文件中,我将定义一个TestGetAccountAPI()带有testing.T 输入参数的新函数。



func TestGetAccountAPI(t *testing.T) {
}


Enter fullscreen mode Exit fullscreen mode

为了测试这个API,我们需要先注册一个账号。所以,我们来编写一个单独的函数来生成一个随机账号。

它将返回一个db.Account对象,其中ID是 1 到 1000 之间的随机整数,Ownerutil.RandomOwner()Balanceutil.RandomMoney()Currencyutil.RandomCurrency()



func randomAccount() db.Account {
    return db.Account{
        ID:       util.RandomInt(1, 1000),
        Owner:    util.RandomOwner(),
        Balance:  util.RandomMoney(),
        Currency: util.RandomCurrency(),
    }
}


Enter fullscreen mode Exit fullscreen mode

现在回到测试部分,我们调用randomAccount()函数来创建一个新账户。



func TestGetAccountAPI(t *testing.T) {
    account := randomAccount()
}


Enter fullscreen mode Exit fullscreen mode

接下来,我们需要使用mockdb.NewMockStore()生成的函数创建一个新的模拟商店。它需要一个gomock.Controller对象作为输入,因此我们需要调用该函数gomock.NewController并传入testing.T对象来创建控制器。



func TestGetAccountAPI(t *testing.T) {
    account := randomAccount()

    ctrl := gomock.NewController(t)
    defer ctrl.Finish()
}


Enter fullscreen mode Exit fullscreen mode

我们应该延迟调用Finish此控制器的方法。这一点非常重要,因为它会检查所有预期调用的方法是否都已被调用。

我们稍后会看到它是如何工作的。现在,让我们通过调用mockdb.NewMockStore()这个输入控制器来创建一个新的商店。



func TestGetAccountAPI(t *testing.T) {
    account := randomAccount()

    ctrl := gomock.NewController(t)
    defer ctrl.Finish()

    store := mockdb.NewMockStore(ctrl)
}


Enter fullscreen mode Exit fullscreen mode

下一步是为这个模拟商店构建存根。在这种情况下,我们只关心该GetAccount()方法,因为它是“获取帐户”API 处理程序应该调用的唯一方法。

所以让我们通过调用来构建这个方法的存根store.EXPECT().GetAccount()。该函数需要两个类型为通用接口的输入参数。

为什么需要两个输入参数?这是因为GetAccount()我们Store接口的方法需要两个输入参数:上下文和帐户 ID。



type Querier interface {
    GetAccount(ctx context.Context, id int64) (Account, error)
    ...
}


Enter fullscreen mode Exit fullscreen mode

因此,对于这个存根定义,我们必须指定我们希望调用此函数时使用的这两个参数的值。

第一个上下文参数可以是任何值,所以我们使用gomock.Any()匹配器来处理它。第二个参数应该等于ID我们上面创建的随机帐户的 ID。因此,我们使用以下匹配器gomock.Eq()并将 ID 传递account.ID给它。



func TestGetAccountAPI(t *testing.T) {
    account := randomAccount()

    ctrl := gomock.NewController(t)
    defer ctrl.Finish()

    store := mockdb.NewMockStore(ctrl)
    store.EXPECT().
        GetAccount(gomock.Any(), gomock.Eq(account.ID)).
        Times(1).
        Return(account, nil)
}


Enter fullscreen mode Exit fullscreen mode

现在这个存根定义可以翻译为:我希望GetAccount()商店的函数能够被调用,并传入任何上下文和这个特定的帐户 ID 参数。

我们还可以使用该函数指定此函数的调用次数Times()。这里Times(1)表示我们希望此函数恰好被调用 1 次。

不仅如此,我们还可以使用该Return()函数告诉 gomock 在每次GetAccount()调用该函数时返回一些特定值。例如,在本例中,我们希望它返回账户对象和一个 nil 错误。

请注意,此函数的输入参数应与接口中定义的函数Return()返回值相匹配GetAccountQuerier

好了,现在我们的模拟 Store 的存根已经搭建好了。我们可以用它来启动测试 HTTP 服务器并发送 GetAccount 请求。接下来,让我们通过调用NewServer()函数并使用模拟 Store 来创建一个服务器。



func TestGetAccountAPI(t *testing.T) {
    ...

    server := NewServer(store)
    recorder := httptest.NewRecorder()
}


Enter fullscreen mode Exit fullscreen mode

在 Go 中测试 HTTP API 时,我们不必启动真正的 HTTP 服务器。相反,我们可以使用该httptest包的录制功能来记录 API 请求的响应。因此,这里我们调用httptest.NewRecorder()创建一个新的ResponseRecorder.

接下来,我们将声明要调用的 API 的 URL 路径,它应该是/accounts/{ID of the account we want to get}

然后我们使用GET该方法向该 URL 发送一个新的 HTTP 请求。由于这是一个GET请求,我们可以将nil其用于请求体。



func TestGetAccountAPI(t *testing.T) {
    ...

    server := NewServer(store)
    recorder := httptest.NewRecorder()

    url := fmt.Sprintf("/accounts/%d", tc.accountID)
    request, err := http.NewRequest(http.MethodGet, url, nil)
    require.NoError(t, err)
}


Enter fullscreen mode Exit fullscreen mode

http.NewRequest函数将返回一个request对象或一个错误。我们要求不返回任何错误。

然后我们调用函数server.router.ServeHTTP()传入创建的对象。recorderrequest



func TestGetAccountAPI(t *testing.T) {
    account := randomAccount()

    ctrl := gomock.NewController(t)
    defer ctrl.Finish()

    store := mockdb.NewMockStore(ctrl)
    store.EXPECT().
        GetAccount(gomock.Any(), gomock.Eq(account.ID)).
        Times(1).
        Return(account, nil)

    server := NewServer(store)
    recorder := httptest.NewRecorder()

    url := fmt.Sprintf("/accounts/%d", tc.accountID)
    request, err := http.NewRequest(http.MethodGet, url, nil)
    require.NoError(t, err)

    server.router.ServeHTTP(recorder, request)
    require.Equal(t, http.StatusOK, recorder.Code)
}


Enter fullscreen mode Exit fullscreen mode

基本上,这会将我们的 API 请求发送request到服务器router,并将服务器的响应记录在文件中recorder。我们只需要检查该响应即可。

最简单的检查方法是查看 HTTP 状态码。正常情况下,状态码应该是http.StatusOK0。该状态码记录在CodeHTTPS 的 GET 字段中recorder

好了!我们来运行测试。

替代文字

通过了!太棒了!

现在我将向您展示,如果我们在文件getAccount()的处理函数中api/account.go不调用该store.GetAccount函数会发生什么。让我们注释掉这段代码,并将 account 设置为一个空对象。



func (server *Server) getAccount(ctx *gin.Context) {
    var req getAccountRequest
    if err := ctx.ShouldBindUri(&req); err != nil {
        ctx.JSON(http.StatusBadRequest, errorResponse(err))
        return
    }

    // account, err := server.store.GetAccount(ctx, req.ID)
    // if err != nil {
    //     if err == sql.ErrNoRows {
    //         ctx.JSON(http.StatusNotFound, errorResponse(err))
    //         return
    //     }

    //     ctx.JSON(http.StatusInternalServerError, errorResponse(err))
    //     return
    // }
    account := db.Account{}

    ctx.JSON(http.StatusOK, account)
}


Enter fullscreen mode Exit fullscreen mode

保存此文件并重新运行单元测试。

替代文字

这次测试失败了。原因是缺少对store.GetAccount函数的调用。我们预期该函数会被调用一次,但在实际实现中,它并没有被调用。

现在您了解了这个gomock软件包的一个强大之处。它让编写单元测试变得如此简单,并为我们节省了大量实现模拟接口的时间。

那么,如果我们想检查的不仅仅是HTTP状态码呢?为了使测试更加可靠,我们还应该检查响应体。

响应体存储在该recorder.Body字段中,该字段实际上只是一个bytes.Buffer指针。

我们希望它与我们在测试开始时生成的账户相匹配。因此,我将requireBodyMatchAccount()为此编写一个新函数。

它将有 3 个输入参数:testing.T响应体(byte.Buffer指针类型)和要比较的帐户对象。



func requireBodyMatchAccount(t *testing.T, body *bytes.Buffer, account db.Account) {
    data, err := ioutil.ReadAll(body)
    require.NoError(t, err)

    var gotAccount db.Account
    err = json.Unmarshal(data, &gotAccount)
    require.NoError(t, err)
    require.Equal(t, account, gotAccount)
}


Enter fullscreen mode Exit fullscreen mode

首先,我们调用函数ioutil.ReadAll()读取响应体中的所有数据并将其存储在一个data变量中。我们要求不返回任何错误。

然后我们声明一个新gotAccount变量来存储从响应正文数据中获取的帐户对象。

然后我们调用函数json.Unmarshal将数据反序列化到gotAccount对象中。要求不出现任何错误,然后要求结果gotAccount与输入相等account

好了,完成了。现在让我们回到单元测试,调用requireBodyMatchAccount函数testing.T,并将recorder.Body、 和生成的account作为输入参数。



func TestGetAccountAPI(t *testing.T) {
    account := randomAccount()

    ctrl := gomock.NewController(t)
    defer ctrl.Finish()

    store := mockdb.NewMockStore(ctrl)
    store.EXPECT().
        GetAccount(gomock.Any(), gomock.Eq(account.ID)).
        Times(1).
        Return(account, nil)

    server := NewServer(store)
    recorder := httptest.NewRecorder()

    url := fmt.Sprintf("/accounts/%d", tc.accountID)
    request, err := http.NewRequest(http.MethodGet, url, nil)
    require.NoError(t, err)

    server.router.ServeHTTP(recorder, request)
    require.Equal(t, http.StatusOK, recorder.Code)
    requireBodyMatchAccount(t, recorder.Body, account)
}


Enter fullscreen mode Exit fullscreen mode

然后重新运行测试。

替代文字

通过了。太好了!

好的,GetAccount API 的单元测试运行良好。但目前它只涵盖了正常情况。

接下来,我将向您展示如何将此测试转换为表格驱动的测试集,以涵盖 GetAccount API 的所有可能场景,并获得 100% 的覆盖率。

实现 100% 覆盖率

首先,我们需要声明一个测试用例列表。我将使用一个匿名类来存储测试数据。

每个测试用例都会有一个唯一的名称,以便与其他用例区分开来。然后我们需要获取一个账户ID。



func TestGetAccountAPI(t *testing.T) {
    account := randomAccount()

    testCases := []struct {
        name          string
        accountID     int64
        buildStubs    func(store *mockdb.MockStore)
        checkResponse func(t *testing.T, recoder *httptest.ResponseRecorder)
    }{
        // TODO: add test data
    }
    ...
}


Enter fullscreen mode Exit fullscreen mode

此外,GetAccount每个场景的桩代码都需要以不同的方式构建,因此这里我添加了一个buildStubs字段,它实际上是一个函数,接受一个模拟存储作为输入。我们可以利用这个模拟存储来构建适合每个测试用例需求的桩代码。

类似地,我们有一个checkResponse函数来检查 API 的输出。它有两个输入参数:一个值testing.T和一个httptest.ResponseRecorder对象。

现在有了这个结构体定义,让我们添加第一个场景,即理想情况。



func TestGetAccountAPI(t *testing.T) {
    account := randomAccount()

    testCases := []struct {
        name          string
        accountID     int64
        buildStubs    func(store *mockdb.MockStore)
        checkResponse func(t *testing.T, recoder *httptest.ResponseRecorder)
    }{
        {
            name:      "OK",
            accountID: account.ID,
            buildStubs: func(store *mockdb.MockStore) {
                store.EXPECT().
                    GetAccount(gomock.Any(), gomock.Eq(account.ID)).
                    Times(1).
                    Return(account, nil)
            },
            checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) {
                require.Equal(t, http.StatusOK, recorder.Code)
                requireBodyMatchAccount(t, recorder.Body, account)
            },
        },
    }
    ...
}


Enter fullscreen mode Exit fullscreen mode

它的名称是"OK"。账户 ID 应该是account.ID。接下来,对于该buildStubs函数,我将复制它的签名。然后将store.EXPECT命令移到该函数中。

函数也类似checkResponse。我们复制它的签名,然后把两个 require 命令移到里面。

稍后我们会在这个列表中添加更多案例。现在,让我们稍微重构一下代码,使其能够适用于多种场景。

我们使用一个简单的for循环来遍历测试用例列表。然后在循环内部,我们声明一个新的变量 tc 来存储当前测试用例的数据。

我们将把每个测试用例作为这个单元测试的独立子测试来运行,所以让我们调用t.Run()函数,传入这个测试用例的名称,以及一个以testing.T对象作为输入的函数。然后我会把所有这些语句都移到这个函数里。



func TestGetAccountAPI(t *testing.T) {
    ...

    for i := range testCases {
        tc := testCases[i]

        t.Run(tc.name, func(t *testing.T) {
            ctrl := gomock.NewController(t)
            defer ctrl.Finish()

            store := mockdb.NewMockStore(ctrl)
            tc.buildStubs(store)

            server := NewServer(store)
            recorder := httptest.NewRecorder()

            url := fmt.Sprintf("/accounts/%d", tc.accountID)
            request, err := http.NewRequest(http.MethodGet, url, nil)
            require.NoError(t, err)

            server.router.ServeHTTP(recorder, request)
            tc.checkResponse(t, recorder)
        })
    }
}


Enter fullscreen mode Exit fullscreen mode

请注意,应该使用为每个测试用例定义的帐户 IDurl来创建它。tc.accountID

我们tc.buildStubs()在发送请求之前使用模拟存储调用函数,最后调用tc.checkResponse()函数来验证结果。

好的,让我们重新运行测试,以确保我们的预期结果仍然有效。

替代文字

耶,通过了!现在是时候增加更多案例了。

我将复制正常情况下的测试数据。我们要测试的第二个情况是找不到帐户的情况。所以它的名称应该是"NotFound"……

这里我们可以沿用同样的方法,accountID因为每个测试用例的模拟商店都是独立的。但我们需要buildStubs稍微修改一下函数。



func TestGetAccountAPI(t *testing.T) {
    account := randomAccount()

    testCases := []struct {
        name          string
        accountID     int64
        buildStubs    func(store *mockdb.MockStore)
        checkResponse func(t *testing.T, recoder *httptest.ResponseRecorder)
    }{
        {
            name:      "OK",
            accountID: account.ID,
            buildStubs: func(store *mockdb.MockStore) {
                store.EXPECT().
                    GetAccount(gomock.Any(), gomock.Eq(account.ID)).
                    Times(1).
                    Return(account, nil)
            },
            checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) {
                require.Equal(t, http.StatusOK, recorder.Code)
                requireBodyMatchAccount(t, recorder.Body, account)
            },
        },
        {
            name:      "NotFound",
            accountID: account.ID,
            buildStubs: func(store *mockdb.MockStore) {
                store.EXPECT().
                    GetAccount(gomock.Any(), gomock.Eq(account.ID)).
                    Times(1).
                    Return(db.Account{}, sql.ErrNoRows)
            },
            checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) {
                require.Equal(t, http.StatusNotFound, recorder.Code)
            },
        },
    }

    ...
}


Enter fullscreen mode Exit fullscreen mode

这里,我们不应该返回这个特定的账户,而应该返回一个空Account{}对象以及一个sql.ErrNoRows错误信息。这是因为在实际Store连接到 Postgres 的实现中,db/sql如果执行查询时找不到账户,该包会返回这个错误SELECT

我们还需要修改checkResponse函数,因为在这种情况下,我们期望服务器返回http.StatusNotFound结果。由于找不到账户,我们可以移除该requireBodyMatchAccount调用。

好的,我们再运行一​​次测试。

替代文字

太棒了!两项测试都通过了。

让我们运行整个软件包测试,看看代码覆盖率。

替代文字

在 account.go 文件中,我们可以看到这个getAccount处理程序并没有 100% 被覆盖。

目前只涵盖了未找到和成功两种情况。我们还需要测试另外两种情况:InternalServerErrorBadRequest

我再次复制测试数据,并将其名称更改为"InternalError"



func TestGetAccountAPI(t *testing.T) {
    account := randomAccount()

    testCases := []struct {
        name          string
        accountID     int64
        buildStubs    func(store *mockdb.MockStore)
        checkResponse func(t *testing.T, recoder *httptest.ResponseRecorder)
    }{
        ...
        {
            name:      "InternalError",
            accountID: account.ID,
            buildStubs: func(store *mockdb.MockStore) {
                store.EXPECT().
                    GetAccount(gomock.Any(), gomock.Eq(account.ID)).
                    Times(1).
                    Return(db.Account{}, sql.ErrConnDone)
            },
            checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) {
                require.Equal(t, http.StatusInternalServerError, recorder.Code)
            },
        },
    }

    ...
}


Enter fullscreen mode Exit fullscreen mode

现在,在函数中,我没有返回 ,buildStubs而是返回 ,这基本上是当对已返回到连接池的连接运行查询时,该包可能返回的一个错误。sql.ErrNoRowssql.ErrConnDonedb/sql

在这种情况下,应该将其视为内部错误,因此在checkResponse函数中,我们必须要求recorder.Code等于http.StatusInternalServerError

让我们重新运行软件包测试。

替代文字

全部通过。现在我们可以看到代码中的 InternalServerError 分支已经得到覆盖。

我们要测试的最后一个场景是BadRequest,这意味着客户端向此 API 发送了一些无效参数。

为了重现此场景,我们将使用一个不满足此绑定条件的无效帐户 ID。

所以我要回到测试文件,再次复制此测试数据,将其名称更改为InvalidID,并将其更新accountID为 0,这是一个无效值,因为最小 ID 应为 1。

GetAccount在这种情况下,我们应该将函数调用的第二个参数更改为gomock.Any()



func TestGetAccountAPI(t *testing.T) {
    account := randomAccount()

    testCases := []struct {
        name          string
        accountID     int64
        buildStubs    func(store *mockdb.MockStore)
        checkResponse func(t *testing.T, recoder *httptest.ResponseRecorder)
    }{
        ...
        {
            name:      "InvalidID",
            accountID: 0,
            buildStubs: func(store *mockdb.MockStore) {
                store.EXPECT().
                    GetAccount(gomock.Any(), gomock.Any()).
                    Times(0)
            },
            checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) {
                require.Equal(t, http.StatusBadRequest, recorder.Code)
            },
        },
    }

    ...
}


Enter fullscreen mode Exit fullscreen mode

由于 ID 无效,GetAccount处理程序不应调用该函数。因此,我们必须将其更新为 `false` Times(0),并移除Return函数调用。

对于checkResponse,我们必须将状态码更改为http.StatusBadRequest

好了,就这些。让我们重新运行整个软件包的测试!

替代文字

全部通过!太棒了!查看 getAccount 处理程序的代码,我们可以看到它已经 100% 覆盖。所以我们的目标达成了!

但是,目前的测试日志包含的信息过多。

替代文字

Gin写入了许多重复的调试日志,这使得测试结果更难解读。

原因是 Gin 默认以模式运行Debug。所以我们需要在包main_test.go内创建一个新文件api,并将 Gin 配置为使用Test另一种模式。

main_test.go这个文件的内容与包中的文件非常相似db,所以我将复制这个 TestMain 函数,并将其粘贴到我们的新文件中。然后,我们删除该函数中除最后一个语句之外的所有语句。

现在我们只需要打电话gin.SetMode把它改成gin.TestMode



func TestMain(m *testing.M) {
    gin.SetMode(gin.TestMode)
    os.Exit(m.Run())
}



Enter fullscreen mode Exit fullscreen mode

好了!我们完成了。现在让我们回到测试文件,运行整个包的测试。

替代文字

全部通过。现在日志看起来比以前更清晰易读了。

结论

好的,今天我们学习了如何使用 gomock 为数据库接口生成模拟对象,并用它来编写 Get Account API 的单元测试,从而实现 100% 的代码覆盖率。它确实能帮助我们更快、更轻松、更简洁、更安全、更健壮地编写测试。

Create Account您可以运用这些知识为我们简单的银行项目中的其他 HTTP API(例如API)编写测试Delete Account

我会将代码上传到Github,以便您在需要查看时可以参考。

非常感谢您的阅读,我们下节课再见。


如果您喜欢这篇文章,请订阅我们的YouTube 频道,并在TwitterFacebook上关注我们,以便将来获取更多教程。


如果你想加入我在Voodoo的优秀团队,请点击此处查看我们的招聘信息。可远程办公,也可在巴黎/阿姆斯特丹/伦敦/柏林/巴塞罗那现场办公,公司提供签证担保。

文章来源:https://dev.to/techschoolguru/mock-db-for-testing-http-api-in-go-and-achieve-100-coverage-4pa9