天天看點

分布式程式的自動化回歸測試自動化測試的必要性單元測試的能與不能分布式系統測試的要點分布式系統的抽象觀點一種自動化的回歸測試方案其他用處小結

陳碩 (giantchen_AT_gmail)

本作品采用“Creative Commons 署名-非商業性使用-禁止演繹 3.0 Unported 許可協定(cc by-nc-nd)”進行許可。

<a href="http://creativecommons.org/licenses/by-nc-nd/3.0/">http://creativecommons.org/licenses/by-nc-nd/3.0/</a>

本文所談的“測試”全部指的是“開發者測試/developer testing”,由程式員自己來做,不是由 QA 團隊進行的系統測試。這兩種測試各有各的用途,不能互相替代。

今天把 test harness 這個做法仔細說一說。

我想自動化測試的必要性無需贅言,自動化測試是 absolutely good stuff。

基本上,要是沒有自動化的測試,我是不敢改産品代碼的(“改”包括添加新功能和重構)。自動化測試的作用是把程式已經實作的 features 以 test case 的形式固化下來,将來任何代碼改動如果破壞了現有的功能需求就會觸發測試 failure。好比 DNA 雙鍊的互補關系,這種互補結構對保持生物遺傳的穩定有重要作用。類似的,自動化測試與被測程式的互補結構對保持系統的功能穩定有重要作用。

一提到自動化測試,我猜很多人想到的是單元測試(unit testing)。單元測試确實有很大的用處,對于解決某一類型的問題很有幫助。粗略地說,單元測試主要用于測試一個函數、一個 class 或者相關的幾個 classes。

為了能用單元測試,主代碼有時候需要做一些改動。這對 Java 通常不構成問題(反正都編譯成 jar 檔案,在運作的時候指定 entry point)。對于 C++,一個程式隻能有一個 main() 入口點,要采用單元測試的話,需要把功能代碼(被測對象)做成一個 library,然後讓單元測試代碼(包含 main() 函數)link 到這個 library 上;當然,為了正常啟動程式,我們還需要寫一個普通的 main(),并 link 到這個 library 上。

根據我的個人經驗,我發現單元測試有以下缺點。

阻礙大型重構。

單元測試是白盒測試,測試代碼直接調用被測代碼,測試代碼與被測代碼緊耦合。從理論上說,“測試”應該隻關心被測代碼實作的功能,不用管它是如何實作的(包括它提供什麼樣的函數調用接口)。比方說,以前面的個稅電腦函數為例,作為使用者,我們隻關心它算的結果是否正确。但是,如果要寫單元測試,測試代碼必須調用被測代碼,那麼測試代碼必須要知道個稅電腦的 package、class、method name、parameter list、return type 等等資訊,還要知道如何構造這個 class。以上任何一點改動都會造成測試失敗(編譯就不通過)。

在添加新功能的時候,我們常會重構已有的代碼,在保持原有功能的情況下讓代碼的“形狀”更适合實作新的需求。一旦修改原有的代碼,單元測試就可能編譯不過:比如給成員函數或構造函數添加一個參數,或者把成員函數從一個 class 移到另一個 class。對于 Java,這個問題還比較好解決,因為 IDE 的重構功能很強,能自動找到 references,并修改之。

對于 C++,這個問題更為嚴重,因為一改功能代碼的接口,單元測試就編譯不過了,而 C++ 通常沒有自動重構工具(文法太複雜,語意太微妙)可以幫我們,都得手動來。要麼每改動一點功能代碼就修複單元測試,讓編譯通過;要麼留着單元測試編譯不通過,先把功能代碼改成我們想要的樣子,再來統一修複單元測試。

這兩種做法都有困難,前者,C++ 編譯緩慢,如果每改動一點就修複單元測試,一天下來也前進不了幾步,很多時間浪費在等待編譯上;後者,問題更嚴重,單元測試與被測代碼的互補性是保證程式功能穩定的關鍵,如果大幅修改功能代碼的同時又大幅修改了單元測試,那麼如何保證前後的單元測試的效果(測試點)不變?如果單元測試自身的代碼發生了改動,如何保證它測試結果的有效性?會不會某個手誤讓功能代碼和單元測試犯了相同的錯誤,負負得正,測試還是綠的,但是實際功能已經亮了紅燈?難道我們要為單元測試寫單元測試嗎?

有時候,我們需要重新設計并重寫某個程式(有可能換用另一種語言)。這時候舊代碼中的單元測試完全廢棄了(代碼結構發生巨大改變,甚至連程式設計語言都換了),其中包含的寶貴的業務知識也付之東流,豈不可惜?

為了友善測試而施行依賴注入,破壞代碼的整體性。

稍微複雜一點的測試要用 mock object。ChatServer 用 TcpServer 和 TcpConenction 來收發消息,為了能單元測試,我們要為 TcpServer 和 TcpConnection 提供 mock 實作,原本一個具體類 TcpServer 就變成了一個 interface TcpServer 加兩個實作 TcpServerImpl 和 TcpServerMock,同理 TcpConnection 也一化為三。ChatServer 本身的代碼也變得複雜,我們要設法把 TcpServer 和 TcpConnection 注入到其中,ChatServer 不能自己初始化 TcpServer 對象。

這恐怕是在 C++ 中使用單元測試的主要困難之一。Java 有動态代理,還可以用 cglib 來操作位元組碼以實作注入。而 C++ 比較原始,隻能自己手工實作 interface 和 implementations。這樣原本緊湊的以 concrete class 構成的代碼結構因為單元測試的需要而變得松散(所謂“面向接口程式設計”嘛),而這麼做的目的僅僅是為了滿足“源碼級的可測試性”,是不是有一點因小失大呢?(這裡且暫時忽略虛函數和普通函數在性能上的些微差别。)對于不同的 test case,可能還需要不同的 mock 對象,比如 TcpServerMock 和 TcpServerFailureMock,這又增加了編碼的工作量。

某些 failure 場景難以測試,而考察這些場景對編寫穩定的分布式系統有重要作用。比方說:網絡連不上、資料庫逾時、系統資源不足。

對多線程程式無能為力。如果一個程式的功能涉及多個線程合作,那麼就比較難用單元測試來驗證其正确性。

如果程式涉及比較多的互動(指和其他程式互動,不是指圖形使用者界面),用單元測試來構造測試場景比較麻煩,每個場景要寫一堆無趣的代碼。而這正是分布式系統最需要測試的地方。

總的來說,單元測試是一個值得掌握的技術,用在适當的地方确實能提高生産力。同時,在分布式系統中,我們還需要其他的自動化測試手段。

在分布式系統中,class 與 function 級别的單元測試對整個系統的幫助不大,當然,這種單元測試對單個程式的品質有幫助;但是,一堆磚頭壘在一起是變不成大樓的。

分布式系統測試的要點是測試程序間的互動:一個程序收到客戶請求,該如何處理,然後轉發給其他程序;收到響應之後,又修改并應答客戶。測試這些多程序協作的場景才算測到了點子上。

假設一個分布式系統由四五種程序組成,每個程式有各自的開發人員。對于整個系統,我們可以用腳本來模拟客戶,自動化地測試系統的整體運作情況,這種測試通常由 QA 團隊來執行,也可以作為系統的冒煙測試。

對于其中每個程式的開發人員,上述測試方法對日常的開發幫助不大,因為測試要能通過必須整個系統都正常運轉才行,在開發階段,這一點不是時時刻刻都能滿足(有可能你用到的新功能對方還沒有實作,這反過來影響了你的進度)。另一方面,如果出現測試失敗,開發人員不能立刻知道這是自己的程式出錯,有可能是環境原因造成的錯誤,這通常要去讀程式日志才能判定。還有,作為開發者測試,我們希望它無副作用,每天反複多次運作也不會增加整個環境的負擔,以整個 QA 系統為測試平台不可避免要留下一些垃圾資料,而清理這些資料又會花一些寶貴的工作時間。(你得判斷資料是自己的測試生成的還是别人的測試留下的,不能誤删了别人的測試資料。)

作為開發人員,我們需要一種單獨針對自己編寫的那個程式的自動化測試方案,一方面提高日常開發的效率,另一方面作為自己那個程式的功能驗證測試集(即回歸測試/regression tests)。

形象地來看,一個分布式系統就是一堆機器,每台機器的屁股上拖着兩根線:電源線和網線(不考慮 SAN 等儲存設備),電源線插到電源插座上,網線插到交換機上。

這個模型實際上說明,一台機器的表現出來的行為完全由它接出來的兩根線展現,今天不談電源線,隻談網線。(“在乎伺服器的功耗”在我看來就是公司利潤率很低的标志,要從電費上摳成本。)

如果網絡是普通的千兆以太網,那麼吞吐量不大于 125MB/s。這個吞吐量比起現在的 CPU 運算速度和記憶體帶寬簡直小得可憐。這裡我想提的是,對于不特别在意 latency 的應用,隻要能讓千兆以太網的吞吐量飽和或接近飽和,用什麼程式設計語言其實無所謂。Java 做網絡服務端開發也是很好的選擇(不是指 web 開發,而是做一些基礎的分布式元件,例如 ZooKeeper 和 Hadoop 之類)。盡管可能 C++ 隻用了 15% 的 CPU,而 Java 用了 30% 的 CPU,Java 還占用更多的記憶體,但是千兆網卡帶寬都已經跑滿,那些省下在資源也隻能浪費了;對于外界(從網線上看過來)而言,兩種語言的效果是一樣的,而通常 Java 的開發效率更高。(Java 是比 C++ 慢一些,但是透過千兆網絡不一定還能看得出這個差別來。)

以下是 Hadoop 的分布式檔案系統 HDFS 的架構簡圖。

HDFS 有四個角色參與其中,NameNode(儲存中繼資料)、DataNode(存儲節點,多個)、Secondary NameNode(定期寫 check point)、Client(客戶,系統的使用者)。這些程序運作在多台機器上,之間通過 TCP 協定互聯。程式的行為完全由它在 TCP 連接配接上的表現決定(TCP 就好比前面提到的“網線”)。

在這個系統中,一個程式其實不知道與自己打交道的到底是什麼。比如,對于 DataNode,它其實不在乎自己連接配接的是真的 NameNode 還是某個調皮的小孩用 Telnet 模拟的 NameNode,它隻管接受指令并執行。對于 NameNode,它其實也不知道 DataNode 是不是真的把使用者資料存到磁盤上去了,它隻需要根據 DataNode 的回報更新自己的中繼資料就行。這已經為我們指明了方向。

假如我是 NameNode 的開發者,為了能自動化測試 NameNode,我可以為它寫一個 test harness (這是一個獨立的程序),這個 test harness 仿冒(mock)了與被測程序打交道的全部程式。如下圖所示,是不是有點像“缸中之腦”?

對于 DataNode 的開發者,他們也可以寫一個專門的 test harness,模拟 Client 和 NameNode。

完全從外部觀察被測程式,對被測程式沒有侵入性,代碼該怎麼寫就怎麼寫,不需要為測試留路。

能測試真實環境下的表現,程式不是單獨為測試編譯的版本,而是将來真實運作的版本。資料也是從網絡上讀取,發送到網絡上。

允許被測程式做大的重構,以優化内部代碼結構,隻要其表現出來的行為不變,測試就不會失敗。(在重構期間不用修改 test case。)

能比較友善地測試 failure 場景。比如,若要測試 DataNode 出錯時 NameNode 的反應,隻要讓 test harness 模拟的那個 mock DataNode 傳回我們想要的出錯資訊。要測試 NameNode 在某個 DataNode 失效之後的反應,隻要讓 test harness 斷開對應的網絡連接配接即可。要測量某請求逾時的反應,隻要讓 Test harness 不傳回結果即可。這對建構可靠的分布式系統尤為重要。

幫助開發人員從使用者的角度了解程式,程式的哪些行為在外部是看得到的,哪些行為是看不到的。

有了一套比較完整的 test cases 之後,甚至可以換種語言重寫被測程式(假設為了提高記憶體使用率,換用 C++ 來重新實作 NameNode),測試用例依舊可用。這時 test harness 起到知識傳承的作用。

發現 bug 之後,往 test harness 裡添加能複現 bug 的 test case,修複 bug 之後,test case 繼續留在 harness 中,反正出現回歸(regression)。

Test harness 的要點在于隔斷被測程式與其他程式的聯系,它冒充了全部其他程式。這樣被測程式就像被放到測試台上觀察一樣,讓我們隻關注它一個。

Test harness 要能發起或接受多個 TCP 連接配接,可能需要用某個現成的 NIO 網絡庫,如果不想寫成多線程程式的話。

Test harness 可以與被測程式運作在同一台機器,也可以運作在兩台機器上。在運作被測程式的時候,可能要用一個特殊的啟動腳本把它依賴的 host:port 指向 test harness。

Test harness 隻需要表現得跟它要 mock 的程式一樣,不需要真的去實作複雜的邏輯。比如 mock DataNode 隻需要對 NameNode 傳回“Yes sir, 資料已存好”,而不需要真的把資料存到硬碟上。若要 mock 比較複雜的邏輯,可以用“記錄+回放”的方式,把預設的響應放到 test case 裡回放(replay)給被測程式。

Test harness 運作起來之後,等待被測程式的連接配接,或者主動連接配接被測程式,或者兼而有之,取決于所用的通信方式。

一切就緒之後,Test harness 依次執行 test cases。一個 NameNode test case 的典型過程是:test harness 模仿 client 向被測 NameNode 發送一個請求(eg. 建立檔案),NameNode 可能會聯絡 mock DataNode,test harness 模仿 DataNode 應有的響應,NameNode 收到 mock DataNode 的回報之後發送響應給 client,這時 test harness 檢查響應是否符合預期。

Test harness 中的 test cases 以配置檔案(每個 test case 有一個或多個文本配置檔案,每個 test case 占一個目錄)方式指定。test harness 和 test cases 連同程式代碼一起用 version controlling 工具管理起來。這樣能複現以外任何一個版本的應有行為。

Test harness 可以有一個指令行界面,程式員輸入“run 10”就選擇執行第 10 号 test case。

Test harness 這種測試方法适合測試有狀态的、與多個程序通信的分布式程式,除了 Hadoop 中的 NameNode 與 DataNode,我還能想到幾個例子。

1. chat 聊天伺服器

聊天伺服器會與多個用戶端打交道,我們可以用 test harness 模拟 5 個用戶端,模拟使用者上下線,發送消息等情況,自動檢測聊天伺服器的工作情況。

2. 連接配接伺服器、登入伺服器、邏輯伺服器

如果要為連接配接伺服器寫 test harness,那麼需要模拟客戶(發起連接配接)、登入伺服器(驗證客戶資料)、邏輯伺服器(收發網遊資料),有了這樣的 test harness,可以友善地測試連接配接伺服器的正确性,也可以友善地模拟其他各個伺服器斷開連接配接的情況,看看連接配接伺服器是否應對自如。

同樣的思路,可以為登入伺服器寫 test harness。(我估計不用為邏輯伺服器再寫了,因為肯定已經有自動測試了。)

3. 多 master 之間的二段送出

這是分布式容錯的一個經典做法。用 test harness 能把 primary master  和 secondary masters 單獨拎出來測試。在測試 primary master 的時候,test harness 扮演 name service 和 secondary masters。在測試 secondary master 的時候,test harness 扮演 name service、primary master、其他 secondary masters。可以比較容易地測試各種 failure 情況。如果不這麼做,而直接部署多個 masters 來測試,恐怕很難做到自動化測試。

4. paxos 的實作

Paxos 協定的實作肯定離不了單元測試,因為涉及多個角色中比較複雜的狀态變遷。同時,如果我要寫 paxos 實作,那麼 test harness 也是少不了的,它能自動測試 paxos 節點在真實網絡環境下的表現,并且輕易模拟各種 failure 場景。

如果被測程式有 TCP 之外的 IO,或者其 TCP 協定不易模拟(比如通過 TCP 連接配接資料庫),那麼這種測試方案會受到幹擾。

如果被測程式有其他 IO (寫 log 不算),比如 DataNode 會通路檔案系統,那麼 test harness 沒有能把 DataNode 完整地包裹起來,有些 failure cases 不是那麼容易測試。這是或許可以把 DataNode 指向 tmpfs,這樣能比較容易測試磁盤滿的情況。當然,這樣也有局限性,因為 tmpfs 沒有真實磁盤那麼大,也不能模拟磁盤讀寫錯誤。我不是分布式存儲方面的專家,這些問題留給分布式檔案系統的實作者去考慮吧。(測試 paxos 節點似乎也可以用 tmpfs 來模拟 persist storage,由 test case 填充所需的初始資料。)

Test harness 除了實作 features 的回歸測試,它還有别的用處。

加速開發,提高生産力。

前面提到,如果有個新功能(增加一種新的 request type)需要改動兩個程式,有可能造成互相等待:客戶程式 A 說要先等服務程式 B 實作對應的功能響應,這樣 A 才能發送新的請求,不然每次請求就會被拒絕,無法測試;服務程式 B 說要先等 A 能夠發送新的請求,這樣自己才能開始編碼與測試,不然都不知道請求長什麼樣子,也觸發不了新寫的代碼。(當然,這是我虛構的例子。)

如果 A 和 B 都有各自的 test harness,事情就好辦了,雙方大緻商量一個協定格式,然後分頭編碼。程式 A 的作者在自己的 harness 裡邊添加一個 test case,模拟他認為 B 應有的響應,這個響應可以 hard code 某種最常見的響應,不必真的實作所需的判斷邏輯(畢竟這是程式 B 的作者該幹的事情),然後程式 A 的作者就可以編碼并測試自己的程式了。同理,程式 B 的作者也不用等 A 拿出一個半成品來發送新請求,他往自己的 harness 添加一個 test case,模拟他認為 A 應該發送的請求,然後就可以編碼并測試自己的新功能。雙方齊頭并進,減少扯皮。等功能實作得差不多了,兩個程式互相連一連,如果發現協定有不一緻,檢查一下 harness 中的新 test cases(這代表了 A/B 程式對對方的預期),看看那邊改動比較友善,很快就能解決問題。

壓力測試。

Test harness 稍作改進還可以變功能測試為壓力測試,供程式員 profiling 用。比如反複不間斷發送請求,向被測程式加壓。不過,如果被測程式是 C++ 寫的,而 test harness 是 Java 寫的,有可能出現 test harness 占 100% CPU,而被測程式還跑得優哉遊哉的情況。這時候可以單獨用 C++ 寫一個負載生成器。

以單獨的程序作為 test harness 對于開發分布式程式相當有幫助,它能達到單元測試的自動化程度和細緻程度,又避免了單元測試對功能代碼結構的侵入與依賴。

    本文轉自 陳碩  部落格園部落格,原文連結:http://www.cnblogs.com/Solstice/archive/2011/04/25/2026606.html,如需轉載請自行聯系原作者