單元測試定義
之前看過一篇文章,里面講到單元測試的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ù)主要做了以下幾件事:
- 構造http request
- 用http client發(fā)起請求
- 對請求動作的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個單元測試的原則
- 如果www.baidu.com哪天掛了,我們這個UT就會fail 因為他還依賴了www.baidu.com這個第三方 而且這個第三方不是我們可以控制的。
- 要提升覆蓋率的話,我們需要構造http.NewRequest("GET", url, nil)返回error、response.StatusCode != 200等場景,而因為我們無法控制www.baidu.com的返回,所以覆蓋率基本就提升不上去了。
怎么解決這個問題呢,很容易想到幾個mock方案:
- 找一臺服務器,上面搭建一個自己的服務 然后自己定制化的根據(jù)接收到的request內(nèi)容,返回自定義的內(nèi)容。 -- 這個方案確實一定程度解決了這個問題,但是存在2個問題:
- 成本太高,需要維護一個新的服務
- 跑UT的機器必須和這臺服務器網(wǎng)絡是通的。
- 從根本上解決問題,不再發(fā)起網(wǎng)絡請求。在client發(fā)起request請求的時候,直接攔截然后進行response返回的模擬。 -- 本文的主角httpmock出現(xiàn)了
httpmock
直接貼github地址:https://github.com/jarcoal/httpmock
readme講解的很清楚了,使用方法基本就是以下幾步:
- 調用Activate方法啟動httpmock環(huán)境
- 通過httpmock.RegisterResponder方法進行mock規(guī)則注冊。
- 這時候再通過http client發(fā)起的請求就都會被httpmock攔截,如果匹配到剛剛注冊的規(guī)則就會按照注冊的內(nèi)容返回對應response。 --- 這里感覺httpmock有一點不太好用,那就是如果請求沒有命中規(guī)則就會錯誤返回,而不是走真實請求。需要把整個過程涉及到的所有請求都注冊上去。
- 在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原理解析
- Activate函數(shù)中通過http.DefaultTransport = DefaultTransport修改了所有通過http/net包發(fā)送的請求的transport
- DefaultTransport通過調用NewMockTransport方法實例化了一個MockTransport來代替DefaultTransport。這個MockTransport實現(xiàn)了http包中的RoundTripper接口。transport源碼
- 再來看一下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)
- 然后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)了,所以這個覆蓋率無法滿足就是合理的。