Go的五种mock技术

简介

良好的单元测试不仅可以在代码发布前验证代码是否可用,并且可以保护代码在以后的某一次修改中功能不被破坏。好的单元测试需要有自我隔离、可靠、无状态等特性。以下介绍五种 mock 技术。

1. Higher-Order Functions

当需要 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 包。

2. Monkey Patching

当需要 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)
    }
  })
}

缺点:

  • 因为多个测试都操纵同一个变量,可能导致不可以并行运行测试;
  • 如果运行测试的包和要测试的函数不在同一个包,需要将变量可见性声明为 public,这导致用户可以替换该变量,进而导致未知的结果。

3. Interface Substitution

当需要 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 方法会返回何值。

4. Embedding Interfaces

当需要 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 方法只用到了 MockSomeLargeTypeGetItem 方法,不必再实现 SomeLargeInterface 的其他方法。

5. Mocking out Downstream HTTP Calls

当需要测试的代码需要发出 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

你可能感兴趣的:(Go,golang)