如何爲分佈式存儲系統做測試之:單元測試

對於分佈式存儲研發團隊來說,測試代碼的重要性等同於開發,研發人員花在寫測試代碼上的時間,也基本等同於寫業務代碼的時間。很多人不願意寫測試代碼,一方面可能覺得測試不重要,一方面也並不知道如何去寫測試代碼。

關於測試技術,作者將陸續用幾篇文章來介紹他與技術團隊是如何對分佈式存儲系統進行測試的,希望能夠幫助到大家,讓大家不再覺得寫測試是很困難的事。

本文系“超融合與分佈式塊存儲”系列文章的第三篇,前文回顧:

《如何構建一個分佈式塊存儲產品?| 上篇》
《如何構建一個分佈式塊存儲產品?| 下篇》

白盒測試 vs 黑盒測試

首先我們來介紹一些測試的背景。

通常,一個程序的行爲取決於它暴露出來的接口的定義。而程序內部複雜的行爲,都被隱藏在了接口的後面。例如對於一個兼容 POSIX 的存儲系統,它的行爲由 POSIX 接口定義。黑盒測試的重點,就是驗證存儲系統是否能夠滿足 POSIX 中定義的行爲。

而白盒測試則不僅僅關心接口的行爲,還關注程序內部的狀態。每個存儲系統都有很多特性,例如每個存儲系統的磁盤佈局都不同。那麼所謂的白盒測試,就是要針對這個內部實現的邏輯進行測試,例如在創建、刪除一個文件以後,檢查磁盤上數據的變化是否符合這個特定存儲系統的設計預期。

白盒測試 vs 單元測試

爲了進行白盒測試,測試程序必須能夠獲取程序的狀態,以便於進行檢查。一個存儲系統的狀態通常由兩部分組成:內存狀態和磁盤狀態。磁盤狀態我們可以通過直接訪問磁盤獲取到,而內存狀態通常不太容易直接獲取。

對於 Linux 文件系統來說,往往通過 sysfs 的方式對外暴露狀態。例如,我們可以通過 /sys/fs/xfs/[disk]/stats/stats 來獲得一個 xfs 文件系統的內存狀態。

此外,我們也可以通過暴露一個通信接口的方式,例如 RPC,用於獲取內部狀態。

然而我們還有更直接的獲取程序狀態的方式。單元測試就是一個例子。

我們在寫單元測試的時候,會把測試代碼和程序代碼鏈接在一起,也就是說,測試代碼可以直接訪問到程序代碼,從而可以控制程序的啓動、終止,並檢查內存狀態或驗證代碼的行爲。

我們千萬不要把單元測試僅僅侷限在對一個小模塊、函數進行測試。實際上單元測試中的單元(Unit),可以有更廣闊的含義。

每一個架構良好的程序都會劃分成多個層次。以 ZBS 舉例,ZBS 中的最頂層的模塊包含劃分爲 Meta Server 和 Chunk Server,Meta Server 又由 Chunk Manager、NFS Manager、iSCSI Manager 等子模塊組成,這些子模塊又可以再被劃分成很多的更小的子模塊。

我們可以把每一層都看做一個單元,對每一層都要進行測試。小到某一個基礎函數,基礎的工具類,大到一個 Chunk Server、Meta Server,一個 ZBS 集羣,甚至多個 ZBS 集羣,都可以通過這種方式進行測試。

例如在 ZBS 的單元測試中,會在一個進程內啓動多個 Meta Server 和 Chunk Server,並組成一個 ZBS 集羣,以進行測試。這些測試包含了常規 IO 測試,故障恢復測試,數據恢復、遷移測試,添加、刪除節點測試等等,我們甚至還可以模擬多個 ZBS 集羣間的數據備份測試。

而在這些測試中,我們不僅僅要保證程序的行爲和接口定義是一致的,還要保證程序內部狀態的正確。例如在測試副本寫入的過程中,我們會檢查 Chunk Server 是否正確的把寫入請求發送給底層的存儲引擎,當磁盤 IO 請求超時時,Chunk Server 是否能正常的處理這種異常行爲。

單元測試技術之獲取程序狀態

由於單元測試代碼和程序代碼鏈接到了一起,所以理論上,單元測試代碼可以獲得程序內部的所有狀態。但在有些場景下,獲取方式並不那麼直接。例如,對於 C++ 或其他面嚮對象語言來說,由於採用了各種形式的封裝,從語言層面實現了對程序狀態的隱藏,使得測試代碼無法直接獲取所有的狀態。

在 C++ 中,最主要的封裝方式就是類裏面的 private 成員。對於測試代碼來說,無法直接獲取一個對象的 private 成員的狀態,也無法調用一個類的 private 方法。

通常,爲了做到可測試,我們可以把一些類的 private 成員通過 public 函數的方式暴露出來。但過多的暴露內部實現也會破壞封裝性。

這個時候,比較常見的方法是使用 friend class。我們可以把的單元測試代碼所在的 class 作爲要訪問的類的 friend class,這樣就可以繞開 private 的限制,直接訪問類裏面的所有成員。

總的來說,C++ 中的封裝特性對寫單元測試造成了一些困擾。而在其他一些語言中,由於不存在 private 的限制,對於寫單元測試來說則相對友好一些。我們需要在封裝和可測試性之間尋找一個平衡點,不建議爲了寫單元測試而隨意破壞封裝性。在 ZBS 項目中,我們只有在必要的測試代碼中才會使用 friend class。

單元測試技術之 Mock

單元測試中另一個常用的技術就是 Mock。通過 Mock,我們可以對程序內部的行爲進行檢驗,也可以模擬一些特定的行爲。

例如,當我們想確認存儲系統對於『創建文件』這個操作,是否按照預期先寫了日誌,然後才寫的元數據,那麼我們可以通過 Mock 底層的 IO 模塊,截取存儲系統發送給底層 IO 系統的請求的方式,來校驗存儲系統的行爲是否符合預期。

在 ZBS 中,Mock 是我們用的最多的測試手段,因爲我們需要模擬各種各樣的異常場景,而且還要精確的控制異常觸發的時間、順序、以及具體異常行爲。

其實 Mock 並不複雜,在 C/C++ 中 Mock 就是多態,在上層代碼並不感知的情況下,下層的模塊的邏輯發生了變化。

在 C 中,多態主要是通過函數指針實現的。一個經典的例子就是 Linux 中的 VFS。VFS 中定義了一組函數指針。對於文件系統來說,只需要實現這些函數指針就可以了。不同的文件系統的實現不同,也就是因爲他們註冊的函數不同。

爲了測試 VFS 的行爲,我們只需要在測試代碼中註冊一個 Mock 文件系統。這個 Mock 文件系統和其他文件系統一樣,實現了這一組函數指針。這樣,我們就可以截獲所有 VFS 發給文件系統的請求,並可以按照測試項目預設的邏輯進行返回。

在 C++ 中,除了可以使用函數指針以外,我們實現多態的方式還有繼承和模板。我們先來介紹一下繼承。

如果我們想要 Mock 一個模塊,我們可以將這個類拆分成基類和子類。基類中之定義了接口,實現全部在子類中。在測試代碼中,我們可以繼承基類,這樣就可以實現 Mock 一個模塊的目的。

然而,僅僅完成了 Mock 類的實現並不足夠。對於完整的程序的來說,內部會劃分成很多的層次。如果我們想對上層模塊進行測試,且還要 Mock 一個底層的模塊,那如何保證上層代碼會使用到 Mock 類,而不是原始的類的。

這裏我們舉個例子:

當我們寫單元測試時,如何能夠保證當 Bar::func 被調用時,訪問的是 FooMock::foo,而不是 FooImpl::foo?

在這裏,我們可以借用 Factory 的思想。對象的構建不是直接通過 new,而是通過一個函數來完成。例如:

此時,單元測試代碼可以繼承 FooFactory,並在 CreateFoo 的時候,返回一個 FooMock,並在創建 Bar 的時候,把我們實現的 FooFactory 作爲參數傳給 Bar。

這種技術在 LevelDB 中被廣泛使用。在 LevelDB 中,存在大量的基類的定義,例如 SequentialFile、RandomAccessFile、WritableFile 等。同時,也定義了一個 Env 的基類,其中包含了這些類的 Factory 方法。

提到多態,C++ 高手們肯定還會提到模板。實際上,模板和繼承的方式都可以實現多態,各有優缺點,這裏我們就不再對模板的方式進行討論了。總的來說,思想都是類似的。具體選擇模板還是集成,就要看實際場景,以及個人喜好了。

單元測試技術之 Instrumentation

Mock 可以讓我們模擬底層模塊的行爲,以對目標代碼進行測試。而有的時候,我們希望直接檢查和控制目標代碼的行爲。例如,我們希望在單元測試中檢查一個代碼路徑被執行的次數。這個時候,我們可以用 Instrumentation 技術。

Instrumentation 技術有很多種不同的實現方式,在單元測試中,我們通常使用一些簡單的宏定義的方式實現。

例如,我們有一個函數 Foo,其中包含兩段比較重要的代碼邏輯。

我們在實現這個函數的時候,使用到了宏 TRACE_POINT,用於注入兩個 Trace Point。其中,POINT 1 和 POINT 2 是這兩個 Trace Point 的名字。在這個宏裏面,我們可以選擇執行一些回調函數。默認行爲自然是什麼都不做,而在單元測試中,我們可以在回調函數中爲每一個 Trace Point 分配一個計數器,用於統計代碼路徑走到的次數。我們也可以在回調函數中做一些更復雜的處理,以進行更復雜的執行控制。

然而簡單的宏 + 回調函數的方法實現的 Intrusmentation 存在一定的限制,無法隨意的更改程序運行的行爲。例如,我們很難通過回調函數的方式,實現在 TRACE_POINT 處從 Foo 函數返回一個特定的返回值(Exception 雖然可以跳出 Foo 函數,但是無法控制返回值)。

這其實就涉及到了 Flow Control 的技術。如果想要實現 Flow Control,需要更深層次的 Intrumentation,或者從語言層面的支持,例如 Continutation。關於 Flow Control 的技術,我們就不再這裏展開了,有機會的話,我們會通過單獨的文章介紹。

單元測試技術之實戰

以上的技術並不是互斥的,可以結合在一起使用使用。而有了這些武器,我們幾乎可以完成所有我們想在單元測試中完成的事情。

接下來,我們將簡要介紹一下我們如何對 ZBS 的存儲引擎進行測試。

我們以寫請求舉例。通常一次寫請求會經歷多個 IO 步驟,包括:

write journalsync journalwrite datawrite metadata

在 ZBS 中,我們將磁盤 IO 封裝爲了 FileHandle 類。爲了模擬 IO 的異常行爲,我們對底層的 FileHandle 進行 Mock。對於任何一個 IO 請求,都可能會觸發以下異常行爲:IO 錯誤,IO 延遲。

其中 IO 錯誤的模擬很簡單,只需要在 Mock 函數中返回一個錯誤狀態就可以了。IO 延遲則可以通過調用特定的 Sleep 函數進行模擬。例如,我們的 FileHandle 底層都是通過 Coroutine 實現的異步 IO,那麼可以調用相應的 CoroutineSleep 函數來模擬 IO 延遲,這樣可以實現只阻塞特定 IO 請求所在的 Coroutine,而不會阻塞整個線程。在 Mock 函數中,我們還可以通過顯示的調用 Yield 函數,讓這個 Coroutine 處於長期阻塞狀態,直到另一個事件被觸發再喚醒,這樣可以模擬不同事件發生的順序。這種方法在驗證併發 IO 的正確性時經常會用到。

此外,在每兩個步驟之間,還可能發生:因程序 Crash 緩存數據丟失,或 silence data corruption。

爲了模擬因程序 Crash 而導致緩存數據丟失,我們可以在 Mock 類中實現一個簡單的內存緩存,用於模擬操作系統或硬盤的緩存。在 Write 函數被調用時,並不會把請求下發到磁盤,而是緩存在內存,直到 Sync 函數被調用。我們在單元測試程序中,可以在某次 Sync 函數被調用的時候,不執行 Sync 操作,而是把存儲引擎 Shutdown 並重啓。在重啓後,對數據和元數據的一致性進行檢查,來驗證程序邏輯是否正確,是否滿足了 Crash Consistency 的要求。

Silence data corruption 的模擬也是類似的道理,我們可以通過 Mock,修改下發到磁盤的 IO 請求的 buffer 內容,模擬 silence data corruption。然後再對數據進行讀取,並觸發 checksum 校驗流程。以保證我們的 checksum 機制是可以正常工作的。

有了這些基礎後,我們可以通過單元測試框架提供的方法,把所有的故障可能性窮舉出來,以儘可能的提高測試覆蓋率。

寫在最後

到此,我們在單元測試中常用的技術就介紹完了。目前 ZBS 使用的測試框架是 GTest 和 GMock。其實掌握了以上的技術和思想,無論用哪個語言,哪個測試框架,都可以達到很好的測試效果。最重要的是,要對代碼和技術保持有敬畏之心,這樣才能做出優秀的產品。

如果你和我們一樣,都認可測試的價值,那麼歡迎加入我們,有興趣者可聯繫 [email protected] 。

作者介紹

張凱,畢業於清華計算機系,畢業以後加入百度基礎架構部工作了兩年,主要從事分佈式系統和大數據相關的工作。張凱也是開源社區的代碼貢獻者,參與的項目包括 Sheepdog 和 InfluxDB。其中 Sheepdog 是一個開源的分佈式塊存儲項目,InfluxDB 是一個時序數據庫(Time Series Database,TSDB)項目。2013 年張凱從百度離職,和清華的兩個師兄一起創辦了 SmartX 公司。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章