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


新能源車軟件單元測試深度解析:自動駕駛系統(tǒng)視角
新能源車背后的隱形守護(hù)者:軟件單元測試的生死較量?
單元測試:構(gòu)建數(shù)字世界的質(zhì)量基石
單元測試在嵌入式軟件中的關(guān)鍵作用及winAMS工具的卓越貢獻(xiàn)
嵌入式軟件單元測試的必要性、核心方法及工具深度解析
嵌入式系統(tǒng)開發(fā)中的測試方法 嵌入式系統(tǒng)開發(fā)與AI結(jié)合應(yīng)用
開發(fā)者必讀!CircleCI?組件測試與單元測試全解析
汽車軟件單元測試的重要性
嚴(yán)格的單元測試造就完美的軟件

TESSY單元測試工具詳解與操作演示:ISO 26262合規(guī)性、自定義測試用例、詳細(xì)測試報告等
嵌入軟件單元/集成測試工具專業(yè)分析
Linux內(nèi)核測試技術(shù)

評論