【译】Golang 模拟 HTTP 请求

注:本文翻译自 mocking-http-requests-in-golang

让我们看一下如何使用接口来构建一个 mock 的 HTTP 客户端,我们可以在 Golang 应用程序的测试套件中使用该客户端。

The App

假设我们正在构建一个与 GitHub API 交互的应用程序:该应用程序的用户可以执行诸如创建 GitHub 存储库、在存储库上提交 issue、获取有关组织的信息等操作。我们的应用程序实现了一个进行这些 GitHub API 调用的 REST 客户端。

我们的客户端的简化版本,POST 目前仅实现一个功能,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package restclient

import (
"bytes"
"encoding/json"
"net/http"
)

// Post sends a post request to the URL with the body
func Post(url string, body interface{}, headers http.Header) (*http.Response, error){
jsonBytes, err := json.Marshal(body)
if err != nil {
return nil, err
}
request, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(jsonBytes))
if err != nil {
return nil, err
}
request.Header = headers
client := &http.Client{}
return client.Do(request)
}

您可以看到我们定义了一个包 restclient,它实现了一个函数 Post, 该函数接收三个参数:请求发送到的 URL、请求正文和 HTTP 头部。它使用该 http 包发出 Web 请求并返回指向 HTTP 响应、*http.Response 或错误的指针。

函数体负责以下工作:

  • 通过调用 json.Marshal 将给定正文转换为 JSON
  • 使用 http.MethodPost、给定的 url 和转换为 readerJSON 内容创建一个新的 http.Request
  • 将给定的标头添加到请求中
  • 创建 http.Client 实例
  • 调用 http.Client 实例的 Do 方法发送请求

我们的 Post 函数直接实例化客户端结构并调用 Do。这给我们留下了一个小问题…

问题

因为我们的 restclient 包的 Do函数直接调用实例 http.Client,所以我们使用此客户端编写的触发代码路径的任何测试实际上都会向 GitHub API 发出 Web 请求。

哎呀!这意味着我们将用虚假的测试数据向真实的 github.com 发送垃圾邮件,就像创建真实的测试存储库并在每次测试运行中耗尽我们的 API 速率限制。

此外,依赖真实的 GitHub API 交互使我们很难编写清晰的测试,在测试中给定的输入集会产生预期的结果。我们最终得到的测试声明性较少,迫使其他开发人员阅读我们的代码,根据特定 HTTP 请求的输入推断结果。

那么,我们如何编写清晰的声明性测试来避免发送真实的 Web 请求呢?

我们将构建一个 mock 的 HTTP 客户端并配置我们的测试以使用该 mock 客户端。然后,每个测试都可以清楚地声明给定 HTTP 请求的 mock 响应。

可是等等!为了构建我们的模拟客户端并指导我们的代码何时使用真实客户端以及何时使用 mock,我们需要构建一个接口。

我们需要一个 Interface!

接口实际上只是一个命名的方法集合。任何实现相同方法集合的自定义结构类型都将被视为符合该接口。

结构体不强制满足特定接口。相反,如果给定的结构实现了接口中声明的所有方法,则暗示该结构满足给定的接口。

接口允许我们实现 多态性 ——而不是给定的函数或变量声明期望特定类型的结构,它可以期望由一个或多个结构共享的接口类型的实体。通过这种方式,我们可以定义接受或声明变量的函数,这些变量可以设置为等于实现共享行为的各种结构。

如果您不熟悉 Golang 中的接口,请查看 Go By Examples 中的这个优秀而简洁的 资源

那么,这与我们的测试套件中的 mock Web 请求有什么关系呢?

我们将重构我们的 restclient 包,使其在 HTTP 客户端方面不那么死板。我们的代码将变得更智能、更灵活,而不是 http.Client 直接在函数体中实例化结构。我们将教它与任何符合共享 HTTP 客户端接口的结构Post 一起工作。然后,我们将能够将包配置为在我们想要 mock 的 Web 请求的任何测试中默认使用该结构以及模拟客户端结构的实例。

让我们来重构它吧!

步骤 1. 定义客户端接口

首先,我们将在 restclient 包中定义一个接口,该 http.Client 结构和即将定义的模拟客户端结构都将符合该接口。

我们将调用我们的接口 HTTPClient并声明它只实现一个函数 Do,因为它是我们在 http.Client 实例上调用的唯一函数。

1
2
3
4
5
6
package restclient

// HTTPClient interface
type HTTPClient interface {
Do(req *http.Request) (*http.Response, error)
}

请注意,我们已经声明接口 Do 函数接受一个指向 http.Request 的指针作为参数,并返回一个指向 http.Response 的指针或一个错误。这正是现有 http.ClientDo 函数的定义。我们需要确保这种情况,因为我们定义的接口, http.Client是能够满足的,以及我们尚未定义的 mock 客户端结构。

现在我们已经定义了接口,让我们的包足够智能,可以在任何符合该接口的实体上进行操作。

步骤 2. 定义Client变量

我们将声明一个接口类型 HTTPClient 的变量 Client

1
2
3
4
5
6
package restclient
...

var (
Client HTTPClient
)

接口类型的变量可以设置为实现该接口的任何类型。由于 http.Client 符合接口 HTTPClient,我们可以设置 Client 为此结构的实例。稍后,一旦我们定义了mock 客户端并使其符合我们的 HTTPClient 接口,我们就可以将变量设置为 mock 的 HTTP 客户端 Client的实例。

现在我们已经定义了 Client 变量,那我们在初始化 restclient 包时可以将 Client 设置为一个 http.Client 的实例。

我们将在一个 init 函数中这样做。回想一下,当导入包时(无论您在应用程序的其他位置导入该包多少次),init 包的函数只会运行一次并且在包的任何其他部分之前运行。

1
2
3
4
5
6
7
8
9
10
package restclient
...

var (
Client HTTPClient
)

func init() {
Client = &http.Client{}
}

我们 init 函数将 Client 设置为新初始化的 http.Client 实例。

最后,我们需要重构我们的 Post 函数来使用 Client 变量而不是直接调用 &http.Client{}

把它们放在一起,我们的 restclient 包现在看起来像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
package restclient

import (
"bytes"
"encoding/json"
"net/http"
)

// HTTPClient interface
type HTTPClient interface {
Do(req *http.Request) (*http.Response, error)
}

var (
Client HTTPClient
)

func init() {
Client = &http.Client{}
}

// Post sends a post request to the URL with the body
func Post(url string, body interface{}, headers http.Header) (*http.Response, error) {
jsonBytes, err := json.Marshal(body)
if err != nil {
return nil, err
}
request, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(jsonBytes))
if err != nil {
return nil, err
}
request.Header = headers
return Client.Do(request)
}

这里需要指出的一件重要的事情是,我们确保 Client 通过使用大写字母命名变量来导出它。由于它是导出的,我们可以在应用程序中导入包的任何其他地方对其进行操作 restclient。换句话说,在任何我们想要模拟对 HTTP 客户端的调用的测试中,我们可以执行以下操作:

1
2
3
import "restclient"

restclient.Client = &ourMock{} // Mock definition coming soon!

现在我们了解了接口允许我们做什么,我们准备好定义我们的 mock 客户端结构了!

步骤3.定义mock客户端结构

由于我们可能需要在应用程序中的各种包的测试中模拟 Web 请求——测试使用我们的代码发出 Web 请求的任何部分,我们将在其自己的包中定义我们的 restclient ,这样 mock 客户端可以导入到任何测试文件中。

我们将在 中定义我们的模拟 utils/mocks/client.go。我们首先定义一个自定义结构类型 MockClient,它实现一个跟 HTTPClient 相同函数 Do:

1
2
3
4
5
6
7
8
9
10
11
12
13
package mocks

import "net/http"

// MockClient is the mock client
type MockClient struct {
DoFunc func(req *http.Request) (*http.Response, error)
}

// Do is the mock client's Do func
func (m *MockClient) Do(req *http.Request) (*http.Response, error) {
// coming soon!
}

请注意,因为我们已经将 MockClient 结构体大写,所以它从我们的 mocks 包中导出,并且可以像这样调用:mocks.MockClient.

现在我们有了一个符合 HTTPClient 接口定义的 MockClient,它实现了一个 Do 方法,我们可以在测试中将 restclient 包的 Client 变量设置为此 mock 的实例:

1
restclient.Client = &mocks.MockClient{}

但是我们如何确保测试调用 restclient.Client.Do 会返回特定的 mock 值?

我们需要使 mock 客户端 Do 函数的返回值可配置。我们开始做吧!

步骤4.配置模拟客户端Do功能

我们将在 mocks 包中定义一个可导出变量 GetDoFunc,并在 MockClientDo 方法中调用GetDoFunc

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package mocks

import "net/http"

// MockClient is the mock client
type MockClient struct {
DoFunc func(req *http.Request) (*http.Response, error)
}

var (
// GetDoFunc fetches the mock client's `Do` func
GetDoFunc func(req *http.Request) (*http.Response, error)
)

// Do is the mock client's `Do` func
func (m *MockClient) Do(req *http.Request) (*http.Response, error) {
return GetDoFunc(req)
}

步骤5.使用模拟客户端

那么,这对我们有什么作用呢?这样我们可以将 mocks.GetDoFunc 设置为任何符合 GetDoFuncAPI 的函数。

换句话说,我们可以定义一个匿名函数,它返回我们想要的给定测试调用 HTTP 客户端的任何预设响应。我们可以设置 mocks.GetDoFunc 等于该函数,从而确保对模拟客户端 Do函数的调用返回该预设响应。

首先,我们设置 restclient.Client为 mock 结构的一个实例:

1
restclient.Client = &mocks.Client{}

因此,当我们调用调用 restclient.Post 时,对该函数的调用 Client.Do 实际上是对模拟客户端 Do 函数的调用。

接下来,我们设置 mocks.GetDoFunc 为一个匿名函数,该函数返回一些响应,这将帮助我们的测试满足特定场景:

1
2
3
4
5
mocks.GetDoFunc = func(*http.Request) (*http.Response, error) {
return nil, errors.New(
"Error from web server",
)
}

因此,当 restclient.Post 调用 Client.Do 时,mock 客户端的 Do 函数调用这个匿名函数,返回nil 和一个 dummy 的错误。

让我们将它们放在一个示例测试中!假设我们的应用程序有一个服务 repositories,它 POST 向 GitHub API 发出请求以创建新的存储库。我们编写一个成功创建存储库的测试,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package services

import (
"bytes"
"io/ioutil"
"testing"

"github.com/SophieDeBenedetto/golang-microservices/src/api/clients/restclient"
"github.com/SophieDeBenedetto/golang-microservices/src/api/domain/repositories"
"github.com/stretchr/testify/assert"
)

func TestCreateRepoSuccess(t *testing.T) {
request := repositories.CreateRepoRequest{Name: "Test Name"}
resp, err := RepoService.CreateRepo(request)
assert.NotNil(t, resp)
assert.Nil(t, err)
assert.EqualValues(t, "Test Name", resp.Name)
}

我们的测试创建一个新 repositories.CreateRepo 请求并 RepoService.CreateRepo 使用该请求的参数进行调用。在这个函数的背后 CreateRepo,我们的代码调用 restclient.Post.

就目前而言,我们没有模拟任何东西,并且此测试中的调用 RepoService.CreateRepo 将真正向 GitHub API 发送 Web 请求。不好了!

让我们配置这个测试文件以使用我们的模拟客户端。resclient.Client.Do 然后,我们将配置此特定测试以使用特定的“成功”响应来模拟调用。

我们将使用一个 init 函数来设置 restclient.Client 模拟客户端结构的实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package services

import (
"net/http"
"testing"

"github.com/SophieDeBenedetto/golang-microservices/src/api/clients/restclient"
"github.com/SophieDeBenedetto/golang-microservices/src/api/domain/repositories"
"github.com/SophieDeBenedetto/golang-microservices/src/api/utils/mocks"
"github.com/stretchr/testify/assert"
)

func init() {
restclient.Client = &mocks.MockClient{}
}

然后,在测试函数的主体中,我们将设置 mocks.GetDoFunc 为返回所需响应的匿名函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func TestCreateRepoSuccess(t *testing.T) {
// build response JSON
json := `{"name":"Test Name","full_name":"test full name","owner":{"login": "octocat"}}`
// create a new reader with that JSON
r := ioutil.NopCloser(bytes.NewReader([]byte(json)))
mocks.GetDoFunc = func(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: 200,
Body: r,
}, nil
}
request := repositories.CreateRepoRequest{Name: "Test Name"}
resp, err := RepoService.CreateRepo(request)
assert.NotNil(t, resp)
assert.Nil(t, err)
assert.EqualValues(t, "Test Name", resp.Name)
assert.EqualValues(t, "octocat", resp.Owner)
}

在这里,我们构建了“成功”响应 JSON,并将其转换为一个新的 reader,我们可以通过调用 bytes.NewReader 来模拟响应正文。

然后,我们设置 mocks.GetDoFunc 一个匿名函数,该函数返回一个 http.Reponse 带有 200 状态代码和给定主体的实例。

现在,当我们 调用 RepoService.CreateRepo时,最终运行的是 restclient.Client.Do ,它将返回匿名函数中定义的模拟响应。这可以让我们在测试用例中编写简单明了的断言。

陷阱!

在结束之前,让我们先回顾一下我在发出两个并发 Web 请求的函数编写测试时遇到的“陷阱”。

当我们如上设置 mocks.GetDoFunc 时,我们只创建了一次 Read Closer,然后将实例 http.ResponseBody 属性设置为该 Read Closer。请记住,Read Closer 只能阅读一次。就像喝一杯水一样——一旦你把杯子喝干,它就消失了。你不可能再回到同一个杯子里,在不倒满杯子的情况下再次喝它。

因此,如果我们在发出两个 Web 请求的测试中使用上述方法,则首先解析的请求将耗尽响应正文。第二个请求尝试读取响应将导致错误,因为响应正文将为空。

为了避免这个问题,我们需要确保每次 mocks.GetDoFunc 测试运行调用时都会动态生成模拟响应正文的 Read Closer。因此,我们需要将 ioutil.Nopcloser 移动到我们设置匿名函数 mocks.GetDoFunc 主体 中。

1
2
3
4
5
6
7
8
json := `{"name":"Test Name","full_name":"test full name","owner":{"login": "octocat"}}`
mocks.GetDoFunc = func(*http.Request) (*http.Response, error) {
r := ioutil.NopCloser(bytes.NewReader([]byte(json)))
return &http.Response{
StatusCode: 200,
Body: r,
}, nil
}

这样,我们的测试可以尽情模拟和读取任意数量的 Web 请求的响应。

httptest

这部分为笔者补充。

相比于作者这样自己实现一套 mock HTTP 请求/响应的,其实 Go 本身也提供了一个非常好的用于 HTTP 测试的实用程序包 —— httptest。该包内部 Server 被描述为:

Server 是一个 HTTP 服务器,监听本地环回接口上的系统选择的端口,用于端到端 HTTP 测试。

具体使用可参考 httptest

彦祖老师 wechat