Golang單元測試實戰(zhàn)一:httpMock

單元測試定義

之前看過一篇文章,里面講到單元測試的2個原則:

  • Unit tests should be pretty lightweight and run fast since they don’t depend on anything external to the unit of code being tested
  • The only way they can fail is by changing the unit of code they are testing

記錄一下最近對自己的代碼進行單元測試實踐

過程

被測代碼

package http_request_demo

import (
    "errors"
    "io/ioutil"
    "net/http"
)

func getAPIResponse(url string) (string, error) {
    var err error
    request, err := http.NewRequest("GET", url, nil)
    if err != nil {
        return "", err
    }
    myClient := &http.Client{}
    response, err := myClient.Do(request)
    if err != nil {
        return "", err
    }
    defer response.Body.Close()
    if response.StatusCode != 200 {
        return "", errors.New("response not 200!")
    }
    bodyBytes, err := ioutil.ReadAll(response.Body)
    if err != nil {
        return "", err
    }
    return string(bodyBytes), nil
}

這個簡單的函數(shù)主要做了以下幾件事:

  1. 構造http request
  2. 用http client發(fā)起請求
  3. 對請求動作的error進行判空、response的狀態(tài)碼判斷、然后從body中read數(shù)據(jù)

如何單元測試

真實發(fā)起請求并測試

拿到這個函數(shù)最開始就覺得很簡單,直接拿萬能的百度上手,梭哈完事

func TestGetAPIResponse(t *testing.T) {
    var api string
    var responseBody string
    var err error

    // first test
    api = "http://www.baidu.com"
    responseBody, err = getAPIResponse(api)
    if err != nil {
        t.Errorf("first test failed! err: %v", err)
    }
    if responseBody == "" {
        t.Errorf("first test failed! Unexpect response!")
    }
}

go test跑一下,完美

=== RUN   TestGetAPIResponse
--- PASS: TestGetAPIResponse (0.10s)
PASS
coverage: 73.3% of statements
ok      github.com/linhuaqing0928/golang-demo/http_mock_demo    0.319s

問題

但是這時候覆蓋率之后73.3% 再往上提升就麻煩了,而且明顯是不符合文章開頭說的2個單元測試的原則

  1. 如果www.baidu.com哪天掛了,我們這個UT就會fail 因為他還依賴了www.baidu.com這個第三方 而且這個第三方不是我們可以控制的。
  2. 要提升覆蓋率的話,我們需要構造http.NewRequest("GET", url, nil)返回error、response.StatusCode != 200等場景,而因為我們無法控制www.baidu.com的返回,所以覆蓋率基本就提升不上去了。

怎么解決這個問題呢,很容易想到幾個mock方案:

  1. 找一臺服務器,上面搭建一個自己的服務 然后自己定制化的根據(jù)接收到的request內(nèi)容,返回自定義的內(nèi)容。 -- 這個方案確實一定程度解決了這個問題,但是存在2個問題:
  • 成本太高,需要維護一個新的服務
  • 跑UT的機器必須和這臺服務器網(wǎng)絡是通的。
  1. 從根本上解決問題,不再發(fā)起網(wǎng)絡請求。在client發(fā)起request請求的時候,直接攔截然后進行response返回的模擬。 -- 本文的主角httpmock出現(xiàn)了

httpmock

直接貼github地址:https://github.com/jarcoal/httpmock
readme講解的很清楚了,使用方法基本就是以下幾步:

  1. 調用Activate方法啟動httpmock環(huán)境
  2. 通過httpmock.RegisterResponder方法進行mock規(guī)則注冊。
  3. 這時候再通過http client發(fā)起的請求就都會被httpmock攔截,如果匹配到剛剛注冊的規(guī)則就會按照注冊的內(nèi)容返回對應response。 --- 這里感覺httpmock有一點不太好用,那就是如果請求沒有命中規(guī)則就會錯誤返回,而不是走真實請求。需要把整個過程涉及到的所有請求都注冊上去。
  4. 在defer里面調用DeactivateAndReset結束mock
func TestFetchArticles(t *testing.T) {
  httpmock.Activate()
  defer httpmock.DeactivateAndReset()

  // Exact URL match
  httpmock.RegisterResponder("GET", "https://api.mybiz.com/articles",
    httpmock.NewStringResponder(200, `[{"id": 1, "name": "My Great Article"}]`))

  // Regexp match (could use httpmock.RegisterRegexpResponder instead)
  httpmock.RegisterResponder("GET", `=~^https://api\.mybiz\.com/articles/id/\d+\z`,
    httpmock.NewStringResponder(200, `{"id": 1, "name": "My Great Article"}`))

  // do stuff that makes a request to articles
  ...

  // get count info
  httpmock.GetTotalCallCount()

  // get the amount of calls for the registered responder
  info := httpmock.GetCallCountInfo()
  info["GET https://api.mybiz.com/articles"] // number of GET calls made to https://api.mybiz.com/articles
  info["GET https://api.mybiz.com/articles/id/12"] // number of GET calls made to https://api.mybiz.com/articles/id/12
  info[`GET =~^https://api\.mybiz\.com/articles/id/\d+\z`] // number of GET calls made to https://api.mybiz.com/articles/id/<any-number>
}

實戰(zhàn)

在自己的單元測試里面使用httpmock

func TestGetAPIResponse(t *testing.T) {
    var api string
    var responseBody string
    var err error

    // url nil test
    api = "://新"
    responseBody, err = getAPIResponse(api)
    if err == nil {
        t.Errorf("url nil test failed! err: %v", err)
    }
    if responseBody != "" {
        t.Errorf("url nil test failed! Unexpect response!")
    }

    // http mock test
    var mockResponse string
    api = "http://www.baidu.com"
    mockResponse = "mock response body"
    httpmock.Activate()
    defer httpmock.DeactivateAndReset()
    httpmock.RegisterResponder("GET", api, httpmock.NewStringResponder(200, string(mockResponse)))
    responseBody, err = getAPIResponse(api)
    if err != nil {
        t.Errorf("second test failed! err: %v", err)
    }
    if responseBody != mockResponse {
        t.Errorf("second test failed! Unexpect response!")
    }

    // request fail test
    api = "http://www.baidu.com/fail"
    httpmock.RegisterResponder("GET", api,
        func(r *http.Request) (*http.Response, error) {
            return &http.Response{
                Status:        strconv.Itoa(200),
                StatusCode:    200,
                ContentLength: -1,
            }, errors.New("fail error!")
        })
    responseBody, err = getAPIResponse(api)
    if err == nil {
        t.Errorf("request fail test failed! err: %v", err)
    }
    if responseBody != "" {
        t.Errorf("request fail test failed! Unexpect response! response: %s", responseBody)
    }

    // wrong status test
    api = "http://www.baidu.com/wrongstatus"
    mockResponseWrong := "mock response body"
    httpmock.RegisterResponder("GET", api, httpmock.NewStringResponder(404, string(mockResponseWrong)))
    responseBody, err = getAPIResponse(api)
    if err == nil {
        t.Errorf("wrong request test failed! err: %v", err)
    }
    if responseBody != "" {
        t.Errorf("wrong request test failed! Unexpect response! response: %s", responseBody)
    }
}
// === RUN   TestGetAPIResponse
// 2021/07/16 16:14:53 RoundTripper returned a response & error; ignoring response
// --- PASS: TestGetAPIResponse (0.00s)
// PASS
// coverage: 93.3% of statements
// ok      github.com/linhuaqing0928/golang-demo/http_mock_demo    0.458s

可以看到因為可以定制化返回結果,所以我們的覆蓋率能夠達到93.3% 同時最重要的是,我們達到了開頭說的單元測試的2個原則。

httpmock原理解析

  1. Activate函數(shù)中通過http.DefaultTransport = DefaultTransport修改了所有通過http/net包發(fā)送的請求的transport
  2. DefaultTransport通過調用NewMockTransport方法實例化了一個MockTransport來代替DefaultTransport。這個MockTransport實現(xiàn)了http包中的RoundTripper接口。transport源碼
  3. 再來看一下MockTransport的結構體和RegisterResponder注冊函數(shù):
type MockTransport struct {
    mu               sync.RWMutex
    responders       map[internal.RouteKey]Responder
    regexpResponders []regexpResponder
    noResponder      Responder
    callCountInfo    map[internal.RouteKey]int
    totalCallCount   int
}

func (m *MockTransport) RegisterResponder(method, url string, responder Responder) {
    if isRegexpURL(url) {
        m.registerRegexpResponder(regexpResponder{
            origRx:    url,
            method:    method,
            rx:        regexp.MustCompile(url[2:]),
            responder: responder,
        })
        return
    }

    key := internal.RouteKey{
        Method: method,
        URL:    url,
    }

    m.mu.Lock()
    m.responders[key] = responder
    m.callCountInfo[key] = 0
    m.mu.Unlock()
}

可以看到其實就是在MockTransport中維護一組map

  • key是正則匹配的路由
  • value是則是type Responder func(http.Request) (http.Response, error)
  1. 然后RegisterResponder的時候就是往這個map里面塞內(nèi)容

最后

以上就是對http的使用和源碼簡單解讀,大家可以看到就算引入了httpmock,最后的代碼覆蓋率還是達不到100% 因為bodyBytes, err := ioutil.ReadAll(response.Body)這個部分我們構造不了err的場景。

讀一下http的response源碼:

    // Body represents the response body.
    //
    // The response body is streamed on demand as the Body field
    // is read. If the network connection fails or the server
    // terminates the response, Body.Read calls return an error.
    //
    // The http Client and Transport guarantee that Body is always
    // non-nil, even on responses without a body or responses with
    // a zero-length body. It is the caller's responsibility to
    // close Body. The default HTTP client's Transport may not
    // reuse HTTP/1.x "keep-alive" TCP connections if the Body is
    // not read to completion and closed.
    //
    // The Body is automatically dechunked if the server replied
    // with a "chunked" Transfer-Encoding.
    //
    // As of Go 1.12, the Body will also implement io.Writer
    // on a successful "101 Switching Protocols" response,
    // as used by WebSockets and HTTP/2's "h2c" mode.
    Body io.ReadCloser

就會發(fā)現(xiàn)這個報錯的出現(xiàn)場景:If the network connection fails or the server terminates the response, Body.Read calls return an error。 這個就不在我們單元測試能覆蓋的范圍內(nèi)了,所以這個覆蓋率無法滿足就是合理的。

最后編輯于
?著作權歸作者所有,轉載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容