我在《單元測試實施解惑(一)》中指出,使用象Cmockery這樣的測試框架,將所需測試的模塊通過打樁的方法實施單元測試並不是最有效的方法。在這篇文章中,讓我們一同來探索更好的方法。在繼續探索之前,讓我從傳統單元測試開始引入所主張的方法。
圖1中所示的分別是某內存池模塊(mpool.c)和雙向鏈表模塊(dll.c)的代碼片斷,現在讓我們聚焦於爲內存池模塊的mpool_buffer_alloc函數實施單元測試。由於該函數使用到了雙向鏈表模塊的dll_pop_head函數,因此,我們需要對dll_pop_head函數進行打樁。(注:實際上還得對global_interrupt_disable和global_interrupt_enable兩函數打樁,但爲了簡化我們只以dll_pop_head爲例)
- mpool.c
- void* mpool_buffer_alloc (mpool_handle_t _handle)
- {
- interrupt_level_t level;
- mpool_node_t *p_node;
-
- level = global_interrupt_disable ();
- if (is_invalid_handle (_handle)) {
- global_interrupt_enable (level);
- return null;
- }
- p_node = (mpool_node_t *)dll_pop_head (&_handle->free_buffer_);
- if (0 == p_node) {
- _handle->stats_nobuf_ ++;
- global_interrupt_enable (level);
- return null;
- }
- global_interrupt_enable (level);
- p_node->in_use_ = true;
- return (void *)p_node->addr_;
- }
- dll.c
- dll_node_t *dll_pop_head (dll_t *_p_dll)
- {
- dll_node_t *p_node = _p_dll->head_;
-
- if (p_node != 0) {
- _p_dll->count_--;
- _p_dll->head_ = p_node->next_;
- if (0 == _p_dll->head_) {
- _p_dll->tail_ = 0;
- }
- else {
- p_node->next_->prev_ = 0;
- }
- p_node->next_ = 0;
- p_node->prev_ = 0;
- }
-
- return p_node;
- }
圖1
爲了便於理解,圖2示例了一個簡化了的樁和mpool_buffer_alloc函數的測試用例。請注意,測試用例中的handle實參假設之前通過mpool_init函數所獲得,圖中同樣爲了簡化並未列出。
- stub_dll.c
- dll_node_t *g_p_node;
-
- dll_node_t *dll_pop_head (dll_t *_p_dll)
- {
- return g_p_node;
- }
-
- test_mpool.c
- void test_mpool_buffer_alloc ()
- {
- mpool_node_t mnode;
-
-
- mnode.addr_ = 0x5A5A5A5A;
- mnode.in_use_ = false;
-
-
- g_p_node = &mnode.node_;
- UNITEST_EQUALS (mpool_buffer_alloc (handle), 0x5A5A5A5A);
- g_p_node = 0;
- UNITEST_EQUALS (mpool_buffer_alloc (handle), 0);
- }
圖2
對於熟悉Cmockery的讀者,圖3所示的樁函數和測試用例或許看起來更有感覺。
- stub_dll.c
- dll_node_t *dll_pop_head (dll_t *_p_dll)
- {
- return (dll_node_t *)mock ();
- }
-
- test_mpool.c
- void test_mpool_buffer_alloc ()
- {
- mpool_node_t mnode;
-
-
- mnode.addr_ = 0x5A5A5A5A;
- mnode.in_use_ = false;
-
-
- will_return (dll_pop_head, &mnode.node_);
- assert_int_equal (mpool_buffer_alloc (handler), 0x5A5A5A5A);
- will_return (dll_pop_head, 0);
- assert_int_equal (mpool_buffer_alloc (handler), 0);
- }
圖3
需要指出的是,通過打樁的方式,既可以完成狀態檢驗(State Verification),也可以完成行爲檢驗(Behavior Verification),這完全取決於樁函數的實現(本文的示例是狀態檢驗)。關於狀態檢驗與行爲檢驗更爲詳細的內容,請參見Martin Fowler的《Mocks aren’t Stubs》。
對於沒有單元測試經驗的讀者來說,這裏的示例會讓你對單元測試有一定的瞭解。而對於有單元測試經驗的讀者來說,一定會想到採用打樁的方式所帶來的實施困境。第一,樁函數對被替換函數的行爲模擬越接近,單元測試的效果就越好,但所花費的成本開銷也越大。極端情況下,會發現樁代碼與樁所替換的代碼在規模上是相當的。在產品的按時交付壓力之下,實施單元測試所造成的軟件規模增大很難讓團隊做到真心擁抱單元測試。第二,當項目規模增大以後,維護單元測試的樁函數並不是一件簡單的事情。項目規模的增大,易造成各個子團隊維護重複的樁代碼。即使整個項目有着很好的規劃,將所有的樁都以庫的形式進行集中維護,但單元測試代碼的編譯、樁代碼與項目代碼的同步維護仍需相當可觀的工作量。要走出這兩大困境,需要我們就單元測試做一點小小的觀念轉變 — 放棄打樁。
想一想,爲什麼不將樁與其所替代的項目代碼整合在一起,從而省去打樁呢?此時,單元測試的實施需要用到我在《專業嵌入式軟件開發》一書中所提出的錯誤注入的方法。大體上,錯誤注入的思想與前面圖2中實現單元測試的方法幾乎一樣,但是將樁函數的代碼與所替換的產品代碼進行了合併。圖4是引入錯誤注入概念之後dll_pop_head函數的實現。
- dll.c
- dll_node_t *dll_pop_head (dll_t *_p_dll)
- {
- dll_node_t *p_node = _p_dll->head_;
-
- #ifdef UNIT_TESTING
- {
- dll_node_t *p_node;
- error_t ecode = injected_error_get (
- INJECTION_POINT_DLL_POP_HEAD, &p_node);
- if (ecode != 0) {
- return p_node;
- }
- }
- #endif
-
- if (p_node != 0) {
- _p_dll->count_--;
- _p_dll->head_ = p_node->next_;
- if (0 == _p_dll->head_) {
- _p_dll->tail_ = 0;
- }
- else {
- p_node->next_->prev_ = 0;
- }
- p_node->next_ = 0;
- p_node->prev_ = 0;
- }
-
- return p_node;
- }
圖4
從圖中可以看出,在產品代碼中我們嵌入了一段用於單元測試的代碼,且通過UNIT_TESTING宏對這段代碼的存在與否進行控制。讀者可以認爲這段代碼與樁函數中的代碼功能相似,但最終達到的效果卻有很大的不同。
首先,UNIT_TESTING所控制的這段代碼存在一個錯誤注入點,這個點以INJECTION_ POINT_DLL_POP_HEAD加以標識。從代碼可以看出,該段代碼先調用injected_error_get函數獲取外部所注入的錯誤及數據。當外部沒有錯誤注入時, dll_pop_head函數的功能與真正的產品代碼是沒有任何區別的(全多了一次對injected_error_get函數的調用),這相當於省去了我們在樁函數中編寫dll_pop_head函數返回不爲null的代碼。
單元測試最難的部分是製造異常情形,比如讓dll_pop_head函數返回null就是我們測試mpool_buffer_alloc函數所需人爲製造的。圖5示例了新的單元測試程序是如何製造一個錯誤的。
- test_mpool.c
- void test_mpool_buffer_alloc ()
- {
- UNITEST_DIFFERS (mpool_buffer_alloc (handle), 0);
- error_inject (INJECTION_POINT_DLL_POP_HEAD,
- ERROR_T (ERROR_DLL_OUT_OF_NODE), null);
- UNITEST_EQUALS (mpool_buffer_alloc (handle), 0);
- error_inject (INJECTION_POINT_DLL_POP_HEAD, 0, null);
- }
圖5
在這個新的單元測試程序中,我們不需爲正常情形的測試做什麼工作(這裏做了一定形式的簡化,實際的單元測試仍需要我們就正常情形進行更爲細緻的檢驗),只是讓dll_pop_head函數正常工作,返回不爲null的節點就行了。要讓dll_pop_head函數返回null的話,人爲地向INJECTION_POINT_DLL_POP_HEAD注入點通過error_inject函數注入錯誤。error_inject函數的第一個參數是注入點;第二個參數是所注入的錯誤碼(我們約定0表示沒有錯誤),該碼作爲調用injected_error_get函數的返回值;第三個參數是所需注入的數據,該數據通過injected_error_get函數的第二個參數返回。
其次,在產品代碼中由UNIT_TESTING宏所控制的代碼並不需要有固定的格式,可以根據需要對錯誤點的行爲進行定義。比如,可能對鏈表模塊進行錯誤注入時,我們只希望影響鏈表1的行爲,而不想對鏈表2有任何影響,這樣的話可以將希望影響鏈表的指針注入到錯誤點中(此時需要定義一個數據結構,以便error_inject函數能傳遞多個參數),當然dll_pop_head函數中由UNIT_TESTING所控制的代碼也得做相應的調整,使其在返回null前檢查當前的鏈表指針(即_p_dll參數)與所注入的指針是否相同。
爲了能對象malloc這樣的函數也增加錯誤注入點,我們需要對之進行封裝。比如,提供osal_malloc函數,並在其中增加錯誤注入點所需的代碼。採用這種方法的結果是,我們需要很薄的一個平臺層。是否是跨平臺完全取決於項目的特點。打個比方,對於基於VxWorks實時操作系統的項目,如果平臺的編譯開發是在Linux主機上的話,則所提供的平臺層應實現跨VxWorks和Linux操作系統。千萬不要忘了,此時單元測試應在Linux主機上完成,而非VxWorks上。而對於一個構建Linux應用軟件的項目來說,如果開發也是在Linux主機上完成的話,平臺層就根本不需要考慮跨平臺問題。
引入平臺層之後,讀者或許會有兩個擔心:一是,跨平臺庫的開發需要大量的時間;二是,跨平臺的代碼又如何做單元測試?對於第一個擔心,我的解釋是:項目所使用的操作系統或C庫的系統函數具有極強的收斂性,數目很有限,與傳統單元測試所採用打樁的方式相比,我相信構建這樣的平臺層的工作量要小很多。至於第二個問題,我的建議是:由於平臺層做得很薄,功能簡單,我們可以放棄對之做單元測試。此時,單元測試的焦點應放在構建於平臺之上的軟件模塊上。
至此,錯誤注入方式的單元測試方法介紹完了。或許還有讀者會問,這又不打樁,也將錯誤點處理代碼嵌入在產品代碼中,這還是單元測試嗎?實際上,單元測試的目的是爲了讓被測代碼的各行和各分支能運行到以確保質量,只要達到這一目的,是否打樁或在產品代碼中嵌入測試代碼並非介定是不是單元測試的關鍵條件。我們應時刻記住,開發活動中的行爲應是價值驅動的,而非形式驅動的。一個沒有價值的行式,註定是浪費,也一定會束縛思想讓我們在困境中難以找到突破的途徑。價值驅動的觀點往往會讓我們拋開束縛,獲得意想不到的效果。
最後,整理一下以錯誤注入這一方法實施單元測試的特點:
1)引入很薄的平臺層。在平臺層中增加錯誤注入點處理代碼,以便更好地對構建於之上的軟件模塊實施單元測試。時否跨平臺取決於項目,但放棄對這一平臺層進行單元測試。
2)構建於平臺之上的各軟件模塊,在合適的地方也增加錯誤注入點處理代碼,以方便對其他依賴於它的模塊實施單元測試。
3)由於不需要額外的樁代碼,所以代碼的維護開銷更低,且天然具備複用的特點。