良好的单元测试不仅可以在代码发布前验证代码是否可用,并且可以保护代码在以后的某一次修改中功能不被破坏。好的单元测试需要有自我隔离、可靠、无状态等特性。以下介绍五种 mock 技术。
当需要 mock 一个包级别函数的时候使用。
以下代码打开一个数据库连接:
func OpenDB(user, password, addr, db string) (*sql.DB, error) {
conn := fmt.Sprintf("%s:%s@%s/%s", user, password, addr, db)
return sql.Open("mysql", conn)
}
为了 mock 出 sql.Open
,可以这样做:
type (
// 将 sql 包的 sql.Open 函数保存在一个变量
sqlOpener func(string, string) (*sql.DB, error)
)
// 现在调用 OpenDB 需要将 sql.Open 传入
func OpenDB(user, password, addr, db string, open sqlOpener) (*sql.DB, error) {
conn := fmt.Sprintf("%s:%s@%s/%s", user, password, addr, db)
return open("mysql", conn)
}
将直接在函数 OpenDB
调用 sql.Open
转化为:将 sqlOpener
函数作为入参。
在源码中只需将 sql.Open
函数显式传入,如下:
OpenDB(“myUser”, “myPass”, “localhost”, “foo”, sql.Open)
在测试时,可以这样 mock 出该 sql.Open
函数:
func TestOpenDB(t *testing.T) {
mockError := errors.New("uh oh")
subtests := []struct {
name string
u, p, a, db string
sqlOpener func(string, string) (*sql.DB, error)
expectedErr error
}{
{
name: "happy path",
u: "u",
p: "p",
a: "a",
db: "db",
// mock 出 sql.Open 函数,传入 OpenDB 方法。
sqlOpener: func(s string, s2 string) (db *sql.DB, err error) {
if s != "u:p@a/db" {
return nil, errors.New("wrong connection string")
}
return nil, nil
},
},
{
name: "error from sqlOpener",
sqlOpener: func(s string, s2 string) (db *sql.DB, err error) {
return nil, mockError
},
expectedErr: mockError,
},
}
for _, subtest := range subtests {
t.Run(subtest.name, func(t *testing.T) {
_, err := OpenDB(subtest.u, subtest.p, subtest.a, subtest.db, subtest.sqlOpener)
if !errors.Is(err, subtest.expectedErr) {
t.Errorf("expected error (%v), got error (%v)", subtest.expectedErr, err)
}
})
}
}
这种方法的缺点是:
sql.Open
传入;sql
包。当需要 mock 一个包级别函数的时候使用。与 1. Higher-Order Functions 类似,但不将 sql.Open
函数作为入参,而是将 sql.Open
存为一个包级别变量:
var (
SQLOpen = sql.Open
)
func OpenDB(user, password, addr, db string) (*sql.DB, error) {
conn := fmt.Sprintf("%s:%s@%s/%s", user, password, addr, db)
// 直接调用包级别变量 SQLOpen.
return SQLOpen("mysql", conn)
}
该方法相当于将 OpenDB
中的一些函数调用抽离出来,在源码中,调用 OpenDB
前,需要确保已经给 SQLOpen
赋好值。
当需要 mock 的时候,先修改 SQLOpen
变量,再调用 OpenDB
函数:
for _, subtest := range subtests {
t.Run(subtest.name, func(t *testing.T) {
// 先将 SQLOpen 替换为 mock 出的函数,再调用 OpenDB.
SQLOpen = subtest.sqlOpener
_, err := OpenDB(subtest.u, subtest.p, subtest.a, subtest.db)
if !errors.Is(err, subtest.expectedErr) {
t.Errorf("expected error (%v), got error (%v)", subtest.expectedErr, err)
}
})
}
缺点:
当需要 mock 一个类型的方法时使用。最简单的方法是定义一个接口,描述需要的所有操作。
以下例子中 ReadContents
函数从一个文件读取指定字节数并返回:
func main() {
f, err := os.Open("foo.txt")
if err != nil {
fmt.Printf("error opening file %v \n", err)
}
data, err := ReadContents(f, 50)
if err != nil {
fmt.Printf("error from ReadContents %v \n", err)
}
fmt.Printf("data from file: %s", string(data))
}
// ReadContents 入参为 *os.File 类型,但只用到了其 Close 和 Read 方法。
// 为了替换 f *os.File,将其签名类型替换为 io.ReadCloser.
func ReadContents(f *os.File, numBytes int) ([]byte, error) {
defer f.Close()
data := make([]byte, numBytes)
_, err := f.Read(data)
if err != nil {
return nil, err
}
return data, nil
}
将 ReadContents
入参替换为接口类型 ReadCloser
:
func ReadContents(rc io.ReadCloser, numBytes int) ([]byte, error) {
defer rc.Close()
data := make([]byte, numBytes)
_, err := rc.Read(data)
if err != nil {
return nil, err
}
return data, nil
}
这时,我们只需 mock 一个实现了 io.ReadCloser
的类型即可:
// mockReadCloser 实现了 io.ReadCloser 接口
type (
mockReadCloser struct {
expectedData []byte
expectedErr error
}
)
// mockReadCloser 的 Read 方法仅是将其 expectedData 拷贝到入参数组。
func (mrc *mockReadCloser) Read(p []byte) (n int, err error) {
copy(p, mrc.expectedData)
return 0, mrc.expectedErr
}
func (mrc *mockReadCloser) Close() error { return nil }
func TestReadContents(t *testing.T) {
subtests := []struct {
name string
rc io.ReadCloser
numBytes int
expectedData []byte
expectedErr error
}{
{
name: "happy path",
rc: &mockReadCloser{
expectedData: []byte(`hello`),
expectedErr: nil,
},
numBytes: 5,
expectedData: []byte(`hello`),
expectedErr: nil,
},
}
for _, subtest := range subtests {
t.Run(subtest.name, func(t *testing.T) {
// 测试时不再需要将 *os.File 类型传入,而将自定义的 mockReadCloser 传入。
data, err := ReadContents(subtest.rc, subtest.numBytes)
if !reflect.DeepEqual(data, subtest.expectedData) {
t.Errorf("expected (%b), got (%b)", subtest.expectedData, data)
}
if !errors.Is(err, subtest.expectedErr) {
t.Errorf("expected error (%v), got error (%v)", subtest.expectedErr, err)
}
})
}
}
上述例子中, expectedData
决定了调用 mock 出的类型的 Read
方法会返回何值。
当需要 mock 一个大型接口的一小部分方法时使用。
func main() {
// xxx.SomeLargeType 实现了大型接口 xxx.SomeLargeInterface
slt := xxx.NewSomeLargeType()
process(slt)
}
func Process(sli xxx.SomeLargeInterface) {
// Do something...
// 在 Process 函数中只用到 SomeLargeInterface 的 GetItem 方法
item := sli.GetItem()
// Do somehing else...
}
// external package xxx
// SomeLargeInterface is a large interface which contains large amount of methods.
type SomeLargeInterface interface {
...
}
根据 3. Interface Substitution,如果需要测试 process
函数,则需要 mock 出一个类型,并实现 xxx.SomeLargeInterface
中的每个方法以实现该接口,我们无需这样做,而是可以将类型 xxx.SomeLargeInterface
内置到新建的 mock 类型中,测试时,只需重写需要的方法即可:
type MockSomeLargeType struct {
xxx.SomeLargeInterface // This is the embedded interface.
}
// 重写 xxx.SomeLargeType 的 getItem 方法,
// 因为该方法在上述 Process 函数中被调用。
func (*MockSomeLargeType) GetItem() xxx.Item {
// return mock item
}
func TestProcess(t *testing.T) {
mockSLT := MockSomeLargeType{}
Process(mockSLT)
}
由于 Process
方法只用到了 MockSomeLargeType
的 GetItem
方法,不必再实现 SomeLargeInterface
的其他方法。
当需要测试的代码需要发出 HTTP 请求时使用。
net/http/httptest
包提供了一个监听本地环回接口 (Local Loopback Interface) 的服务器。要用该服务器替换实际的服务器,只需将 URL 作为参数,在测试时将 URL 替换为测试服务器的 URL。如以下需要测试 MakeHTTPCall
函数:
type Response struct {
ID int `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
}
// MakeHTTPCall 发出一个 HTTP 请求并返回一个 *Response 结构体。
func MakeHTTPCall(url string) (*Response, error) {
resp, err := http.Get(url)
if err != nil {
return nil, err
}
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
}
r := &Response{}
if err := json.Unmarshal(body, r); err != nil {
return nil, err
}
return r, nil
}
测试时传入测试服务器的 URL:
func TestMakeHTTPCall(t *testing.T) {
testTable := []struct {
name string
server *httptest.Server // 测试用的服务器
expectedResponse *Response
expectedErr error
}{
{
name: "happy-server-response",
server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"id": 1, "name": "kyle", "description": "novice gopher"}`))
})),
expectedResponse: &Response{
ID: 1,
Name: "kyle",
Description: "novice gopher",
},
expectedErr: nil,
},
}
for _, tc := range testTable {
t.Run(tc.name, func(t *testing.T) {
defer tc.server.Close()
resp, err := MakeHTTPCall(tc.server.URL) // MakeHTTPCall 将向 mock 服务器发出请求。
if !reflect.DeepEqual(resp, tc.expectedResponse) {
t.Errorf("expected (%v), got (%v)", tc.expectedResponse, resp)
}
if !errors.Is(err, tc.expectedErr) {
t.Errorf("expected (%v), got (%v)", tc.expectedErr, err)
}
})
}
}
上述代码首先用 httptest.NewServer
创建了一个服务器,并且该服务器对于任何请求都会往 http.ResponseWriter
中写入状态码 200 以及若干键值对。测试时,需要测试的代码中的 down stream HTTP call 就能获取到该结果。
Mocking Techniques For Go