程序员社区

Go:单元测试之模拟HTTP调用

Go:单元测试之模拟HTTP调用插图

Go开发中单元测试是写代码的一个必备环节,它可以保证你写的代码接口逻辑符合预期。但是很多时候,在写单测时需要使用有一些外部资源,最常见的包括数据库调用、http调用或者rpc调用等等。

解决单测中调用外部资源的一个常见方法就是使用Mock技术,也就是所谓的模拟一个外部资源调用过程。其实模拟的本质就是面向对象思想中的接口实现,在Go语言中要调用一个外部资源,可以自定义一个结构体,然后该结构体实现外部资源所包含的接口方法,就能通过调用自定义的结构体方法来模拟实际外部资源调用。

今天我们以HTTP调用为例来说明mock的使用。因为http的调用涉及客户端和服务端两个方面,因此我们要模拟的话可以选择模拟客户端,也可以选择模拟服务的。

选择模拟客户端的话,需要更改API实现以使用HTTPClient接口。从长远来看,这是一个相当大的问题,因为你不知道在下一个版本的Golang代码库中,HTTP客户端会有什么变更。如果mock HTTP客户端,就会遇到这个问题。所以,在本文中,我们将使用内置的测试库模拟HTTP服务器。不需要创建自己的接口,因为它都是由Golang标准库httptest提供的。

目录结构

$ go mod init example
go: creating new go.mod: module example
$ mkdir -p external
$ touch external/{external.go,external_test.go}
$ tree .
.
├── external
│   ├── external.go
│   └── external_test.go
└── go.mod
1 directory, 3 files

代码实现

package external

import (
    "context"
    "encoding/json"
    "errors"
    "fmt"
    "net/http"
    "time"
)

var ErrResponseNotOk = errors.New("response not ok")

type (
    //待测试接口返回数据类型定义
    Data struct {
        ID   string `json:"id"`
        Name string `json:"name"`
    }

    //该接口FetchData方法需要调用外部http服务
    External interface {
        FetchData(ctx context.Context, id string) (*Data, error)
    }

    //定义结构体用户http调用
    v1 struct {
        baseURL string
        client  *http.Client
        timeout time.Duration
    }
)

//创建结构体初始化
func New(baseURL string, client *http.Client, timeout time.Duration) *v1 {
    return &v1{
        baseURL: baseURL,
        client:  client,
        timeout: timeout,
    }
}

//FetchData是需要写单测的接口方法,内部包含调用外部http服务
func (v *v1) FetchData(ctx context.Context, id string) (*Data, error) {
    url := fmt.Sprintf("%s/?id=%s", v.baseURL, id)

    ctx, cancel := context.WithTimeout(ctx, v.timeout)
    defer cancel()

    req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
    if err != nil {
        return nil, err
    }

    resp, err := v.client.Do(req)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusOK {
        return nil, fmt.Errorf("%w. %s", ErrResponseNotOk,
            http.StatusText(resp.StatusCode))
    }
    var d *Data
    return d, json.NewDecoder(resp.Body).Decode(&d)
}

上面的External接口就是需要写的单元测试接口,其中FetchData方法内部包含调用外部http服务,因此需要我们实现http调用的模拟过程。

让我们来完成要模拟的External接口。

package external_test

import (
    "example/external"
    "fmt"
    "net/http"
    "net/http/httptest"
    "testing"
    "time"
)

var (
    server *httptest.Server
    ext external.External
)

func TestMain(m *testing.M)  {
    fmt.Println("mocking server")
        //模拟http服务端,httptest.NewServer返回一个实现http.Server接口的实例
    server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter,
        r *http.Request) {
        //这里是模拟服务端接口处理程序
    }))
    
    fmt.Println("mocking external")
    ext = external.New(server.URL, http.DefaultClient, time.Second)
    
    fmt.Println("run tests")
    m.Run()
}

首先需要模拟HTTP服务端以及External对象。如代码25行所示:

...
ext = external.New(server.URL, http.DefaultClient, time.Second)
...

可以使用server.URL作为baseURL,因此HTTP调用baseURL时将路由到httptest.Server中。也就是我们模拟的HTTP服务端,而不是实际的HTTP调用。

创建了模拟的http服务端之后,需要实现模拟服务端的接口处理函数,如下所示:

//模拟服务的响应
func mockFetchDataEndPoint(w http.ResponseWriter, r *http.Request)  {
    ids, ok := r.URL.Query()["id"]
    
    sc := http.StatusOK
    m := make(map[string]interface{})
    
    if !ok || len(ids[0]) == 0 {
        sc = http.StatusBadRequest
    } else {
        m["id"] = "mock"      //FetchData接口的返回值Data.ID
        m["name"] = "mock"    ////FetchData接口的返回值Data.Name
    }
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(sc)
    //模拟返回值响应
    json.NewEncoder(w).Encode(m)
}

然后,将接口处理程序放在http模拟服务端中并添加路由。

...
func TestMain(m *testing.M)  {
    fmt.Println("mocking server")
    server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter,
        r *http.Request) {
        switch strings.TrimSpace(r.URL.Path) {
        case "/":
            mockFetchDataEndPoint(w, r)
        default:
            http.NotFoundHandler().ServeHTTP(w, r)
        }
    }))
...

创建单元测试

...
func fatal(t *testing.T, want, got interface{}) {
    t.Helper()
    t.Fatalf(`want: %v, got: %v`, want, got)
}
func TestExternal_FetchData(t *testing.T) {
    tt := []struct {
        name     string
        id       string
        wantData *external.Data
        wantErr  error
    }{
        {
            name:     "response not ok",
            id:       "",
            wantData: nil,
            wantErr:  external.ErrResponseNotOk,
        },
        {
            name: "data found",
            id:   "mock",
            wantData: &external.Data{
                ID:   "mock",
                Name: "mock",
            },
            wantErr: nil,
        },
    }
    for i := range tt {
        tc := tt[i]
        t.Run(tc.name, func(t *testing.T) {
            t.Parallel()
            gotData, gotErr := ext.FetchData(context.Background(), tc.id)
            if !errors.Is(gotErr, tc.wantErr) {
                fatal(t, tc.wantErr, gotErr)
            }
            if !reflect.DeepEqual(gotData, tc.wantData) {
                fatal(t, tc.wantData, gotData)
            }
        })
    }
}

现在您已经模拟了HTTP服务器,单元测试本身并没有什么特别之处。您可以和平常一样开始编写单元测试。

总结:

通过实现http模拟服务端,您已经大大改进了单元测试。与模拟HTTP客户端相比,模拟HTTP服务器更具可读性也更合适。您不需要创建HTTP客户端的接口,并通过使用httptest开始使用标准方法来模拟调用。

赞(0) 打赏
未经允许不得转载:IDEA激活码 » Go:单元测试之模拟HTTP调用

一个分享Java & Python知识的社区