陳碩 (giantchen_AT_gmail)
Blog.csdn.net/Solstice t.sina.com.cn/giantchen
陳碩關(guān)于分布式系統(tǒng)的系列文章:http://blog.csdn.net/Solstice/category/802325.aspx
本作品采用“Creative Commons 署名-非商業(yè)性使用-禁止演繹 3.0 Unported 許可協(xié)議(cc by-nc-nd)”進(jìn)行許可。
http://creativecommons.org/licenses/by-nc-nd/3.0/
本文所談的“測(cè)試”全部指的是“開(kāi)發(fā)者測(cè)試/developer testing”,由程序員自己來(lái)做,不是由 QA 團(tuán)隊(duì)進(jìn)行的系統(tǒng)測(cè)試。這兩種測(cè)試各有各的用途,不能相互替代。
我在《樸實(shí)的C++設(shè)計(jì)》一文中談到“為了確保正確性,我們另外用Java寫(xiě)了一個(gè)測(cè)試夾具(test harness)來(lái)測(cè)試我們這個(gè)C++程序。這個(gè)測(cè)試夾具模擬了所有與我們這個(gè)C++程序打交道的其他程序,能夠測(cè)試各種正常或異常的情況。基本上任何代碼改動(dòng)和bug修復(fù)都在這個(gè)夾具中有體現(xiàn)。如果要新加一個(gè)功能,會(huì)有對(duì)應(yīng)的測(cè)試用例來(lái)驗(yàn)證其行為。如果發(fā)現(xiàn)了一個(gè)bug,先往夾具里加一個(gè)或幾個(gè)能復(fù)現(xiàn)bug的測(cè)試用例,然后修復(fù)代碼,讓測(cè)試通過(guò)。我們積累了幾百個(gè)測(cè)試用例,這些用例表示了我們對(duì)程序行為的預(yù)期,是一份可以運(yùn)行的文檔。每次代碼改動(dòng)提交之前,我們都會(huì)執(zhí)行一遍測(cè)試,以防低級(jí)錯(cuò)誤發(fā)生。”
今天把 test harness 這個(gè)做法仔細(xì)說(shuō)一說(shuō)。
自動(dòng)化測(cè)試的必要性
我想自動(dòng)化測(cè)試的必要性無(wú)需贅言,自動(dòng)化測(cè)試是 absolutely good stuff。
基本上,要是沒(méi)有自動(dòng)化的測(cè)試,我是不敢改產(chǎn)品代碼的(“改”包括添加新功能和重構(gòu))。自動(dòng)化測(cè)試的作用是把程序已經(jīng)實(shí)現(xiàn)的 features 以 test case 的形式固化下來(lái),將來(lái)任何代碼改動(dòng)如果破壞了現(xiàn)有的功能需求就會(huì)觸發(fā)測(cè)試 failure。好比 DNA 雙鏈的互補(bǔ)關(guān)系,這種互補(bǔ)結(jié)構(gòu)對(duì)保持生物遺傳的穩(wěn)定有重要作用。類(lèi)似的,自動(dòng)化測(cè)試與被測(cè)程序的互補(bǔ)結(jié)構(gòu)對(duì)保持系統(tǒng)的功能穩(wěn)定有重要作用。
單元測(cè)試的能與不能
一提到自動(dòng)化測(cè)試,我猜很多人想到的是單元測(cè)試(unit testing)。單元測(cè)試確實(shí)有很大的用處,對(duì)于解決某一類(lèi)型的問(wèn)題很有幫助。粗略地說(shuō),單元測(cè)試主要用于測(cè)試一個(gè)函數(shù)、一個(gè) class 或者相關(guān)的幾個(gè) classes。
最典型的是測(cè)試純函數(shù),比如計(jì)算個(gè)人所得稅的函數(shù),輸出是“起征點(diǎn)、扣除五險(xiǎn)一金之后的應(yīng)納稅所得額、稅率表”,輸出是應(yīng)該繳的個(gè)稅。又比如我在《〈程序中的日期與時(shí)間〉第一章 日期計(jì)算》中用單元測(cè)試來(lái)驗(yàn)證 Julian day number 算法的正確性。再比如我在《“過(guò)家家”版的移動(dòng)離線計(jì)費(fèi)系統(tǒng)實(shí)現(xiàn)》和《模擬銀行窗口排隊(duì)叫號(hào)系統(tǒng)的運(yùn)作》中用單元測(cè)試來(lái)檢查程序運(yùn)行的結(jié)果是否符合預(yù)期。(最后這個(gè)或許不是嚴(yán)格意義上的單元測(cè)試,更像是驗(yàn)收測(cè)試。)
為了能用單元測(cè)試,主代碼有時(shí)候需要做一些改動(dòng)。這對(duì) Java 通常不構(gòu)成問(wèn)題(反正都編譯成 jar 文件,在運(yùn)行的時(shí)候指定 entry point)。對(duì)于 C++,一個(gè)程序只能有一個(gè) main() 入口點(diǎn),要采用單元測(cè)試的話(huà),需要把功能代碼(被測(cè)對(duì)象)做成一個(gè) library,然后讓單元測(cè)試代碼(包含 main() 函數(shù))link 到這個(gè) library 上;當(dāng)然,為了正常啟動(dòng)程序,我們還需要寫(xiě)一個(gè)普通的 main(),并 link 到這個(gè) library 上。
單元測(cè)試的缺點(diǎn)
根據(jù)我的個(gè)人經(jīng)驗(yàn),我發(fā)現(xiàn)單元測(cè)試有以下缺點(diǎn)。
單元測(cè)試是白盒測(cè)試,測(cè)試代碼直接調(diào)用被測(cè)代碼,測(cè)試代碼與被測(cè)代碼緊耦合。從理論上說(shuō),“測(cè)試”應(yīng)該只關(guān)心被測(cè)代碼實(shí)現(xiàn)的功能,不用管它是如何實(shí)現(xiàn)的(包括它提供什么樣的函數(shù)調(diào)用接口)。比方說(shuō),以前面的個(gè)稅計(jì)算器函數(shù)為例,作為使用者,我們只關(guān)心它算的結(jié)果是否正確。但是,如果要寫(xiě)單元測(cè)試,測(cè)試代碼必須調(diào)用被測(cè)代碼,那么測(cè)試代碼必須要知道個(gè)稅計(jì)算器的 package、class、method name、parameter list、return type 等等信息,還要知道如何構(gòu)造這個(gè) class。以上任何一點(diǎn)改動(dòng)都會(huì)造成測(cè)試失敗(編譯就不通過(guò))。
在添加新功能的時(shí)候,我們常會(huì)重構(gòu)已有的代碼,在保持原有功能的情況下讓代碼的“形狀”更適合實(shí)現(xiàn)新的需求。一旦修改原有的代碼,單元測(cè)試就可能編譯不過(guò):比如給成員函數(shù)或構(gòu)造函數(shù)添加一個(gè)參數(shù),或者把成員函數(shù)從一個(gè) class 移到另一個(gè) class。對(duì)于 Java,這個(gè)問(wèn)題還比較好解決,因?yàn)?IDE 的重構(gòu)功能很強(qiáng),能自動(dòng)找到 references,并修改之。
對(duì)于 C++,這個(gè)問(wèn)題更為嚴(yán)重,因?yàn)橐桓墓δ艽a的接口,單元測(cè)試就編譯不過(guò)了,而 C++ 通常沒(méi)有自動(dòng)重構(gòu)工具(語(yǔ)法太復(fù)雜,語(yǔ)意太微妙)可以幫我們,都得手動(dòng)來(lái)。要么每改動(dòng)一點(diǎn)功能代碼就修復(fù)單元測(cè)試,讓編譯通過(guò);要么留著單元測(cè)試編譯不通過(guò),先把功能代碼改成我們想要的樣子,再來(lái)統(tǒng)一修復(fù)單元測(cè)試。
這兩種做法都有困難,前者,C++ 編譯緩慢,如果每改動(dòng)一點(diǎn)就修復(fù)單元測(cè)試,一天下來(lái)也前進(jìn)不了幾步,很多時(shí)間浪費(fèi)在等待編譯上;后者,問(wèn)題更嚴(yán)重,單元測(cè)試與被測(cè)代碼的互補(bǔ)性是保證程序功能穩(wěn)定的關(guān)鍵,如果大幅修改功能代碼的同時(shí)又大幅修改了單元測(cè)試,那么如何保證前后的單元測(cè)試的效果(測(cè)試點(diǎn))不變?如果單元測(cè)試自身的代碼發(fā)生了改動(dòng),如何保證它測(cè)試結(jié)果的有效性?會(huì)不會(huì)某個(gè)手誤讓功能代碼和單元測(cè)試犯了相同的錯(cuò)誤,負(fù)負(fù)得正,測(cè)試還是綠的,但是實(shí)際功能已經(jīng)亮了紅燈?難道我們要為單元測(cè)試寫(xiě)單元測(cè)試嗎?
有時(shí)候,我們需要重新設(shè)計(jì)并重寫(xiě)某個(gè)程序(有可能換用另一種語(yǔ)言)。這時(shí)候舊代碼中的單元測(cè)試完全作廢了(代碼結(jié)構(gòu)發(fā)生巨大改變,甚至連編程語(yǔ)言都換了),其中包含的寶貴的業(yè)務(wù)知識(shí)也付之東流,豈不可惜?
- 為了方便測(cè)試而施行依賴(lài)注入,破壞代碼的整體性。
為了讓代碼具有“可測(cè)試性”,我們常會(huì)使用依賴(lài)注入技術(shù),這么做的好處據(jù)說(shuō)是“解耦”(其實(shí),有人一句話(huà)道破真相:但凡你在某個(gè)地方切斷聯(lián)系,那么你必然會(huì)在另一個(gè)地方重新產(chǎn)生聯(lián)系),壞處就是割裂了代碼的邏輯:?jiǎn)慰匆粔K代碼不知道它是干嘛的,它依賴(lài)的對(duì)象不知道在哪兒創(chuàng)建的,如果一個(gè) interface 有多個(gè)實(shí)現(xiàn),不到運(yùn)行的時(shí)候不知道用的是哪個(gè)實(shí)現(xiàn)。(動(dòng)態(tài)綁定的初衷就是如此,想來(lái)讀過(guò)“以面向?qū)ο笏枷雽?shí)現(xiàn)”的代碼的人都明白我在說(shuō)什么。)
以《Muduo 網(wǎng)絡(luò)編程示例之二:Boost.Asio 的聊天服務(wù)器》中出現(xiàn)的聊天服務(wù)器 ChatServer 為例,ChatServer 直接使用了 muduo::net::TcpServer 和 muduo::net::TcpConnection 來(lái)處理網(wǎng)絡(luò)連接并收發(fā)數(shù)據(jù),這個(gè)設(shè)計(jì)簡(jiǎn)單直接。如果要為 ChatServer 寫(xiě)單元測(cè)試,那么首先它肯定不能在構(gòu)造函數(shù)里初始化 TcpServer 了。
稍微復(fù)雜一點(diǎn)的測(cè)試要用 mock object。ChatServer 用 TcpServer 和 TcpConenction 來(lái)收發(fā)消息,為了能單元測(cè)試,我們要為 TcpServer 和 TcpConnection 提供 mock 實(shí)現(xiàn),原本一個(gè)具體類(lèi) TcpServer 就變成了一個(gè) interface TcpServer 加兩個(gè)實(shí)現(xiàn) TcpServerImpl 和 TcpServerMock,同理 TcpConnection 也一化為三。ChatServer 本身的代碼也變得復(fù)雜,我們要設(shè)法把 TcpServer 和 TcpConnection 注入到其中,ChatServer 不能自己初始化 TcpServer 對(duì)象。
這恐怕是在 C++ 中使用單元測(cè)試的主要困難之一。Java 有動(dòng)態(tài)代理,還可以用 cglib 來(lái)操作字節(jié)碼以實(shí)現(xiàn)注入。而 C++ 比較原始,只能自己手工實(shí)現(xiàn) interface 和 implementations。這樣原本緊湊的以 concrete class 構(gòu)成的代碼結(jié)構(gòu)因?yàn)閱卧獪y(cè)試的需要而變得松散(所謂“面向接口編程”嘛),而這么做的目的僅僅是為了滿(mǎn)足“源碼級(jí)的可測(cè)試性”,是不是有一點(diǎn)因小失大呢?(這里且暫時(shí)忽略虛函數(shù)和普通函數(shù)在性能上的些微差別。)對(duì)于不同的 test case,可能還需要不同的 mock 對(duì)象,比如 TcpServerMock 和 TcpServerFailureMock,這又增加了編碼的工作量。
此外,如果程序中用到的涉及 IO 的第三方庫(kù)沒(méi)有以 interface 方式暴露接口,而是直接提供的 concrete class (這是對(duì)的,因?yàn)镃++中應(yīng)該《避免使用虛函數(shù)作為庫(kù)的接口》),這也讓編寫(xiě)單元變得困難,因?yàn)榭偛荒茏约喊€(gè) wrapper 一遍吧?難道用 link-time 的注入技術(shù)?
- 某些 failure 場(chǎng)景難以測(cè)試,而考察這些場(chǎng)景對(duì)編寫(xiě)穩(wěn)定的分布式系統(tǒng)有重要作用。比方說(shuō):網(wǎng)絡(luò)連不上、數(shù)據(jù)庫(kù)超時(shí)、系統(tǒng)資源不足。
- 對(duì)多線程程序無(wú)能為力。如果一個(gè)程序的功能涉及多個(gè)線程合作,那么就比較難用單元測(cè)試來(lái)驗(yàn)證其正確性。
- 如果程序涉及比較多的交互(指和其他程序交互,不是指圖形用戶(hù)界面),用單元測(cè)試來(lái)構(gòu)造測(cè)試場(chǎng)景比較麻煩,每個(gè)場(chǎng)景要寫(xiě)一堆無(wú)趣的代碼。而這正是分布式系統(tǒng)最需要測(cè)試的地方。
總的來(lái)說(shuō),單元測(cè)試是一個(gè)值得掌握的技術(shù),用在適當(dāng)?shù)牡胤酱_實(shí)能提高生產(chǎn)力。同時(shí),在分布式系統(tǒng)中,我們還需要其他的自動(dòng)化測(cè)試手段。
分布式系統(tǒng)測(cè)試的要點(diǎn)
在分布式系統(tǒng)中,class 與 function 級(jí)別的單元測(cè)試對(duì)整個(gè)系統(tǒng)的幫助不大,當(dāng)然,這種單元測(cè)試對(duì)單個(gè)程序的質(zhì)量有幫助;但是,一堆磚頭壘在一起是變不成大樓的。
分布式系統(tǒng)測(cè)試的要點(diǎn)是測(cè)試進(jìn)程間的交互:一個(gè)進(jìn)程收到客戶(hù)請(qǐng)求,該如何處理,然后轉(zhuǎn)發(fā)給其他進(jìn)程;收到響應(yīng)之后,又修改并應(yīng)答客戶(hù)。測(cè)試這些多進(jìn)程協(xié)作的場(chǎng)景才算測(cè)到了點(diǎn)子上。
假設(shè)一個(gè)分布式系統(tǒng)由四五種進(jìn)程組成,每個(gè)程序有各自的開(kāi)發(fā)人員。對(duì)于整個(gè)系統(tǒng),我們可以用腳本來(lái)模擬客戶(hù),自動(dòng)化地測(cè)試系統(tǒng)的整體運(yùn)作情況,這種測(cè)試通常由 QA 團(tuán)隊(duì)來(lái)執(zhí)行,也可以作為系統(tǒng)的冒煙測(cè)試。
對(duì)于其中每個(gè)程序的開(kāi)發(fā)人員,上述測(cè)試方法對(duì)日常的開(kāi)發(fā)幫助不大,因?yàn)闇y(cè)試要能通過(guò)必須整個(gè)系統(tǒng)都正常運(yùn)轉(zhuǎn)才行,在開(kāi)發(fā)階段,這一點(diǎn)不是時(shí)時(shí)刻刻都能滿(mǎn)足(有可能你用到的新功能對(duì)方還沒(méi)有實(shí)現(xiàn),這反過(guò)來(lái)影響了你的進(jìn)度)。另一方面,如果出現(xiàn)測(cè)試失敗,開(kāi)發(fā)人員不能立刻知道這是自己的程序出錯(cuò),有可能是環(huán)境原因造成的錯(cuò)誤,這通常要去讀程序日志才能判定。還有,作為開(kāi)發(fā)者測(cè)試,我們希望它無(wú)副作用,每天反復(fù)多次運(yùn)行也不會(huì)增加整個(gè)環(huán)境的負(fù)擔(dān),以整個(gè) QA 系統(tǒng)為測(cè)試平臺(tái)不可避免要留下一些垃圾數(shù)據(jù),而清理這些數(shù)據(jù)又會(huì)花一些寶貴的工作時(shí)間。(你得判斷數(shù)據(jù)是自己的測(cè)試生成的還是別人的測(cè)試留下的,不能誤刪了別人的測(cè)試數(shù)據(jù)。)
作為開(kāi)發(fā)人員,我們需要一種單獨(dú)針對(duì)自己編寫(xiě)的那個(gè)程序的自動(dòng)化測(cè)試方案,一方面提高日常開(kāi)發(fā)的效率,另一方面作為自己那個(gè)程序的功能驗(yàn)證測(cè)試集(即回歸測(cè)試/regression tests)。
分布式系統(tǒng)的抽象觀點(diǎn)
一臺(tái)機(jī)器兩根線
形象地來(lái)看,一個(gè)分布式系統(tǒng)就是一堆機(jī)器,每臺(tái)機(jī)器的屁股上拖著兩根線:電源線和網(wǎng)線(不考慮 SAN 等存儲(chǔ)設(shè)備),電源線插到電源插座上,網(wǎng)線插到交換機(jī)上。

這個(gè)模型實(shí)際上說(shuō)明,一臺(tái)機(jī)器的表現(xiàn)出來(lái)的行為完全由它接出來(lái)的兩根線展現(xiàn),今天不談電源線,只談網(wǎng)線。(“在乎服務(wù)器的功耗”在我看來(lái)就是公司利潤(rùn)率很低的標(biāo)志,要從電費(fèi)上摳成本。)
如果網(wǎng)絡(luò)是普通的千兆以太網(wǎng),那么吞吐量不大于 125MB/s。這個(gè)吞吐量比起現(xiàn)在的 CPU 運(yùn)算速度和內(nèi)存帶寬簡(jiǎn)直小得可憐。這里我想提的是,對(duì)于不特別在意 latency 的應(yīng)用,只要能讓千兆以太網(wǎng)的吞吐量飽和或接近飽和,用什么編程語(yǔ)言其實(shí)無(wú)所謂。Java 做網(wǎng)絡(luò)服務(wù)端開(kāi)發(fā)也是很好的選擇(不是指 web 開(kāi)發(fā),而是做一些基礎(chǔ)的分布式組件,例如 ZooKeeper 和 Hadoop 之類(lèi))。盡管可能 C++ 只用了 15% 的 CPU,而 Java 用了 30% 的 CPU,Java 還占用更多的內(nèi)存,但是千兆網(wǎng)卡帶寬都已經(jīng)跑滿(mǎn),那些省下在資源也只能浪費(fèi)了;對(duì)于外界(從網(wǎng)線上看過(guò)來(lái))而言,兩種語(yǔ)言的效果是一樣的,而通常 Java 的開(kāi)發(fā)效率更高。(Java 是比 C++ 慢一些,但是透過(guò)千兆網(wǎng)絡(luò)不一定還能看得出這個(gè)區(qū)別來(lái)。)
進(jìn)程間通過(guò) TCP 相互連接
陳碩在《多線程服務(wù)器的常用編程模型》第 5 節(jié)“進(jìn)程間通信”中提倡僅使用 TCP 作為進(jìn)程間通信的手段,今天這個(gè)觀點(diǎn)將再次得到驗(yàn)證。
以下是 Hadoop 的分布式文件系統(tǒng) HDFS 的架構(gòu)簡(jiǎn)圖。

HDFS 有四個(gè)角色參與其中,NameNode(保存元數(shù)據(jù))、DataNode(存儲(chǔ)節(jié)點(diǎn),多個(gè))、Secondary NameNode(定期寫(xiě) check point)、Client(客戶(hù),系統(tǒng)的使用者)。這些進(jìn)程運(yùn)行在多臺(tái)機(jī)器上,之間通過(guò) TCP 協(xié)議互聯(lián)。程序的行為完全由它在 TCP 連接上的表現(xiàn)決定(TCP 就好比前面提到的“網(wǎng)線”)。
在這個(gè)系統(tǒng)中,一個(gè)程序其實(shí)不知道與自己打交道的到底是什么。比如,對(duì)于 DataNode,它其實(shí)不在乎自己連接的是真的 NameNode 還是某個(gè)調(diào)皮的小孩用 Telnet 模擬的 NameNode,它只管接受命令并執(zhí)行。對(duì)于 NameNode,它其實(shí)也不知道 DataNode 是不是真的把用戶(hù)數(shù)據(jù)存到磁盤(pán)上去了,它只需要根據(jù) DataNode 的反饋更新自己的元數(shù)據(jù)就行。這已經(jīng)為我們指明了方向。
一種自動(dòng)化的回歸測(cè)試方案
假如我是 NameNode 的開(kāi)發(fā)者,為了能自動(dòng)化測(cè)試 NameNode,我可以為它寫(xiě)一個(gè) test harness (這是一個(gè)獨(dú)立的進(jìn)程),這個(gè) test harness 仿冒(mock)了與被測(cè)進(jìn)程打交道的全部程序。如下圖所示,是不是有點(diǎn)像“缸中之腦”?

對(duì)于 DataNode 的開(kāi)發(fā)者,他們也可以寫(xiě)一個(gè)專(zhuān)門(mén)的 test harness,模擬 Client 和 NameNode。

Test harness 的優(yōu)點(diǎn)
- 完全從外部觀察被測(cè)程序,對(duì)被測(cè)程序沒(méi)有侵入性,代碼該怎么寫(xiě)就怎么寫(xiě),不需要為測(cè)試留路。
- 能測(cè)試真實(shí)環(huán)境下的表現(xiàn),程序不是單獨(dú)為測(cè)試編譯的版本,而是將來(lái)真實(shí)運(yùn)行的版本。數(shù)據(jù)也是從網(wǎng)絡(luò)上讀取,發(fā)送到網(wǎng)絡(luò)上。
- 允許被測(cè)程序做大的重構(gòu),以?xún)?yōu)化內(nèi)部代碼結(jié)構(gòu),只要其表現(xiàn)出來(lái)的行為不變,測(cè)試就不會(huì)失敗。(在重構(gòu)期間不用修改 test case。)
- 能比較方便地測(cè)試 failure 場(chǎng)景。比如,若要測(cè)試 DataNode 出錯(cuò)時(shí) NameNode 的反應(yīng),只要讓 test harness 模擬的那個(gè) mock DataNode 返回我們想要的出錯(cuò)信息。要測(cè)試 NameNode 在某個(gè) DataNode 失效之后的反應(yīng),只要讓 test harness 斷開(kāi)對(duì)應(yīng)的網(wǎng)絡(luò)連接即可。要測(cè)量某請(qǐng)求超時(shí)的反應(yīng),只要讓 Test harness 不返回結(jié)果即可。這對(duì)構(gòu)建可靠的分布式系統(tǒng)尤為重要。
- 幫助開(kāi)發(fā)人員從使用者的角度理解程序,程序的哪些行為在外部是看得到的,哪些行為是看不到的。
- 有了一套比較完整的 test cases 之后,甚至可以換種語(yǔ)言重寫(xiě)被測(cè)程序(假設(shè)為了提高內(nèi)存利用率,換用 C++ 來(lái)重新實(shí)現(xiàn) NameNode),測(cè)試用例依舊可用。這時(shí) test harness 起到知識(shí)傳承的作用。
- 發(fā)現(xiàn) bug 之后,往 test harness 里添加能復(fù)現(xiàn) bug 的 test case,修復(fù) bug 之后,test case 繼續(xù)留在 harness 中,反正出現(xiàn)回歸(regression)。
實(shí)現(xiàn)要點(diǎn)
- Test harness 的要點(diǎn)在于隔斷被測(cè)程序與其他程序的聯(lián)系,它冒充了全部其他程序。這樣被測(cè)程序就像被放到測(cè)試臺(tái)上觀察一樣,讓我們只關(guān)注它一個(gè)。
- Test harness 要能發(fā)起或接受多個(gè) TCP 連接,可能需要用某個(gè)現(xiàn)成的 NIO 網(wǎng)絡(luò)庫(kù),如果不想寫(xiě)成多線程程序的話(huà)。
- Test harness 可以與被測(cè)程序運(yùn)行在同一臺(tái)機(jī)器,也可以運(yùn)行在兩臺(tái)機(jī)器上。在運(yùn)行被測(cè)程序的時(shí)候,可能要用一個(gè)特殊的啟動(dòng)腳本把它依賴(lài)的 host:port 指向 test harness。
- Test harness 只需要表現(xiàn)得跟它要 mock 的程序一樣,不需要真的去實(shí)現(xiàn)復(fù)雜的邏輯。比如 mock DataNode 只需要對(duì) NameNode 返回“Yes sir, 數(shù)據(jù)已存好”,而不需要真的把數(shù)據(jù)存到硬盤(pán)上。若要 mock 比較復(fù)雜的邏輯,可以用“記錄+回放”的方式,把預(yù)設(shè)的響應(yīng)放到 test case 里回放(replay)給被測(cè)程序。
- 因?yàn)橥ㄐ抛?TCP 協(xié)議,test harness 不一定要和被測(cè)程序用相同的語(yǔ)言,只要符合協(xié)議就行。試想如果用共享內(nèi)存實(shí)現(xiàn) IPC,這是不可能的。陳碩在《在 muduo 中實(shí)現(xiàn) protobuf 編解碼器與消息分發(fā)器》中提到利用 protobuf 的跨語(yǔ)言特性,我們可以采用 Java 為 C++ 服務(wù)程序編寫(xiě) test harness。其他跨語(yǔ)言的協(xié)議格式也行,比如 XML 或 Json。
- Test harness 運(yùn)行起來(lái)之后,等待被測(cè)程序的連接,或者主動(dòng)連接被測(cè)程序,或者兼而有之,取決于所用的通信方式。
- 一切就緒之后,Test harness 依次執(zhí)行 test cases。一個(gè) NameNode test case 的典型過(guò)程是:test harness 模仿 client 向被測(cè) NameNode 發(fā)送一個(gè)請(qǐng)求(eg. 創(chuàng)建文件),NameNode 可能會(huì)聯(lián)絡(luò) mock DataNode,test harness 模仿 DataNode 應(yīng)有的響應(yīng),NameNode 收到 mock DataNode 的反饋之后發(fā)送響應(yīng)給 client,這時(shí) test harness 檢查響應(yīng)是否符合預(yù)期。
- Test harness 中的 test cases 以配置文件(每個(gè) test case 有一個(gè)或多個(gè)文本配置文件,每個(gè) test case 占一個(gè)目錄)方式指定。test harness 和 test cases 連同程序代碼一起用 version controlling 工具管理起來(lái)。這樣能復(fù)現(xiàn)以外任何一個(gè)版本的應(yīng)有行為。
- 對(duì)于比較復(fù)雜的 test case,可以用嵌入式腳本語(yǔ)言來(lái)描述場(chǎng)景。如果 test harness 是 Java 寫(xiě)的,那么可以嵌入 Groovy,就像陳碩在《“過(guò)家家”版的移動(dòng)離線計(jì)費(fèi)系統(tǒng)實(shí)現(xiàn)》中用 Groovy 實(shí)現(xiàn)計(jì)費(fèi)邏輯一樣。Groovy 調(diào)用 test harness 模擬多個(gè)程序分別發(fā)送多份數(shù)據(jù)并驗(yàn)證結(jié)果,groovy 本身就是程序代碼,可以有邏輯判斷甚至循環(huán)。這種動(dòng)靜結(jié)合的做法在不增加 test harness 復(fù)雜度的情況下提供了相當(dāng)高的靈活性。
- Test harness 可以有一個(gè)命令行界面,程序員輸入“run 10”就選擇執(zhí)行第 10 號(hào) test case。
幾個(gè)實(shí)例
Test harness 這種測(cè)試方法適合測(cè)試有狀態(tài)的、與多個(gè)進(jìn)程通信的分布式程序,除了 Hadoop 中的 NameNode 與 DataNode,我還能想到幾個(gè)例子。
1. chat 聊天服務(wù)器
聊天服務(wù)器會(huì)與多個(gè)客戶(hù)端打交道,我們可以用 test harness 模擬 5 個(gè)客戶(hù)端,模擬用戶(hù)上下線,發(fā)送消息等情況,自動(dòng)檢測(cè)聊天服務(wù)器的工作情況。
2. 連接服務(wù)器、登錄服務(wù)器、邏輯服務(wù)器
這是云風(fēng)在他的 blog 中提到的三種網(wǎng)游服務(wù)器(http://blog.codingnow.com/2007/02/user_authenticate.html,http://blog.codingnow.com/2006/04/iocp_kqueue_epoll.html,http://blog.codingnow.com/2010/11/go_prime.html),我這里借用來(lái)舉例子。
如果要為連接服務(wù)器寫(xiě) test harness,那么需要模擬客戶(hù)(發(fā)起連接)、登錄服務(wù)器(驗(yàn)證客戶(hù)資料)、邏輯服務(wù)器(收發(fā)網(wǎng)游數(shù)據(jù)),有了這樣的 test harness,可以方便地測(cè)試連接服務(wù)器的正確性,也可以方便地模擬其他各個(gè)服務(wù)器斷開(kāi)連接的情況,看看連接服務(wù)器是否應(yīng)對(duì)自如。
同樣的思路,可以為登錄服務(wù)器寫(xiě) test harness。(我估計(jì)不用為邏輯服務(wù)器再寫(xiě)了,因?yàn)榭隙ㄒ呀?jīng)有自動(dòng)測(cè)試了。)
3. 多 master 之間的二段提交
這是分布式容錯(cuò)的一個(gè)經(jīng)典做法。用 test harness 能把 primary master 和 secondary masters 單獨(dú)拎出來(lái)測(cè)試。在測(cè)試 primary master 的時(shí)候,test harness 扮演 name service 和 secondary masters。在測(cè)試 secondary master 的時(shí)候,test harness 扮演 name service、primary master、其他 secondary masters。可以比較容易地測(cè)試各種 failure 情況。如果不這么做,而直接部署多個(gè) masters 來(lái)測(cè)試,恐怕很難做到自動(dòng)化測(cè)試。
4. paxos 的實(shí)現(xiàn)
Paxos 協(xié)議的實(shí)現(xiàn)肯定離不了單元測(cè)試,因?yàn)樯婕岸鄠€(gè)角色中比較復(fù)雜的狀態(tài)變遷。同時(shí),如果我要寫(xiě) paxos 實(shí)現(xiàn),那么 test harness 也是少不了的,它能自動(dòng)測(cè)試 paxos 節(jié)點(diǎn)在真實(shí)網(wǎng)絡(luò)環(huán)境下的表現(xiàn),并且輕易模擬各種 failure 場(chǎng)景。
局限性
如果被測(cè)程序有 TCP 之外的 IO,或者其 TCP 協(xié)議不易模擬(比如通過(guò) TCP 連接數(shù)據(jù)庫(kù)),那么這種測(cè)試方案會(huì)受到干擾。
對(duì)于數(shù)據(jù)庫(kù),如果被測(cè)程序只是簡(jiǎn)單的從數(shù)據(jù)庫(kù) select 一些配置信息,那么或許可以在 test harness 里內(nèi)嵌一個(gè) in-memory H2 DB engine,然后讓被測(cè)程序從這里讀取數(shù)據(jù)。當(dāng)然,前提是被測(cè)程序的 DB driver 能連上 H2 (或許不是大問(wèn)題,H2 支持 JDBC 和 部分 ODBC)。如果被測(cè)程序有比較復(fù)雜的 SQL 代碼,那么 H2 表現(xiàn)的行為不一定和生產(chǎn)環(huán)境的數(shù)據(jù)庫(kù)一致,這時(shí)候恐怕還是要部署測(cè)試數(shù)據(jù)庫(kù)(有可能為每個(gè)開(kāi)發(fā)人員部署一個(gè)小的測(cè)試數(shù)據(jù)庫(kù),以免相互干擾)。
如果被測(cè)程序有其他 IO (寫(xiě) log 不算),比如 DataNode 會(huì)訪問(wèn)文件系統(tǒng),那么 test harness 沒(méi)有能把 DataNode 完整地包裹起來(lái),有些 failure cases 不是那么容易測(cè)試。這是或許可以把 DataNode 指向 tmpfs,這樣能比較容易測(cè)試磁盤(pán)滿(mǎn)的情況。當(dāng)然,這樣也有局限性,因?yàn)?tmpfs 沒(méi)有真實(shí)磁盤(pán)那么大,也不能模擬磁盤(pán)讀寫(xiě)錯(cuò)誤。我不是分布式存儲(chǔ)方面的專(zhuān)家,這些問(wèn)題留給分布式文件系統(tǒng)的實(shí)現(xiàn)者去考慮吧。(測(cè)試 paxos 節(jié)點(diǎn)似乎也可以用 tmpfs 來(lái)模擬 persist storage,由 test case 填充所需的初始數(shù)據(jù)。)
其他用處
Test harness 除了實(shí)現(xiàn) features 的回歸測(cè)試,它還有別的用處。
- 加速開(kāi)發(fā),提高生產(chǎn)力。
前面提到,如果有個(gè)新功能(增加一種新的 request type)需要改動(dòng)兩個(gè)程序,有可能造成相互等待:客戶(hù)程序 A 說(shuō)要先等服務(wù)程序 B 實(shí)現(xiàn)對(duì)應(yīng)的功能響應(yīng),這樣 A 才能發(fā)送新的請(qǐng)求,不然每次請(qǐng)求就會(huì)被拒絕,無(wú)法測(cè)試;服務(wù)程序 B 說(shuō)要先等 A 能夠發(fā)送新的請(qǐng)求,這樣自己才能開(kāi)始編碼與測(cè)試,不然都不知道請(qǐng)求長(zhǎng)什么樣子,也觸發(fā)不了新寫(xiě)的代碼。(當(dāng)然,這是我虛構(gòu)的例子。)
如果 A 和 B 都有各自的 test harness,事情就好辦了,雙方大致商量一個(gè)協(xié)議格式,然后分頭編碼。程序 A 的作者在自己的 harness 里邊添加一個(gè) test case,模擬他認(rèn)為 B 應(yīng)有的響應(yīng),這個(gè)響應(yīng)可以 hard code 某種最常見(jiàn)的響應(yīng),不必真的實(shí)現(xiàn)所需的判斷邏輯(畢竟這是程序 B 的作者該干的事情),然后程序 A 的作者就可以編碼并測(cè)試自己的程序了。同理,程序 B 的作者也不用等 A 拿出一個(gè)半成品來(lái)發(fā)送新請(qǐng)求,他往自己的 harness 添加一個(gè) test case,模擬他認(rèn)為 A 應(yīng)該發(fā)送的請(qǐng)求,然后就可以編碼并測(cè)試自己的新功能。雙方齊頭并進(jìn),減少扯皮。等功能實(shí)現(xiàn)得差不多了,兩個(gè)程序互相連一連,如果發(fā)現(xiàn)協(xié)議有不一致,檢查一下 harness 中的新 test cases(這代表了 A/B 程序?qū)?duì)方的預(yù)期),看看那邊改動(dòng)比較方便,很快就能解決問(wèn)題。
Test harness 稍作改進(jìn)還可以變功能測(cè)試為壓力測(cè)試,供程序員 profiling 用。比如反復(fù)不間斷發(fā)送請(qǐng)求,向被測(cè)程序加壓。不過(guò),如果被測(cè)程序是 C++ 寫(xiě)的,而 test harness 是 Java 寫(xiě)的,有可能出現(xiàn) test harness 占 100% CPU,而被測(cè)程序還跑得優(yōu)哉游哉的情況。這時(shí)候可以單獨(dú)用 C++ 寫(xiě)一個(gè)負(fù)載生成器。
小結(jié)
以單獨(dú)的進(jìn)程作為 test harness 對(duì)于開(kāi)發(fā)分布式程序相當(dāng)有幫助,它能達(dá)到單元測(cè)試的自動(dòng)化程度和細(xì)致程度,又避免了單元測(cè)試對(duì)功能代碼結(jié)構(gòu)的侵入與依賴(lài)。