从头到脚说单测——谈有效的单元测试
導語?非常幸運的是,從4月份至今,我能夠全身心投入到騰訊新聞的單元測試專項任務中,從無知懵懂,到不斷深入理解的過程,與開發同學互幫互助,受益匪淺。在此過程中,得到了質量總監、新聞總監和喬幫主的傾囊指導,真心感謝!!我希望把所有心得,總結成一篇較為全面的文章,分享給其他團隊。時刻牢記:1. 不要濫用mock 2. 基于意圖。
在我們談到單元測試,大都清楚是測試函數符合預期,國外很多大公司都將單測執行的很好,國內成功的案例則相對有限。在本文中,筆者將在騰訊新聞項目中親身經歷單測從無到有的實踐過程梳理為可讀可參考的經驗分享出來。在實踐的過程我發現,單測可以推動產品質量轉為優秀,推動實行它的過程更需要對它有真實的認識以及一套方法論。
我曾經認為,單元測試面向的是一個函數。任何走出一個函數的測試,都不是單元測試。
其實,對“單元”的定義取決于自己。如果你正在使用函數式編程,一個單元最有可能指的是一個函數。你的單元測試將使用不同的參數調用這個函數,并斷言它返回了期待的結果;在面向對象語言里,下至一個方法,上至一個類都可以是一個單元(從一個單一的方法到一整個的類都可以是一個單元)。意圖很重要(“意圖”二字是本文中第一次提到,它很重要)
我們有單元測試、增量測試、集成測試、回歸測試、冒煙測試等等,名字非常多。谷歌看到這種“百家爭鳴”的現象,創立了自己的命名方式,只分為小型測試、中型測試和大型測試。
小型測試,針對單個函數的測試,關注其內部邏輯,mock所有需要的服務。小型測試帶來優秀的代碼質量、良好的異常處理、優雅的錯誤報告
中型測試,驗證兩個或多個制定的模塊應用之間的交互
大型測試,也被稱為“系統測試”或“端到端測試”。大型測試在一個較高層次上運行,驗證系統作為一個整體是如何工作的。
資源 | 小型測試 | 中型測試 | 大型測試 |
網絡訪問 | 否 | 僅訪問localhost | 是 |
數據庫訪問 | 否 | 是 | 是 |
訪問文件 | 否 | 是 | 是 |
訪問用戶界面 | 否 | 否 | 是 |
使用外部服務 | 否 | 不鼓勵,可mock | 是 |
多線程 | 否 | 是 | 是 |
使用sleep語句 | 否 | 是 | 是 |
使用系統屬性設置 | 否 | 是 | 是 |
運行時間限制(毫秒) | 60 | 300 | 900+ |
強制時間限制(分鐘) | 1 | 5 | 15 |
小型測試 | 中型測試 | 大型測試 | |
對應測試類型 | 單元測試 | 單元測試+邏輯層測試(泛單元或分層測試) | UI測試或接口測試 |
結論:我們的單元測試,既可以針對一個函數寫case,也可以按照函數的調用關系串起來寫case。
在金字塔模型之前,流行的是冰淇淋模型。包含了大量的手工測試、端到端的自動化測試及少量的單元測試。造成的后果是,隨著產品壯大,手工回歸測試時間越來越長,質量很難把控;自動化case頻頻失敗,每一個失敗對應著一個長長的函數調用,到底哪里出了問題?單元測試少的可憐,基本沒作用。
Mike Cohn 在他的著作《Succeeding with Agile》一書中提出了“測試金字塔”這個概念。這個比喻非常形象,它讓你一眼就知道測試是需要分層的。它還告訴你每一層需要寫多少測試。
測試金字塔本身是一條很好的經驗法則,我們最好記住Cohn在金字塔模型中提到的兩件事:
編寫不同粒度的測試
層次越高,你寫的測試應該越少
同時,我們對金字塔的理解絕不能止步于此,要進一步理解:
我把金字塔模型理解為——冰激凌融化了。就是指,最頂部的“手工測試”理論上全部要自動化,向下融化,優先全部考慮融化成單元測試,單元測試覆蓋不了的 放在中間層(分層測試),再覆蓋不了的才會放到UI層。因此,UI層的case,能沒有就不要有,跑的慢還不穩定。按照喬幫主的說法,我不分單元測試還是分層測試,統一都叫自動化測試,那就應該把所有的自動化case看做一個整體,case不要冗余,單元測試能覆蓋,就要把這個case從分層或ui中去掉。
越是底層的測試,牽扯到相關內容越少,而高層測試則涉及面更廣。比如單元測試,它的關注點只有一個單元,而沒有其它任何東西。所以,只要一個單元寫好了,測試就是可以通過的;而集成測試則要把好幾個單元組裝到一起才能測試,測試通過的前提條件是,所有這些單元都寫好了,這個周期就明顯比單元測試要長;系統測試則要把整個系統的各個模塊都連在一起,各種數據都準備好,才可能通過。
另外,因為涉及到的模塊過多,任何一個模塊做了調整,都有可能破壞高層測試,所以,高層測試通常是相對比較脆弱的,在實際的工作中,有些高層測試會牽扯到外部系統,這樣一來,復雜度又在不斷地提升。
這個問題我們規避不掉。新聞是這次研發模式改革的主力軍之一,所以自上而下的推動讓這個問題不那么棘手:做了就是做了。不做,卻又有那么多的理由:(搜集到的吐槽真實聲音)
單元測試浪費了太多的時間
單元測試僅僅是證明這些代碼做了什么
我是很棒的程序員,我是不是可以不進行單元測試?
后面的集成測試將會抓住所有的bug
單元測試的成本效率不高我把測試都寫了,那么測試人員做什么呢?
公司請我來是寫代碼,而不是寫測試
測試代碼的正確性,并不是我的工作
我覺得我們總監指導的很到位:改革,一是工作方式的改革,更難的是思想上的改革。
單元測試的意義
新聞的總監dot老師是至始至終推進單測的好領導,他講述了螺絲釘與飛機的故事:干貨 | 測試扁平化之必備神器:好的單元測試
單元測試對我們的產品質量是非常重要的。
單元測試是所有測試中最底層的一類測試,是第一個環節,也是最重要的一個環節,是唯一一次有保證能夠代碼覆蓋率達到100%的測試,是整個軟件測試過程的基礎和前提,單元測試防止了開發的后期因bug過多而失控,單元測試的性價比是最好的。
據統計,大約有80%的錯誤是在軟件設計階段引入的,并且修正一個軟件錯誤所需的費用將隨著軟件生命期的進展而上升。錯誤發現的越晚,修復它的費用就越高,而且呈指數增長的趨勢。作為編碼人員,也是單元測試的主要執行者,是唯一能夠做到生產出無缺陷程序這一點的人,其他任何人都無法做到這一點
代碼規范、優化,可測試性的代碼
放心重構
自動化執行three-thousand times
下面這張圖,來自微軟的統計數據:bug在單元測試階段被發現,平均耗時3.25小時,如果漏到系統測試階段,要花費11.5小時。
下面這張圖,旨在說明兩個問題:85%的缺陷都在代碼設計階段產生,而發現bug的階段越靠后,耗費成本就越高,指數級別的增高。所以,在早期的單元測試就能發現bug,省時省力,一勞永逸,何樂而不為呢
單元測試特別耗時?
不能一刀切,不能只盯著單測階段的耗時。
我采訪了新聞客戶端、后臺的開發,首先肯定的是,單測會增加開發量、增加開發時長。
在《單元測試的藝術》這本書提到一個案例:找了開發能力相近的兩個團隊,同時開發相近的需求。進行單測的團隊在編碼階段時長增長了一倍,從7天到14天,但是,這個團隊在集成測試階段的表現非常順暢,bug量小,定位bug迅速等。最終的效果,整體交付時間和缺陷數,均是單測團隊最少。
單測,存在即合理。一方面,需要把單測放在整個迭代周期來觀測其效果;一方面,寫單測也是技術活,寫得好的同學,時間少代碼質量高(也即,不是說寫了單測,就能寫好單測)
誰來寫單測呢?
開發同學寫單測
測試同學具有寫單測的能力。重點在于開發腳手架、分層測試/端到端測試
增量還是存量
單測case針對增量代碼
當存量代碼出現大規模重構,后者質量暴露出極大風險時,都是推動補全單測的好時機
一. 廣義的單元測試,我們指這三部分的有機組合:
code review
靜態代碼掃描
單元測試用例編寫
二. 結合新聞的實踐,我把單測成長的過程分為4個目標,分別為:
會寫,全員可寫
寫的好,同時關注可測性問題,試點解決
識別可測性問題,熟練使用重構方法進行重構;識別代碼架構設計問題;case與業務代碼同步編寫
TDD。但這個目標是期望,不能作為必須實現的目標。
截至發稿當天,新聞處于第三階段,即,每個迭代均能產出高質量的case,人數覆蓋和需求覆蓋均較高;關注重點在于可測性,時刻注重重構。
還挺尷尬的,不太有直接的指標去衡量單測的效果。我們也經常被問到,“怎么證明你們新聞單測的作用呀?”
bug類指標(間接指標):連續迭代的bug總數趨勢、迭代內新建bug的趨勢、千行bug率
單測的需求覆蓋度(50%以上),參與人員覆蓋度(80%以上)
單測case總數趨勢,代碼行增量趨勢
增量代碼的行覆蓋率(接入層80%,客戶端30%)
單函數圈復雜度(低于40),單函數代碼行數(低于80),掃描告警數
在迭代需求持續高吞吐量的前提下,以新聞iOS的數據為例:
go單元測試框架選型基本選型:testify + gomonkey
附加:httptest + sqlmock
前提
測試文件,以_test.go結尾,與被測文件放于相同目錄
測試函數,函數名以Test開頭,并且隨后的第一個字符必須為大寫字母或下劃線,如:TestParseReq_CorrectNum_TableDriven
測試函數,參數為t *testing.T;對于bench測試,參數為b *testing.B
運行命令行,我的文章有深入講解:go test命令行
testify常規用法
https://github.com/stretchr/testify
testify基于gotesting編寫,所以語法上、執行命令行與go test完全兼容
支持大量高效的api,比如:
??? assert.Equal:常規對比,是把兩者分別換成[]byte去嚴格比對
??? assert.Nil:判斷對象為nil時,有時對err判空時也用
??? assert.Error:判斷err的具體類型和內容
??? assert.JSONEq:這個比較有用,對比map時;或者對比struct的時候,也會先轉為map,在用這個api去做對比,如下面這個例子,我封裝了建議的方法去將struct轉換為string(json):
支持suite,用例集管理
運行時,可以指定用例集執行
自帶mock工具,但只支持接口方法的mock,而且用法相對復雜
table-driven
gomonkey用法(加粗字體表示常用)
https://github.com/agiledragon/gomonkey
https://studygolang.com/articles/15034
支持為一個函數打一個樁
支持為一個成員方法打一個樁
支持為一個全局變量打一個樁
支持為一個函數變量打一個樁
支持為一個函數打一個特定的樁序列
支持為一個成員方法打一個特定的樁序列
支持為一個函數變量打一個特定的樁序列
table-driven的方式定義一系列stub
注意,對內聯函數的Stub,go test命令行一定要加上參數才可生效。見官方文檔。所以,我的命令行默認加上-gcflags=all=-l就行了。
我設置了一些goland的代碼模板,放在附件中。
ApplyFunc是對外部函數Stub(非類方法)
/* 用法:gomonkey.ApplyFunc(被stub函數名, 被stub函數簽名) 函數返回值 *例子: patches := gomonkey.ApplyFunc(fake.Exec, func(_ string, _ ...string) (string, error) { return outputExpect, nil }) */ patches := gomonkey.ApplyFunc(lcache.GetCache, func(_ string) (interface{}, bool) { return getCommentsResp() }) defer patches.Reset()(左滑可查看完整代碼,下同)
ApplyMethod是對類函數Stub。但這里注意,要被stub的方式是私有方法,gomonkey通過反射是找不到的,有兩種解決方法:
1)使用增強版的gomonkey;
2)不Stub它,而是選擇走進這個函數,這個話題在后面專題談mock的時候說。
/* 用法:gomonkey.ApplyMethod(反射類名, 被stub函數簽名) 函數返回值 *例子: var s *fake.Slice patches := ApplyMethod(reflect.TypeOf(s), "Add", func(_ *fake.Slice, _ int) error { return nil }) */ var ac *auth.AuthCheck patches := gomonkey.ApplyMethod(reflect.TypeOf(ac), "PrepareWithHttp", func(_ *auth.AuthCheck, _ *http.Request, _ ...auth.AuthOption) error { return fmt.Errorf("prepare with nil object") }) defer patches.Reset()ApplyMethodSeq是對同一個Stub的函數返回不同的結果
/* 用法:gomonkey.ApplyMethodSeq(類的反射,"被stub函數名", 返回結構體); Params{info1},中括號內為被stub函數的返回值列表; Times為生效次數 *例子: e := &fake.Etcd{} info1 := "hello cpp" info2 := "hello golang" info3 := "hello gomonkey" outputs := []OutputCell{ {Values: Params{info1, nil}}, {Values: Params{info2, nil}}, {Values: Params{info3, nil}}, } patches := ApplyMethodSeq(reflect.TypeOf(e), "Retrieve", outputs) defer patches.Reset() */ conn := &redis.RedisConn{} patch1 := gomonkey.ApplyFunc(redis.NewRedisHTTP, func(serviceName string, _ string) *redis.RedisConn { conn := &redis.RedisConn{ redis.RedisConfig{}, &redis.RedisHelper{}, } return conn }) defer patch1.Reset() // mock redis data. 返回空和不為空的情況 outputCell := []gomonkey.OutputCell{ {Values: gomonkey.Params{"12", nil}, Times: 1}, {Values: gomonkey.Params{"", nil}, Times: 1}, } patchs := gomonkey.ApplyMethodSeq(reflect.TypeOf(conn.RedisHelper), "Get", outputCell) defer patchs.Reset()先舉這幾個例子,詳細的可以在上面的鏈接文章中全面得到。
這里補充一點,對類方法進行stub,必須要找到該方法對應的真實的類(結構體),舉個例子:
//被測函數中有如下一段,其中的Get方法我們想stub掉,只要找到Get方法對應的類就好了 readCountStr, _ := conn.Get(redisKey) if len(readCountStr) == 0 { return 0, nil }定位conn,是RedisConn類型的struct type RedisConn struct { RedisConfig *RedisHelper } 所以第一次,我用gomonkey.AppleyMethod時這么寫: patches := gomonkey.ApplyMethod(reflect.TypeOf(*RedisConn),"Get", func(_ *redis.RedisHelper,_ string, _ []string) ([]string, error){ return info,err_notNil }) defer?patches.Reset()運行時報了空指針panic,提示RedisConn沒有Get方法。
繼續追,原來Get是*RedisHelper的方法,組合到了RedisConn結構體中,共用方法。但我們使用gomonkey時,需要指向真正定義它的類
func (this *RedisHelper) Get(key string) (string, error) { return redigo.String(this.Do("GET", key))最終這么寫:
patches := gomonkey.ApplyMethod(reflect.TypeOf(giftData.rankRedisRD.RedisHelper),"Get", func(_ *redis.RedisHelper,_ string, _ []string) ([]string, error){ return info,err_notNil }) defer patches.Reset()必須說一說mock了test doubles
在《xUnit Test Patterns》一書中,作者首次提出test doubles(測試替身)的概念。我們常掛在嘴邊的mock只是其中一種,而且是最容易與Stub(打樁)混淆的一種。在上一節中對gomonkey的介紹,你可以注意到了,我沒有使用mock,全部是Stub。是的,gomonkey不是mock工具,只是一個高級打樁的工具,適配了我們大部分的使用場景。
測試替身,共有五種:可以參考這篇翻譯《xUnit Test Patterns》學習筆記6 - Test Double
Dummy Object:
????用于傳遞給調用者但是永遠不會被真實使用的對象,通常它們只是用來填滿參數列表
Test Stub
??? Stubs通常用于在測試中提供封裝好的響應,譬如有時候編程設定的并不會對所有的調用都進行響應。Stubs也會記錄下調用的記錄,譬如一個email gateway就是一個很好的例子,它可以用來記錄所有發送的信息或者它發送的信息的數目。簡而言之,Stubs一般是對一個真實對象的封裝
Test Spy
??? Test Spy像一個間諜,安插在了SUT內部,專門負責將SUT內部的間接輸出(indirect outputs)傳到外部。它的特點是將內部的間接輸出返回給測試案例,由測試案例進行驗證,Test Spy只負責獲取內部情報,并把情報發出去,不負責驗證情報的正確性
Mock Object
????針對設定好的調用方法與需要響應的參數封裝出合適的對象
Fake Object
??? Fake對象常常與類的實現一起起作用,但是只是為了讓其他程序能夠正常運行,譬如內存數據庫就是一個很好的例子。
stub與mock
打樁和mock應該是最容易混淆的,而且習慣上我們統一用mock去形容模擬返回的能力,習慣成自然,也就把mock常掛在嘴邊了。
就我的理解,stub可以理解為mock的子集,mock更強大一些:
mock可以驗證實現過程,驗證某個函數是否被執行,被執行幾次
mock可以依條件生效,比如傳入特定參數,才會使mock效果生效
mock可以指定返回結果
當mock指定任何參數都返回固定的結果時,它等于stub
只不過,go的mock工具gomock只基于接口生效,不適合新聞、企鵝號項目,而gomonkey的stub覆蓋了大部分的使用場景。
我把這一部分單獨放一章節,表現出它重要的意義。需要讀懂肖鵬的《mock七宗罪》,在gitchat上。
兩個門派
約從2004-2005年間,江湖上形成兩大門派:經典測試驅動開發派 和 mockist(mock極端派)。
先說mockist。他主張將被測函數?所有?調用的外面函數,全部mock。也即,只關注被測函數自己的一行行代碼,只要調用其他函數,全都mock掉,用假數據來測試。
再說經典測試驅動開發派,他們主張不要濫用mock,能不mock就不mock,被測單元也不一定是具體的一個函數,可能是多個函數,串起來。必要的時候再mock。
兩個門派相爭多年,理論各有利弊,至今仍然共存。存在即合理。比如mockist,使用了過多的mock,無法覆蓋函數接口,這部分又是很容易出錯的;經典派,串的太多,又被質疑是集成測試。
對于我們實際應用,不必強制遵從某一派,結合即可,需要的時候mock,盡量少mock,不用糾結。
什么時候適合mock
如果一個對象具有以下特征,比較適合使用mock對象:
該對象提供非確定的結果(比如當前的時間或者當前的溫度)
對象的某些狀態難以創建或者重現(比如網絡錯誤或者文件讀寫錯誤)
對象方法上的執行太慢(比如在測試開始之前初始化數據庫)
該對象還不存在或者其行為可能發生變化(比如測試驅動開發中驅動創建新的類)
該對象必須包含一些專門為測試準備的數據或者方法(后者不適用于靜態類型的語言,流行的Mock框架不能為對象添加新的方法。Stub是可以的。)
因此,不要濫用mock(stub),當被測方法中調用其他方法函數,第一反應應該走進去串起來,而不是從根部就mock掉了。
喬幫主介紹了一篇文章:像機器一樣思考
文章講述思考程序設計的根本思路——考慮輸入輸出。我們設計case,想要得到最全面的設計,根本是考慮全輸入全輸出的組合,當然,一方面,這么做耗時太大,很多時候是不可執行的;一方面,這不是想要的結果,要考慮投入產出比。這時,需要理論與實踐相結合,理論指導實踐,實踐精細理論。
先說理論
1. 還是從上篇文章說起,考慮輸入、輸出,就要先知道哪些屬于輸入輸出:
2. 白盒&黑盒設計
白盒法:
邏輯覆蓋(語句、分支、條件、條件組合等)
路徑(全路徑、最小線性無關路徑)
循環:結合5種場景(跳過循環、循環一次,循環最大次,循環m次命中、循環m次未命中)
黑盒法:
等價類:正確的,錯誤的(合法的,非法的)
邊界法:[1,10] ==> 0,1,2,9,10,11(是等價類的有效補充)
3. 結合應用
全輸入輸出,實施難度較大,轉而我們思考到業內大神們設計出白盒黑盒設計法,通過仔細思考,可以判斷出是對全輸入全輸出的方法論體現。
因此,白盒&黑盒用例設計法,每一種我都親自實踐,理解其優缺點,從設計覆蓋角度,條件組合>最小線性無關路徑>條件>分支>語句。
下面這張圖,是我早期思考用例設計時的一次實踐,現在回憶起來,它過度設計了。
但實際中,我們擔心“過度設計”,也還無法給出答案“用什么方法設計保證萬無一失”。
過度設計,也會使case脆弱
在有限的時間內,我們尋求收益較大化
1. 小函數&重要(計算,對象處理):盡量設計全面
2. 邏輯較重,代碼行數較多:分支、語句覆蓋 + 循環 + 典型的邊界處理(我們看個例子:GetUserGiftList)
3. 引出“基于實現”與“基于意圖”的設計:過多去Stub被測函數內部的調用,就越接近“基于實現”(第二次提到“基于意圖”)
這個話題是非常重要的。
基于意圖:思考函數最終想做什么,把被測函數當做黑盒,考慮其輸出輸出,而不要關注其中間是怎樣實現的,究竟生成了什么臨時變量,循環了幾次,有什么判斷等。
基于實現:輸入輸出我也考慮,中間怎么實現的我也考慮。mock就是一個好例子,比如我們寫一個case,我們會用mock去驗證函數內是否調用了哪個外部方法、調用了幾次,語句的執行順序是怎樣的。程序的變動比需求還快,重構隨時都有,稍有一變,case大批量失敗,這也是《mock七宗罪》中提到的一種情況。
我們要的是基于意圖,遠離基于實現。
dot老師和喬幫主給我們上了課程,結合實戰經驗,我總結如下:
“要么寫好,要么不寫”。case也是代碼,也需要維護,也有工作量,所以要寫的到位,而不是寫得多。寫了一堆沒用的,你還得維護,不如刪了。
拿到一個函數,先問問自己,這個函數要實現什么功能,最終輸出是什么;然后,問自己,這個函數的風險在哪里,哪部分邏輯不太自信,最容易出錯(計算、復雜的判斷、某異常分支的命中等)。這些才是我們case要覆蓋的點。
內聯函數、直接get/set,沒幾行沒什么邏輯的,只要你判斷沒什么風險,就不用寫case。
確定了要寫的case,再用分支條件組合、邊界等核心方面設計出具體用例,實施編寫。
可以結合新聞幾次單測case review記錄,來詳細理解。詳見我的KM文章
我們看一個具體的case:
拿到這個函數,作為測試同學的我先向開發了解該函數的意圖:對符合格式、符合時間的用戶禮物進行加和
讀代碼,了解了代碼流程、幾個異常分支,先做了code review
根據必要的異常分支,設計case覆蓋
對正常的業務流程,是按照開發講述的函數意圖,進行設計,case如下:
被測函數:?
ret := make(map[int]int) now := library.UnixNow() for record, numStr := range giftRecord { hasNum, err := strconv.Atoi(numStr) if err != nil || hasNum < 0 { continue } detail := strings.Split(record, ":") if len(detail) != 2 { continue } itemExpire, err := strconv.ParseInt(detail[1], 10, 64) if err != nil { continue } //星星過期 if itemExpire != 0 && now > itemExpire { continue } //統計可用數目 giftId, err := strconv.Atoi(detail[0]) if err != nil { continue } if _, ok := ret[giftId]; !ok { ret[giftId] = hasNum } else { ret[giftId] += hasNum } }正常路徑的單測case
func TestNum_CorrectRet(t *testing.T) { giftRecord := map[string]string{ "1:1000": "10", "1:2001": "100", "1:999": "20", "2": "200", "a": "30", "2:1001": "20", "2:999": "200", } expectRet := map[int]int{ 1: 110, 2: 20, } var s *redis.xxx patches := gomonkey.ApplyMethod(reflect.TypeOf(s), "Getxxx", func(_ *redis.xxx, _ string)(map[string]string, error) { return giftRecord, nil }) defer?patches.Reset() p := &StarData{xxx } userStarNum,?err?:=?p.GetNum(10000) assert.Nil(t, err) assert.JSONEq(t,?Calorie.StructToString(expectRet),?Calorie.StructToString(userStarNum)) }有同學會問到:但是你最終還是看的代碼呀?看到代碼的正確邏輯是怎么處理的,再去設計的case和構造數據吧?而且你不看代碼,怎么知道有哪些異常分支要覆蓋呢?
答:1. 我現在作為測試同學寫開發同學的case,確實需要知道有哪些異常分支要處理, 但不局限于代碼中的幾種,還應該包括我理解到的異常分支,都要體現在case中。我們的case絕不是為了證明代碼是怎么實現的!通過單測,我們經常能夠發現bug。但是將來是開發來寫單測的,他自己設計的函數肯定知道要覆蓋哪些異常分支。
2. 嗯,我需要看代碼的正常流程是怎樣的,但不代表著把代碼扒下來以設計出case。case實際上是通過與開發的溝通后,了解輸入數據的結構,輸出的格式,數據校驗和計算的過程,去設計輸入輸出的。
對于怎么個順序去寫單測,我們重點實踐了一番,基本上也就三種情況吧:
獨立原子:mockist,被我們推翻了。當然,最底部的函數可能沒有外部依賴,那單測它就夠了。
自上而下(紅線):從入口函數往下測。實踐的過程中,我發現很難執行,因為我從入口處就要想好每一次調用都需要返回哪些數據及格式,串起來一個case已經非常不易。
自下而上(黃線):我們發現,入口函數,往往沒什么邏輯,調用另一個函數然后拿到響應返回。所以入口函數,也許不用寫?我們繼續往下看,每一次調用的函數都看,也調出了以往的線上線下bug,我們發現出現問題的代碼部分往往是調用鏈的底端,尤其是涉及計算、復雜分支循環等。而且,底端的函數往往可測性較好。
因此,考慮兩方面,我們選擇自下而上設計來選擇函數編寫case:
底部的函數可測性通常很好
核心邏輯比較多,尤其涉及計算、拼接,分支的。
導致無法寫單測的重要原因是,代碼可測性不好。如果一個函數八九十行、二三百行,基本就是不可測的,或者說“不好測的”。因為里面邏輯太多了,從第一行到最后一行都經歷了什么,各種函數調用外部依賴,各種if/for,各種異常分支處理,寫一個case的代碼行數可能是原函數的幾倍。
因此,推動單測走下去,重構提升可測性是必須環節。而且,通過重構,代碼結構間接清晰了,更可讀可維護,更容易發現和定位問題。
常見的問題:重復代碼、魔法數字、箭頭式的代碼等
推薦的理論書籍是《重構:改善既有代碼的設計》第二版、《clean code》
我輸出了一篇關于重構的文章。
使用codecc(騰訊代碼檢查中心)的圈復雜度、函數長度來評估代碼結構質量,我們與開發一起學習,一起實踐,不斷有成果輸出。
對于箭頭式的代碼,可考慮如下步驟:
多使用衛語句,先判斷異常,異常return
將判斷語句抽離
將核心部分抽離為函數
用例設計要素
將內部邏輯與外部請求分開測試
對服務邊界(interface)的輸入和輸出進行嚴格驗證
用斷言來代替原生的報錯函數
避免隨機結果
盡量避免斷言時間的結果
適時使用setup和teardown
測試用例之間相互隔離,不要相互影響
原子性,所有的測試只有兩種結果:成功和失敗
避免測試中的邏輯,即不該包含if、switch、for、while等
不要保護起來,try…catch…
每個用例只測試一個關注點
少用sleep,延緩測試時長的行為都是不健康的
3A策略:arrange,action,assert
用例可讀性
標題要明確表明意圖,如Test+被測函數名+condition+result。case失敗后,通過名字就知道哪個場景失敗,而不用一行行再讀代碼。將來維護這個測試代碼的,可能是其他人,我們需要讓別人容易讀懂
測試代碼的內容要清晰,3A原則:arrange,action,assert 分成三部分。數據準備部分arrange如果代碼行較多,考慮抽離出去。
斷言的意圖明顯,可以考慮將魔法數字變為變量,命名通俗易通
一個case,不要做過多的assert,要專一
和業務代碼的要求一致,都要可讀
用例可維護性
重復:文本字符串重復、結構重復、語義重復
拒絕硬編碼
基于意圖的設計。不要因為業務代碼重構一次,就導致一批case失敗
注意代碼的各種壞味道,可參見《重構》第二版
用例可信賴性
單元測試,小而且運行快,它不是為了發現本次的bug,更是為了放在流水線上 努力發現每一次MR是否產生了bug。單測運行失敗,唯一的原因只應該是出現bug,而不是因為外部依賴不穩定、基于實現的涉及等,長期的失敗將失去單元測試的警示作用,“狼來了”的故事是慘痛的教訓。
非被測程序缺陷,隨機失敗的case
永不失敗的case
沒有assert的case
名不副實的case
我們提到,對單元測試的實踐分為4個階段,每階段均有目標。
第一階段? 會寫,全員寫,不要求寫好
由上而下的推動,從總監到組長,極力支持,毫無猶豫,使組員情緒高漲
快速確定單測框架,熟練使用
結合開發需求,輸出各場景下 單測框架的使用方法,包括assert、mock,table-driven等
封裝http2WebContext,方便生成context對象
多次培訓,講解單測理論及框架使用
各團隊(終端、接入層)指定單測接口人,由他先嘗螃蟹。他是最熟悉框架使用,在前期寫最多case的人
在磨合好單測框架的集成使用后,啟動會,部分同學先試點使用,確保連續兩個迭代,這幾個同學都有case輸出
每個迭代總結數據中,加入單測相關數據:組長和總監非常關注單測數據信息,針對性鼓勵提升case數量和代碼行數
第二階段 寫好,有效,全員寫
測試同學探索出mock的正確使用方法、用例設計的正確思路,分享給團隊,經過探討達成一致
結對編程,每迭代結對2-3個開發,共同寫case,互相提升。
這里的結對是靈活的:有的開發,只需用半天的時間給他講框架使用,同他練習,他就可以上手了不需要再擔心;有的開發,會分給測試同學需求,測試同學寫完case后,開發review學習,并嘗試寫出自己的第一個case;有的開發,一開始可能不太接受,以需求不適合單測為理由,觀察了一段時間,他發現其他人都寫了,也沒那么難,對團隊也有利,他甚至會主動找到測試同學教他寫case。
測試同學對開發提交的case進行review,跟進開發修改后重新MR
連續兩個迭代,邀請dot老師、喬幫主進行case review,效果非常好
對迭代的單測數據分析,關注需求覆蓋度、人員覆蓋度,case增量
組長持續鼓勵支持單測
每迭代的需求增加“單元測試”字段,由組長評估后置位。不帶單測的MR不予通過,單測也要被review
第三階段 可測性提升
測試和開發共同學習《重構》第二版,每周有分享會
某些骨干同學優先重構自己的代碼
測試同學嚴格要求,先保證有單測,然后小步重構,每一步均有單測保障
通過流水線的codecc掃描,圈復雜度和函數長度必須達標,不可人工干預其通過
第四階段 TDD
先不保證開發同學做到TDD,門檻還是挺高的,而且需要在線下熟練之后再運用到業務開發中
逐步推動開發將業務代碼和測試代碼同步編寫,而不是完成業務代碼后再補case
測試同學練成TDD
單測要放在流水線上跑,客戶端和后臺都配好了流水線,保證每次push和MR都運行一次,發報告。
對于go的單測,新聞接入層各模塊是通過MakeFile來編譯,因為要導入一些環境變量,所以我將go test集成在MakeFile中,執行make test即可運行該模塊下所有的測試用例。
GO = go CGO_LDFLAGS = xxx CGO_LDFLAGS += xxx CGO_LDFLAGS += xxx CGO_LDFLAGS?+=?xxx TARGET?=aaa export CGO_LDFLAGS all:$(TARGET) $(TARGET): main.go $(GO) build -o $@ $^ test: CFLAGS=-g export CFLAGS $(GO) test $(M) -v -gcflags=all=-l -coverpkg=./... -coverprofile=test.out ./... clean: rm -f $(TARGET)注:上述做法,只能生成被測試的代碼文件的覆蓋率,無法拿到未被測試覆蓋率情況。可以在根目錄建一個空的測試文件,就能解決這個問題,拿到全量代碼覆蓋率。
//main_test.go package main import ( "fmt" "testing" ) func TestNothing(t *testing.T) { fmt.Println("ok") }流水線加上流程
# cd ${WORKSPACE} 可進入當前工作空間目錄 export GOPATH=${WORKSPACE}/xxx pwd echo "====================work space" echo ${WORKSPACE} cd ${GOPATH}/src for file in `ls`: do if [ -d $file ] then if [[ "$file" == "a" ]] || [[ "$file" == "b" ]] || [[ "$file" == "c" ]] || [[ "$file" == "d" ]] then echo $file echo ${GOPATH}"/src/"$file cp -r ${GOPATH}/src/tools/qatesting/main_test.go ${GOPATH}/src/$file"/." cd ${GOPATH}/src/$file make test cd .. fi fi done?附錄. 資料《測試驅動開發》
《單元測試的藝術》
《有效的單元測試》
《重構,改善既有代碼的設計》
《修改代碼的藝術》
《測試驅動開發的三項修煉》
《xUnit Test Patterns》
mock七宗罪
總結
以上是生活随笔為你收集整理的从头到脚说单测——谈有效的单元测试的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: IJCAI2019报告:基于无监督学习和
- 下一篇: redis实践及思考