代碼規(guī)范與質(zhì)量
本文是關(guān)于代碼規(guī)范和代碼質(zhì)量相關(guān)的主題牡昆。
隨著我們寫(xiě)的代碼越來(lái)越多砰诵,技術(shù)債務(wù)也就隨之升高。什么是技術(shù)債務(wù)呢菩咨?通俗來(lái)講就是我們?cè)谲浖_(kāi)發(fā)的過(guò)程中吠式,為了達(dá)到當(dāng)前的目標(biāo),寫(xiě)一些只對(duì)當(dāng)前工作有效的代碼旦委。在項(xiàng)目緊急的時(shí)候奇徒,這么做無(wú)可厚非,只要我們?cè)谑潞竽軌蚣皶r(shí)的償還這些“技術(shù)債務(wù)”就行了缨硝。在事后能夠及時(shí)償還“技術(shù)債務(wù)”,這是理想情況罢低,現(xiàn)實(shí)情況更多的是在代碼上寫(xiě)上“TODO:這個(gè)我們有時(shí)間再優(yōu)化”查辩,而眾所周知,TODO is NOT TODO网持∫说海“出來(lái)混遲早要還的”,“技術(shù)債”也是這樣功舀。
隨著項(xiàng)目一個(gè)接著一個(gè)萍倡,似乎每個(gè)項(xiàng)目又都是緊急的,“技術(shù)債”越積越高辟汰,當(dāng)初承諾要優(yōu)化的似乎永遠(yuǎn)沒(méi)有時(shí)間去優(yōu)化列敲。而我們?cè)陧?xiàng)目緊急的時(shí)候?qū)懙哪切坝植皇遣荒苡谩钡拇a,好像也變得很難理解了帖汞。想再在上面寫(xiě)這些勉強(qiáng)可用的代碼也變得困難了戴而,結(jié)果造成的情況就是“前方產(chǎn)品故障頻發(fā),后方開(kāi)發(fā)人員不停地?fù)浠稹薄?/p>
影響產(chǎn)品質(zhì)量的因素有很多翩蘸,這里只討論代碼質(zhì)量與產(chǎn)品質(zhì)量的關(guān)系所意,應(yīng)該怎樣去提高代碼質(zhì)量。本文將從以下三個(gè)方面來(lái)討論:
- 代碼風(fēng)格與規(guī)范
- 代碼重構(gòu)的方式
- 單元測(cè)試
代碼風(fēng)格與規(guī)范
首先是為人編寫(xiě)程序,其次才是計(jì)算機(jī)扶踊。
代碼風(fēng)格無(wú)非就是那些不寫(xiě)也沒(méi)關(guān)系泄鹏,不會(huì)影響正常功能的東西,譬如命名秧耗、注釋命满、函數(shù)名等削锰。但是風(fēng)格良好的代碼墙懂,可讀性就更好偿枕,“挨揍”和“挨罵”的可能性就更少年局,而這些“揍”你和“罵”你的也許就是半年后的自己哑子。
可讀性基本定理
代碼的寫(xiě)法應(yīng)當(dāng)使別人(可能是6個(gè)月后的自己)理解它所需的時(shí)間最小化漱牵。要經(jīng)常地想一想其他人是不是會(huì)覺(jué)得你的代碼容易理解桐罕,這需要額外的時(shí)間宁否。這樣做就需要你打開(kāi)大腦中從前在編碼時(shí)可能沒(méi)有打開(kāi)的那部分功能缩麸。
命名
一個(gè)好的铸磅、有意義的命名能夠傳遞足夠多的信息,達(dá)到替代冗余注釋的效果杭朱,只通過(guò)名字就可以獲得大量的信息阅仔。
-
名字要有意義
在為函數(shù)或者變量去名字的時(shí)候,想一想這個(gè)名字需不需要注釋來(lái)補(bǔ)充說(shuō)明弧械,如果需要的話(huà)八酒,說(shuō)明取的名字不夠好。
var d int32 // 文件創(chuàng)建時(shí)間刃唐,天 var daysSinceCreation int32
以上代碼中羞迷,第一行中的變量
d
就沒(méi)有第二行的daysSinceCreation
更有意義。 -
避免空泛的名字
比如
tmp
,ret
,foo
,bar
等無(wú)實(shí)際信息的名字画饥。當(dāng)然衔瓮,在作用域小,只是臨時(shí)存儲(chǔ)一下中間變量除外抖甘。tmp := user.Name() tmp += " " + user.Tel() tmp += " " + user.Score()
以上代碼热鞍,
userInfo
就比tmp
變量可讀性更好。 -
使用更專(zhuān)業(yè)的名詞衔彻,更有表現(xiàn)力
func GetPage() func DownloadPage()
比如有個(gè)函數(shù)薇宠,作用是從網(wǎng)絡(luò)上獲取頁(yè)面信息,用
GetPage
容易造成疑惑米奸,不清楚是從數(shù)據(jù)庫(kù)還是從網(wǎng)絡(luò)上獲取數(shù)據(jù)昼接,使用DownloadPage
就更專(zhuān)業(yè),更能體現(xiàn)這個(gè)函數(shù)的功能悴晰。 -
用具體的名字代替抽象的名字
在給變量慢睡、函數(shù)或者其他元素命名時(shí)逐工,要把它描述得更具體而不是更抽象。
func ServerCanStart() func CanListenOnPort() // 更好
以上代碼中
CanListenOnPort比
ServerCanStart`更具體漂辐。代碼中硬編碼的數(shù)字也更抽象泪喊,用一個(gè)便于搜索的變量替代。
-
名字可以帶上其他重要信息
比如髓涯,在值為毫秒的變量后面加上
_ms
袒啼,或者在還需要轉(zhuǎn)義的,未處理的變量前面加上raw_
纬纪。 -
類(lèi)名和方法名
- 類(lèi)名和對(duì)象名使用名詞或名詞短語(yǔ)蚓再,如Customer、AddressParser()
- 方法名使用動(dòng)詞或動(dòng)詞短語(yǔ)包各,如 DeleteUser() 摘仅、Save()
名字容易且重要,當(dāng)你看到一個(gè)毫無(wú)意義的變量或方法名時(shí)问畅,毫不猶豫的改掉娃属,現(xiàn)在的IDE都支持一鍵改名,幾乎毫不費(fèi)力的改善了代碼質(zhì)量护姆。
注釋
首先引用《Clean Code》中的幾句對(duì)注釋的描述:
“什么也比不上放置良好的注釋來(lái)得有用矾端。什么也不會(huì)比亂七八糟的注釋更有本事搞亂一個(gè)模塊。什么也不會(huì)比陳舊卵皂、提供錯(cuò)誤信息的注釋更具有破壞力”
“注釋是為了彌補(bǔ)代碼的不足”
“能不能不寫(xiě)注釋?zhuān)瑥拇a上就可以很清晰的了解用途”
好的代碼自己本身就是最好的文檔秩铆。當(dāng)你打算加注釋的時(shí)候,問(wèn)問(wèn)自己‘我如何才能把我的代碼改善到不需增加注釋?zhuān)俊貥?gòu)自己的代碼渐裂,然后使文檔讓其更清楚豺旬。 — Steve McConnell《代碼大全》的作者
從上面大概可以看出,該書(shū)的作者對(duì)注釋持負(fù)面態(tài)度柒凉,作者認(rèn)為好代碼 > 壞代碼 + 好注釋。
很多時(shí)候我們又不得不寫(xiě)一些注釋?zhuān)瑑H僅用代碼不能很清晰的描述我們的真實(shí)意圖篓跛。那么什么是好的注釋?zhuān)渴裁礃拥淖⑨屖菈淖⑨屇叵ダ蹋坑衷撊绾稳?xiě)呢?
注釋?xiě)?yīng)該是對(duì)“意圖”進(jìn)行詮釋?zhuān)玫淖⑨屟院?jiǎn)意賅愧沟,用最少的文字傳達(dá)了必要的信息蔬咬,例如以下幾種情況的注釋?zhuān)?/p>
- 這些看起來(lái)很怪異的代碼為什么這么寫(xiě)
- 代碼中埋下的“坑”是什么,“這個(gè)函數(shù)只有我才能調(diào)用通沐寺,且不能改”
- 常量定義林艘,常量的用途,為何取這個(gè)值
- 總結(jié)性的文字描述混坞,告訴別人這段代碼的工作機(jī)制
- 產(chǎn)品/老板要求一定要有這個(gè)業(yè)務(wù)狐援,特殊處理
而壞注釋對(duì)代碼并沒(méi)有多少用處钢坦,甚至?xí)斐衫斫馍系恼系K,如下面這幾種情況:
-
從代碼本身就能快速推斷的事實(shí)寫(xiě)注釋
// Account 結(jié)構(gòu)體定義 type Account struct{ } // 設(shè)置賬戶(hù)名 func (a *Account)SetName(name string){ }
-
給不好的名字加注釋
// Release the handle for this key. This doesn't modify the actual registry. func DeleteRegistry(key string) func ReleaseRegistryHandle(key string)
與其花額外的精力為不好的名字進(jìn)行注釋?zhuān)€不如取一個(gè)更好的名字啥酱。
-
大量的廢棄代碼注釋
現(xiàn)在都有版本管理爹凹,代碼不會(huì)丟失,在需要的時(shí)候看看文件提交歷史就能夠找回镶殷。這些代碼果斷刪除吧禾酱,讓代碼更干凈一點(diǎn)。
那我們注釋的時(shí)候又該如何寫(xiě)呢绘趋,下面有些小Tips可以作為參考颤陶。
-
精簡(jiǎn)注釋?zhuān)屪⑨屆枋龊?jiǎn)潔
// 學(xué)生的分?jǐn)?shù)情況,第一個(gè)map中的key是學(xué)生姓名陷遮,第二個(gè)map分別是課程名和對(duì)應(yīng)的分?jǐn)?shù) ScoreMap map[string]map[string]int32 // userName -> [project:score] ScoreMap map[string]map[string]int32
上面兩個(gè)注釋都能說(shuō)明意圖滓走,但是第二個(gè)注釋顯得更簡(jiǎn)潔,不啰嗦拷呆。
-
精確描述函數(shù)的行為
// 返回文件的行數(shù) func CountLines(string fileName) // 統(tǒng)計(jì)文件中的 '\n' 換行符個(gè)數(shù) func CountLines(string fileName)
第一個(gè)注釋描述的不夠清晰闲坎,例如空文件算0行還是1行?hello\nworld 算幾行茬斧?而第二個(gè)注釋就沒(méi)有歧義腰懂。
-
用適當(dāng)例子說(shuō)明特殊情況
// 移除 src 中的 chars func Strip(src, chars string)string // Example:Strip("abba/a/ba", "ab") -> "/a/" func Strip(src, chars string)string
第二個(gè)在注釋中添加恰當(dāng)?shù)睦樱f(shuō)明了
Strip
函數(shù)的意圖项秉。 -
直接申明代碼意圖
//add by xx 產(chǎn)品要求一定要給某些高級(jí)用戶(hù)增加xx功能绣溜。 時(shí)間:2020-04-30 func Increase(userId string)
當(dāng)你感覺(jué)需要撰寫(xiě)注釋時(shí),請(qǐng)先嘗試重構(gòu)娄蔼,試著讓所有注釋都變得多余怖喻。
函數(shù)
函數(shù)應(yīng)該盡量寫(xiě)的短小,但是該寫(xiě)多小并沒(méi)有明確的標(biāo)準(zhǔn)岁诉,一般來(lái)說(shuō)20-30行最佳锚沸。Go語(yǔ)言中,一個(gè)函數(shù)如果很大涕癣,我們可以采用匿名函數(shù)將邏輯寫(xiě)的更清晰哗蜈。
一個(gè)函數(shù)只做一件事,不要把不相關(guān)的邏輯都堆在一個(gè)函數(shù)中坠韩,把這個(gè)函數(shù)變成了萬(wàn)能函數(shù)距潘。如果發(fā)現(xiàn)足夠的行數(shù)都是在解決不相關(guān)的字問(wèn)題時(shí),果斷的把它抽取到獨(dú)立的函數(shù)中只搁。
在寫(xiě)代碼時(shí)音比,可以問(wèn)問(wèn)自己下面這些問(wèn)題,有助于識(shí)別不相關(guān)代碼:
- 看到某個(gè)函數(shù)或者代碼塊時(shí)氢惋,問(wèn)問(wèn)自己:這段代碼的高層次的目標(biāo)時(shí)什么洞翩?
- 對(duì)于每一行代碼稽犁,問(wèn)一下:它是直接為了目標(biāo)而工作嗎?這段代碼高層次的目標(biāo)是什么呢菱农?
過(guò)度設(shè)計(jì)
程序員傾向于高估有多少功能真的對(duì)于他們的項(xiàng)目來(lái)講是必不可少的缭付。很多功能結(jié)果沒(méi)有完成,或者沒(méi)有用到循未,也可能只是讓程序更復(fù)雜陷猫。程序員還傾向于低估實(shí)現(xiàn)一個(gè)功能所要花的工夫。
有些用不到的功能并沒(méi)有想象中那么重要的妖,不一定會(huì)在未來(lái)用到绣檬,我們不需要為這些可能用不到的功能投入寶貴的時(shí)間。如一個(gè)內(nèi)部的系統(tǒng)嫂粟,本身沒(méi)幾個(gè)人用娇未,去要求系統(tǒng)的高可用、分布式星虹、微服務(wù)和國(guó)際化等明顯不合理的需求零抬。
邏輯
邏輯是代碼的血肉,清晰的邏輯會(huì)讓代碼更容易理解宽涌,有些常用的手段可以稍微簡(jiǎn)化代碼的復(fù)雜度平夜。
-
使用“衛(wèi)語(yǔ)句(guard clause)”從函數(shù)中提前返回
func Save(content string)bool{ if len(content) == 0 { return false } // 很多其他判斷條件 ... }
-
最小化嵌套
if userResult == SUCCESS{ if permissionResult != SUCCESS{ reply.Error("error..") reply.Done return; } reply.Error("") }else{ reply.Error(userResult) } reply.Done()
上面代碼 if ... else 嵌套對(duì)理解并沒(méi)增加什么困難,但是當(dāng)在一個(gè)復(fù)雜函數(shù)中時(shí)卸亮,理解這個(gè)嵌套就比較吃力忽妒,我們可以提前返回,調(diào)整代碼順序兼贸,理解起來(lái)更容易段直。
if userResult != SUCCESS{ reply.Error("error..") reply.Done() return; } if permissionResult != SUCCESS{ reply.Error("error..") reply.Done() return; } reply.Error("") reply.Done()
在循環(huán)中出現(xiàn)嵌套我們也可以采取提前返回的方法,只是把
return
換成break
或continue
溶诞。for _, item := range results{ if item != nil{ // do ... if item.name != ""{ // do... } } }
采取條件提前返回鸯檬,調(diào)整代碼后:
for _, item := range results{ if item == nil{ continue } if item.name == ""{ continue } // do ... }
以上就是代碼規(guī)范相關(guān)的幾點(diǎn)內(nèi)容,沒(méi)多少難度卻意義重大螺垢。
代碼重構(gòu)的方式
一幢有少許破窗的建筑為例京闰,如果那些窗不被修理好,可能將會(huì)有破壞者破壞更多的窗戶(hù)甩苛。最終他們甚至?xí)J入建筑內(nèi),如果發(fā)現(xiàn)無(wú)人居住俏站,也許就在那里定居或者縱火讯蒲。一面墻,如果出現(xiàn)一些涂鴉沒(méi)有被清洗掉肄扎,很快的墨林,墻上就布滿(mǎn)了亂七八糟赁酝、不堪入目的東西;一條人行道有些許紙屑旭等,不久后就會(huì)有更多垃圾酌呆,最終人們會(huì)視若理所當(dāng)然地將垃圾順手丟棄在地上。這個(gè)現(xiàn)象搔耕,就是犯罪心理學(xué)中的破窗效應(yīng)隙袁。
我們的代碼也是一樣,一段優(yōu)秀的代碼弃榨,如果從小到變量命名開(kāi)始不注意菩收,就會(huì)開(kāi)始“腐爛”,直到自己都忍受不了鲸睛,最后推倒重來(lái)娜饵,開(kāi)始惡性循環(huán)。因此官辈,我們需要經(jīng)常對(duì)代碼進(jìn)行重構(gòu)箱舞,不斷改善軟件設(shè)計(jì),使軟件更容易理解拳亿,保持軟件的“生命力”晴股。
何為重構(gòu)?
在不改變代碼外在行為的前提下风瘦,對(duì)代碼做出修改队魏,以改進(jìn)程序的內(nèi)部結(jié)構(gòu)。本質(zhì)上說(shuō)万搔,重構(gòu)就是在代碼寫(xiě)好之后改進(jìn)它的設(shè)計(jì)胡桨,這個(gè)改進(jìn)會(huì)讓代碼更容易理解,更易于修改瞬雹,而這可能使程序運(yùn)行得更快昧谊,也可能使程序運(yùn)行得更慢。它融入到整個(gè)開(kāi)發(fā)過(guò)程酗捌,而不是需要專(zhuān)門(mén)的時(shí)間呢诬,需要專(zhuān)門(mén)時(shí)間來(lái)寫(xiě)那是“重寫(xiě)”,而不是“重構(gòu)”胖缤。
重構(gòu)時(shí)機(jī)
重構(gòu)是融入到整個(gè)開(kāi)發(fā)過(guò)程的尚镰,上一分鐘在寫(xiě)代碼,下一分鐘可能就在重構(gòu)哪廓。當(dāng)出現(xiàn)下面一些時(shí)機(jī)狗唉,就可以考慮重構(gòu)了。
-
新添加功能
每次在新添加功能之前涡真,考慮一下是不是把當(dāng)前代碼重構(gòu)一下分俯,更容易添加這個(gè)功能肾筐。
每次要修改時(shí),首先令修改很容易(警告:這件事有時(shí)會(huì)很難)缸剪,然后再進(jìn)行這次容易的修改吗铐。
-
重復(fù)代碼——DRY
當(dāng)看到重復(fù)代碼時(shí),毫不猶豫進(jìn)行重構(gòu)杏节。
-
過(guò)時(shí)的知識(shí)
例如當(dāng)一個(gè)語(yǔ)言出現(xiàn)新特性唬渗,發(fā)現(xiàn)這個(gè)特性能夠使代碼更簡(jiǎn)潔,更優(yōu)雅時(shí)拢锹,可以考慮重構(gòu)谣妻。
一個(gè)小的重構(gòu)是否能使代碼更容易理解
有個(gè)通用的原則就是,保證每次離開(kāi)你所見(jiàn)到的代碼時(shí)卒稳,比你剛看到時(shí)更漂亮蹋半。當(dāng)然也有不需要重構(gòu)的情況,例如有些運(yùn)行良好的底層“祖?zhèn)鞔a”充坑,已經(jīng)成功運(yùn)行N年沒(méi)出現(xiàn)過(guò)bug减江,很少人動(dòng)的代碼,那就不要重構(gòu)了捻爷,不能保證你重構(gòu)的代碼比現(xiàn)在更好辈灼。而有些代碼發(fā)現(xiàn)重寫(xiě)比重構(gòu)更容易時(shí),也不要去重構(gòu)了也榄,直接重寫(xiě)吧巡莹。
在重構(gòu)之前,一定要確保有良好的測(cè)試甜紫,在這個(gè)前提下用簡(jiǎn)短又慎重的步驟進(jìn)行降宅,一次修改一個(gè)小點(diǎn),然后頻繁的運(yùn)行測(cè)試用例囚霸,保證每次的重構(gòu)都能通過(guò)所有測(cè)試腰根,不影響系統(tǒng)正常運(yùn)行。
“壞味道”與技巧
識(shí)別出代碼中的“壞味道”拓型,然后用一些重構(gòu)技巧來(lái)優(yōu)化额嘿,下面列舉一些典型的“壞味道”和常用的重構(gòu)技巧。
-
神秘的命名
正如本文代碼風(fēng)格中關(guān)于命名的描述劣挫,遇到令人費(fèi)解的變量册养、字段或命名時(shí),對(duì)它們進(jìn)行改名操作⊙构蹋現(xiàn)在IDE都支持一鍵改名操作捕儒。
-
重復(fù)代碼
重復(fù)代碼的危害眾所周知,修改一處代碼的時(shí)候還需要兼顧其他代碼。
改變代碼的順序刘莹,重組代碼,把重復(fù)代碼提煉成函數(shù)焚刚。
-
過(guò)長(zhǎng)的函數(shù)
每當(dāng)感覺(jué)某段代碼需要注釋來(lái)說(shuō)明的時(shí)候点弯,可以把要說(shuō)明的東西寫(xiě)進(jìn)一個(gè)獨(dú)立的函數(shù)中,并以其用途命名矿咕。我們可以對(duì)一組甚至短短一行代碼做這件事抢肛。哪怕替換后的函數(shù)調(diào)用動(dòng)作比函數(shù)自身還長(zhǎng),只要函數(shù)名稱(chēng)能夠解釋其用途碳柱,我們也該毫不猶豫地那么做捡絮。關(guān)鍵不在于函數(shù)的長(zhǎng)度,而在于函數(shù)“做什么”和“如何做”之間的語(yǔ)義距離莲镣。
-
參數(shù)列表過(guò)長(zhǎng)
引入?yún)?shù)對(duì)象福稳,將相關(guān)的參數(shù)用一個(gè)對(duì)象封裝起來(lái)。
func Save(name, path, perm string, length int, flag int ...)
有些函數(shù)有甚至上十個(gè)參數(shù)瑞侮,調(diào)用的時(shí)候容易傳參錯(cuò)誤的圆,可以定義一個(gè)對(duì)象,調(diào)用時(shí)傳遞該對(duì)象即可半火。
type Param struct{ name string path string perm string length int flag int ... } func Save(data Param)
-
全局變量
全局變量的危害在于越妈,當(dāng)不止一處代碼會(huì)對(duì)其進(jìn)行修改,往往會(huì)造成不可預(yù)期的結(jié)果钮糖。
我們可以采用單例模式或著封裝變量梅掠,控制全局變量的作用域,對(duì)外只提供操作的接口店归。
var global *Asset func SetAssetName(name string){ global.Name = name }
-
發(fā)散式變化
當(dāng)我們修改或增加一處功能時(shí)阎抒,必須同時(shí)修改若干個(gè)函數(shù),這叫做發(fā)散式變化娱节∧域龋可以提煉和搬移函數(shù),做到“每次只關(guān)心一個(gè)上下文”肄满,將相關(guān)函數(shù)搬到一起谴古。
-
依戀情節(jié)
一個(gè)函數(shù)跟另一個(gè)模塊中的函數(shù)或者數(shù)據(jù)交流格外頻繁,遠(yuǎn)勝于在自己所處模塊內(nèi)部的交流稠歉,這就是依戀情結(jié)的典型情況掰担。
判斷哪個(gè)模塊擁有的此函數(shù)使用的數(shù)據(jù)最多,然后就把這個(gè)函數(shù)和那些數(shù)據(jù)擺在一起怒炸,將那些總是一起變化的東西放在一塊带饱。
-
數(shù)據(jù)泥團(tuán)
將分散的變量匯總起來(lái),為數(shù)據(jù)泥團(tuán)新建一個(gè)類(lèi),不要擔(dān)心只用到了其中幾個(gè)字段勺疼。
type PermData struct{ path string name string perm string ... }
-
過(guò)長(zhǎng)的消息鏈(調(diào)用鏈)
過(guò)長(zhǎng)的消息鏈教寂,一旦當(dāng)其中一個(gè)對(duì)象發(fā)生修改,我們就不得不更改所有地方执庐。
name = company().section().user().name()
增加一個(gè)“中間者”酪耕,減少消息鏈。
-
過(guò)大的類(lèi)
觀察一個(gè)大類(lèi)的使用者轨淌,經(jīng)常能找到如何拆分類(lèi)的線(xiàn)索迂烁。看看使用者是否只用到了這個(gè)類(lèi)所有功能的一個(gè)子集递鹉,每個(gè)這樣的子集都可能拆分成一個(gè)獨(dú)立的類(lèi)盟步。
單元測(cè)試
有人說(shuō),看一段代碼質(zhì)量如何躏结,就看代碼中的接口多不多却盘。一個(gè)接口都沒(méi)有的代碼,很難說(shuō)是好代碼窜觉,至少很難測(cè)試谷炸。在敏捷開(kāi)發(fā)中,有一種TDD方案禀挫,在正式寫(xiě)代碼之前旬陡,先寫(xiě)單元測(cè)試,然后再讓單元測(cè)試通過(guò)语婴,
這種方式能夠讓代碼更優(yōu)雅描孟,更簡(jiǎn)潔,當(dāng)然需要的開(kāi)發(fā)時(shí)間也很多砰左。理想是美好的匿醒,現(xiàn)實(shí)情況是,由于各種原因缠导,不可能?chē)?yán)格按照TDD所提倡的方式來(lái)進(jìn)行開(kāi)發(fā)廉羔。但是核心代碼的單元測(cè)試必不可少,只有在有單元測(cè)試覆蓋的情況下僻造,我們才能放心的進(jìn)行重構(gòu)與開(kāi)發(fā)憋他。
一個(gè)好的單元測(cè)試要求運(yùn)行時(shí)間短、可以幫忙快速的定位問(wèn)題髓削,試想一下竹挡,如果一個(gè)單元測(cè)試用例需要運(yùn)行1s,那么整個(gè)項(xiàng)目上千個(gè)測(cè)試用例耗時(shí)難以忍受立膛,我們更不愿意頻繁的跑測(cè)試揪罕。
有時(shí)候梯码,我們會(huì)寫(xiě)一些測(cè)試代碼,來(lái)測(cè)試功能是否通過(guò)好啰,但有時(shí)這些不能稱(chēng)之為單元測(cè)試轩娶,因?yàn)橥@些臨時(shí)寫(xiě)的測(cè)試代碼,有下面這些特點(diǎn):
- 依賴(lài)數(shù)據(jù)庫(kù)
- 依賴(lài)網(wǎng)絡(luò)通信
- 調(diào)用文件系統(tǒng)
- 需要額外的配置文件
下面簡(jiǎn)單介紹一些單元測(cè)試中常用到的框架坎怪。
goconvey
雖然 Golang 自帶了單元測(cè)試功能和很多其他第三方測(cè)試框架罢坝,但是 GoConvey 更簡(jiǎn)潔,更優(yōu)雅搅窿,使用起來(lái)更方便。
我們通過(guò)一個(gè)案例來(lái)介紹如何使用隙券。
待測(cè)試函數(shù):
func CompareSlice(a, b []int) bool{
if len(a) != len(b){
return false
}
if (a == nil) != (b == nil){
return false
}
for i, v := range a{
if v != b[i]{
return false
}
}
return true
}
測(cè)試代碼:
func TestCompareSlice(t *testing.T) {
Convey("TestSliceEqual", t, func() {
Convey("should be true", func() {
a := []int{1, 2, 3}
b := []int{1, 2, 3}
So(CompareSlice(a, b), ShouldBeTrue)
})
Convey("should return true a=nil&b=nil", func() {
So(CompareSlice(nil, nil), ShouldBeTrue)
})
Convey("should return false", func() {
a := []int{1, 2, 3}
b := []int{1, 2}
So(CompareSlice(a,b), ShouldBeFalse)
})
})
}
最常用的函數(shù)就是通過(guò)So
進(jìn)行斷言男应。
輸出:
=== RUN TestCompareSlice
TestSliceEqual
should be true ?
should return true a=nil&b=nil ?
should return false ?
3 total assertions
--- PASS: TestCompareSlice (0.00s)
測(cè)試失敗的情況:
=== RUN TestCompareSlice2
should be true ?
1 total assertion
should return true a=nil&b=nil ?
2 total assertions
should return false ?
Failures:
* /Users/jiami/go/src/awesome/example_test.go
Line 44:
Expected: false
Actual: true
goroutine 7 [running]:
使用時(shí)有如下幾個(gè)要點(diǎn):
1、import goconvey
包時(shí)娱仔,前面加"."號(hào)沐飘,以減少冗余代碼。
2牲迫、測(cè)試函數(shù)名稱(chēng)以Test
開(kāi)頭耐朴,參數(shù)是*testing.T
。
3盹憎、每個(gè)測(cè)試用例必須用Convey
函數(shù)包裹起來(lái)筛峭。
gomoke
當(dāng)待測(cè)試的函數(shù)/對(duì)象的依賴(lài)關(guān)系很復(fù)雜,并且有些依賴(lài)不能直接創(chuàng)建陪每,例如數(shù)據(jù)庫(kù)連接影晓、文件I/O等。這種場(chǎng)景就非常適合使用 mock/stub 測(cè)試檩禾。簡(jiǎn)單來(lái)說(shuō)挂签,就是用 mock 對(duì)象模擬依賴(lài)項(xiàng)的行為。
gomoke是官方提供的moke框架盼产,下面將用一個(gè)簡(jiǎn)單的例子來(lái)介紹如何使用饵婆。
// 文件名: db.go
type DB interface {
Get(key string)(int, error)
}
func GetFromDB(db DB, key string)int{
if value, err := db.Get(key); err == nil{
return value
}
return -1
}
在測(cè)試用例中,不能創(chuàng)建真正的數(shù)據(jù)庫(kù)連接戏售,這個(gè)時(shí)候我們就可以moke
DB 接口侨核。
首先生成 moke 對(duì)象:
mockgen -source=db.go -destination=db_mock.go -package=main
此時(shí),就會(huì)生成一個(gè)名為 db_moke.go
的文件蜈项,部分代碼如下:
// MockDB is a mock of DB interface
type MockDB struct {
ctrl *gomock.Controller
recorder *MockDBMockRecorder
}
// MockDBMockRecorder is the mock recorder for MockDB
type MockDBMockRecorder struct {
mock *MockDB
}
// NewMockDB creates a new mock instance
func NewMockDB(ctrl *gomock.Controller) *MockDB {
mock := &MockDB{ctrl: ctrl}
mock.recorder = &MockDBMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use
func (m *MockDB) EXPECT() *MockDBMockRecorder {
return m.recorder
}
// Get mocks base method
func (m *MockDB) Get(key string) (int, error) {
ret := m.ctrl.Call(m, "Get", key)
ret0, _ := ret[0].(int)
ret1, _ := ret[1].(error)
return ret0, ret1
}
...
接下來(lái)芹关,在我們的測(cè)試用例中就可以使用 MockDB
來(lái)模擬DB操作了,測(cè)試代碼如下:
func TestGetFromDB(t *testing.T) {
Convey("test mock",t, func() {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
m := NewMockDB(ctrl)
m.EXPECT().Get(gomock.Eq("Test")).Return(0, errors.New("not exist"))
So(GetFromDB(m, "Test"), ShouldEqual, -1)
m.EXPECT().Get(gomock.Any()).DoAndReturn(func(key string) (int, error){
if key == "test"{
return 1, nil
}
return -1, errors.New("not exist")
}).AnyTimes()
So(GetFromDB(m, "test"), ShouldEqual, 1)
So(GetFromDB(m, "test1"), ShouldEqual, -1)
})
}
我們可以使用EXCEPT()
函數(shù)來(lái)“打樁”——給定明確的參數(shù)和返回值紧卒、檢測(cè)調(diào)用次數(shù)侥衬、調(diào)用順序,動(dòng)態(tài)設(shè)置返回值等。
gomonkey
這是國(guó)人寫(xiě)的一個(gè)很方便的打樁框架轴总,接口友好直颅,功能強(qiáng)大,支持下面一些特性:
- 支持為一個(gè)函數(shù)打一個(gè)樁
- 支持為一個(gè)成員方法打一個(gè)樁
- 支持為一個(gè)全局變量打一個(gè)樁
- 支持為一個(gè)函數(shù)變量打一個(gè)樁
- 支持為一個(gè)函數(shù)打一個(gè)特定的樁序列
- 支持為一個(gè)成員方法打一個(gè)特定的樁序列
- 支持為一個(gè)函數(shù)變量打一個(gè)特定的樁序列
下面就“支持為一個(gè)函數(shù)打一個(gè)樁”怀樟、“支持為一個(gè)成員方法打一個(gè)樁”功偿、“支持為一個(gè)全局變量打一個(gè)樁”來(lái)舉例說(shuō)明如何使用,具體功能有興趣可自行搜索往堡。
為一個(gè)函數(shù)打一個(gè)樁
需要用到下面一個(gè)接口:
func ApplyFunc(target, double interface{}) *Patches
func (this *Patches) ApplyFunc(target, double interface{}) *Patches
ApplyFunc 第一個(gè)參數(shù)是函數(shù)名械荷,第二個(gè)參數(shù)是樁函數(shù)。測(cè)試完成后虑灰,patches 對(duì)象通過(guò) Reset 成員方法刪除所有測(cè)試樁吨瞎。
假設(shè)我們有一個(gè)用來(lái)比較大小的函數(shù):
func CompareInt(a,b int)int{
if a == b{
return 0
}else if a > b{
return 1
}else{
return -1
}
}
我們需要為這個(gè)函數(shù)“打樁”,讓它在被調(diào)用時(shí)返回指定的結(jié)果穆咐,用法如下:
func TestCompareInt(t *testing.T) {
Convey("CompareInt", t, func() {
patch := ApplyFunc(CompareInt, func(_ int, _ int) int{
return 0
})
defer patch.Reset()
So(CompareInt(1,1), ShouldEqual, 0)
})
}
設(shè)置完樁函數(shù)之后颤诀,我們?cè)俅握{(diào)用,無(wú)論參數(shù)是什么对湃,它都會(huì)返回 0 值崖叫。
這個(gè)也可以給標(biāo)準(zhǔn)庫(kù)中的函數(shù)進(jìn)行“打樁”,比如 strconv.FormatInt
函數(shù):
func TestPackage(t *testing.T){
Convey("test", t, func() {
patch := ApplyFunc(strconv.FormatInt, func(_ int64, _ int)string{
return "test_package"
})
defer patch.Reset()
So(strconv.FormatInt(1,10), ShouldEqual, "test_package")
Convey("test_2", func() {
So(strconv.FormatInt(1,10), ShouldEqual, "test_package")
patch.Reset()
So(strconv.FormatInt(1,10), ShouldEqual, "1")
})
})
}
設(shè)置完樁函數(shù)后拍柒,strconv.FormatInt
就不是原先的函數(shù)了心傀,它只會(huì)返回"test_package"。
為一個(gè)成員方法打一個(gè)樁
需要用到下面這個(gè)接口:
func ApplyMethod(target reflect.Type, methodName string, double interface{}) *Patches
func (this *Patches) ApplyMethod(target reflect.Type, methodName string, double interface{}) *Patches
例如斤儿,我們有個(gè) RedisCli
類(lèi)剧包,用來(lái)表示 redis 連接,其中有個(gè) Get 方法:
type RedisCli struct{
}
func (r *RedisCli)Get(key string)(string, error){
return key + "test", nil
}
在單元測(cè)試中往果,不能真正的連接 redis疆液,為了測(cè)試代碼,我們除了可以 moke 外陕贮,還可以為 Get
方法打樁堕油,讓它暫時(shí)返回指定結(jié)果。
func TestClass(t *testing.T){
Convey("test class", t, func() {
var redisCli *RedisCli
patch := ApplyMethod(reflect.TypeOf(redisCli), "Get", func(_ *RedisCli, _ string) (string, error){
return "test_redis_get", nil
})
defer patch.Reset()
redisCli = &RedisCli{}
rsp, err := redisCli.Get("test")
So(err, ShouldBeNil)
So(rsp, ShouldEqual, "test_redis_get")
})
}
注意:如果打樁目標(biāo)為內(nèi)聯(lián)的函數(shù)或成員方法肮之,請(qǐng)?jiān)跍y(cè)試時(shí)通過(guò)命令行參數(shù) -gcflags=-l
關(guān)閉內(nèi)聯(lián)優(yōu)化掉缺。
為一個(gè)全局變量打一個(gè)樁
需要用到下面一個(gè)接口:
func ApplyGlobalVar(target, double interface{}) *Patches
func (this *Patches) ApplyGlobalVar(target, double interface{}) *Patches
使用示例如下:
// 全局變量插樁
var num = 10
func TestApplyGlobalVar(t *testing.T){
Convey("test global var", t, func() {
Convey("change", func() {
patch := ApplyGlobalVar(&num, 100)
defer patch.Reset()
So(num, ShouldEqual, 100)
})
Convey("recover", func() {
So(num, ShouldEqual, 10)
})
})
}
總結(jié)
本文內(nèi)容只是作為一個(gè)簡(jiǎn)單的介紹,很多內(nèi)容被舍棄掉了戈擒,如果覺(jué)得太淺顯或太長(zhǎng)不看眶明,那么只需要在編碼時(shí)記得時(shí)刻問(wèn)自己下面兩個(gè)問(wèn)題:
- 如果這樣寫(xiě)代碼,半年后的我會(huì)不會(huì)罵現(xiàn)在的自己筐高?
- 這塊代碼怎樣寫(xiě)才方便測(cè)試搜囱?