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开始使用标准方法来模拟调用。