C++ 工程實踐(6):單元測試如何 mock 系統調用

來源:互聯網
上載者:User
文章目錄
  • 系統函數的依賴注入
  • 連結期墊片 (link seams)
  • 例子:ZooKeeper 的 C client library
  • 其他手法
  • 第三方 C++ 庫

陳碩 (giantchen_AT_gmail)

Blog.csdn.net/Solstice

陳碩關於 C++ 工程實踐的系列文章: http://blog.csdn.net/Solstice/category/802325.aspx

陳碩部落格文章合集下載: http://blog.csdn.net/Solstice/archive/2011/02/24/6206154.aspx

本作品採用“Creative Commons 署名-非商業性使用-禁止演繹 3.0 Unported 許可協議(cc by-nc-nd)”進行許可。http://creativecommons.org/licenses/by-nc-nd/3.0/

摘要:本文討論了在編寫單元測試時 mock 系統調用(以及其他第三方庫)的幾種做法。

本文只考慮 Linux x86/amd64 平台。

陳碩在《分布式程式的自動化迴歸測試》 http://blog.csdn.net/Solstice/archive/2011/04/25/6359748.aspx 一文中曾經談到單元測試在分布式程式開發中的優缺點(好吧,主要是缺點)。但是,在某些情況下,單元測試是很有必要的,在測試 failure 情境的時候尤顯重要,比如:

  • 在開發儲存體系統時,類比 read(2)/write(2) 返回 EIO 錯誤(有可能是磁碟寫滿了,有可能是磁碟出壞道讀不出資料)。
  • 在開發網路程式庫的時候,類比 write(2) 返回 EPIPE 錯誤(對方意外中斷連線)。
  • 在開發網路程式庫的時候,類比自串連 (self-connection),網路程式庫應該用 getsockname(2) 和 getpeername(2) 判斷是否是自串連,然後斷開之。
  • 在開發網路程式庫的時候,類比本地 ephemeral port 用完,connect(2) 返回 EAGAIN 臨時錯誤。
  • 讓 gethostbyname(2) 返回我們預設的值,防止單元測試給公司的 DNS server 帶來太大壓力。

這些 test case 恐怕很難用前文提到的 test harness 來測試,該單元測試上場了。現在的問題是,如何 mock 這些系統函數?或者換句話說,如何把對系統函數的依賴注入到被測程式中?

系統函數的依賴注入

在Michael Feathers 的《修改代碼的藝術 / Working Effectively with Legacy Code》一書第 4.3.2 節中,作者介紹了連結期接縫(link seam),正好可以解決我們的問題。另外,在 Stack Overflow 的一個文章裡也總結了幾種做法:http://stackoverflow.com/questions/2924440/advice-on-mocking-system-calls

如果程式(庫)在編寫的時候就考慮了可測試性,那麼用不到上面的 hack 手段,我們可以從設計上解決依賴注入的問題。這裡提供兩個思路。

其一,採用傳統的物件導向的手法,藉助運行期的遲綁定實現注入與替換。自己寫一個 System interface,把程式裡用到的 open、close、read、write、connect、bind、listen、accept、gethostname、getpeername、getsockname 等等函數統統用虛函數封裝一層。然後在代碼裡不要直接調用 open(),而是調用 System::instance().open()。

這樣代碼主動把控制權交給了 System interface,我們可以在這裡動動手腳。在寫單元測試的時候,把這個 singleton instance 替換為我們的 mock object,這樣就能類比各種 error code。

其二,採用編譯期或連結期的遲綁定。注意到在第一種做法中,運行期多態是不必要的,因為程式從生到死只會用到一個 implementation object。為此付出虛函數調用的代價似乎有些不值。(其實,跟系統調用比起來,虛函數這點開銷可忽略不計。)

我們可以寫一個 system namespace 標頭檔,在其中聲明 read() 和 write() 等普通函數,然後在 .cc 檔案裡轉寄給對應系統的系統函數 ::read() 和 ::write() 等。

// SocketsOps.hnamespace sockets{  int connect(int sockfd, const struct sockaddr_in& addr);}// SocketsOps.ccint sockets::connect(int sockfd, const struct sockaddr_in& addr){  return ::connect(sockfd, sockaddr_cast(&addr), sizeof addr);}

此處的代碼來自 muduo 網路程式庫

http://code.google.com/p/muduo/source/browse/trunk/muduo/net/SocketsOps.h
http://code.google.com/p/muduo/source/browse/trunk/muduo/net/SocketsOps.cc

有了這麼一層間接性,就可以在編寫單元測試的時候動動手腳,連結我們的 stub 實現,以達到替換實現的目的:

// MockSocketsOps.ccint sockets::connect(int sockfd, const struct sockaddr_in& addr){  errno = EAGAIN;  return -1;}

C++ 一個程式只能有一個 main() 入口,所以要先把程式做成 library,再用單元測試代碼連結這個 library。假設有一個 mynetcat 程式,為了編寫 C++ 單元測試,我們把它拆成兩部分,library 和 main(),源檔案分別是 mynetcat.cc 和 main.cc。

在編譯普通程式的時候:

g++ main.cc mynetcat.cc SocketsOps.cc -o mynetcat

在編譯單元測試時這麼寫:

g++ test.cc mynetcat.cc MockSocketsOps.cc -o test

以上是最簡單的例子,在實際開發中可以讓 stub 功能更強大一些,比如根據不同的 test case 返回不同的錯誤。這麼做無需用到虛函數,代碼寫起來也比較簡潔,只用首碼 sockets:: 即可。例如應用程式的代碼裡寫 sockets::connect(fd, addr)。

muduo 目前還沒有單元測試,只是預留了這些 stubs。

namespace 的好處在於它不是封閉的,我們可以隨時開啟往裡添加新的函數,而不用改動原來的標頭檔(該檔案的控制權可能不在我們手裡)。這也是以 non-member non-friend 函數為介面的優點。

以上兩種做法還有一個好處,即只 mock 我們關心的部分代碼。如果程式用到了 SQLite 或 Berkeley DB 這些會訪問本地檔案系統的第三方庫,那麼我們的 System interface 或 system namespace 不會攔截這些第三方庫的 open(2)、close(2)、read(2)、write(2) 等系統調用。

連結期墊片 (link seams)

如果程式在一開始編碼的時候沒有考慮單元測試,那麼又該如何注入 mock 系統調用呢?上面第二種做法已經給出了答案,那就是使用 link seam (連結期墊片)。

比方說要 mock connect(2) 函數,那麼我們自己在單元測試程式裡實現一個 connect() 函數,在連結的時候,會優先採用我們自己定義的函數。(這對動態連結是成立的,如果是靜態連結,會報  multiple definition 錯誤。好在絕大多數情況下 libc 是動態連結的。)

typedef int (*connect_func_t)(int sockfd, const struct sockaddr *addr, socklen_t addrlen);connect_func_t connect_func = dlsym(RTDL_NEXT, "connect");bool mock_connect;int mock_connect_errno;// mock connectextern "C" int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen){  if (mock_connect) {    errno = mock_connect_errno;return errno == 0 ? 0 : -1;  } else {    return connect_func(sockfd, addr, addrlen);  }}

如果程式真的要調用 connect(2) 怎麼辦?在我們自己的 mock connect(2) 裡不能再調用 connect() 了,否則會出現無限遞迴。為了防止這種情況,我們用 dlsym(RTDL_NEXT, "connect") 獲得 connect(2) 系統函數的真真實位址,然後通過函數指標 connect_func 來調用它。

例子:ZooKeeper 的 C client library

ZooKeeper 的 C client library 正是採用了 link seams 來編寫單元測試,代碼見:

http://svn.apache.org/repos/asf/zookeeper/tags/release-3.3.3/src/c/tests/LibCMocks.h
http://svn.apache.org/repos/asf/zookeeper/tags/release-3.3.3/src/c/tests/LibCMocks.cc

其他手法

Stack Overflow 的文章裡還提到一個做法,可以方便地替換動態庫裡的函數,即使用 ld 的 --wrap 參數,
文檔裡說得很清楚,這裡不再贅述。

       --wrap=symbol           Use a wrapper function for symbol.  Any undefined reference to           symbol will be resolved to "__wrap_symbol".  Any undefined           reference to "__real_symbol" will be resolved to symbol.           This can be used to provide a wrapper for a system function.  The           wrapper function should be called "__wrap_symbol".  If it wishes to           call the system function, it should call "__real_symbol".           Here is a trivial example:                   void *                   __wrap_malloc (size_t c)                   {                     printf ("malloc called with %zu\n", c);                     return __real_malloc (c);                   }           If you link other code with this file using --wrap malloc, then all           calls to "malloc" will call the function "__wrap_malloc" instead.           The call to "__real_malloc" in "__wrap_malloc" will call the real           "malloc" function.           You may wish to provide a "__real_malloc" function as well, so that           links without the --wrap option will succeed.  If you do this, you           should not put the definition of "__real_malloc" in the same file           as "__wrap_malloc"; if you do, the assembler may resolve the call           before the linker has a chance to wrap it to "malloc".
第三方 C++ 庫

link seam 同樣適用於第三方 C++ 庫

比方說公司某個基礎庫團隊提供了了 File class,但是這個 class 沒有使用虛函數,我們無法通過 sub-classing 的辦法來實現 mock object。

class File : boost::noncopyable{ public:  File(const char* filename);  ~File();    int readn(void* data, int len);  int writen(const void* data, int len);  size_t getSize() const; private:};

如果需要為用到 File class 的程式編寫單元測試,那麼我們可以自己定義其成員函數的實現,這樣可以注入任何我們想要的結果。

// MockFile.ccint File::readn(void* data, int len){  return -1;}

(這個做法對動態庫是可行的,靜態庫會報錯。我們要麼讓對方提供專供單元測試的動態庫,要麼拿過源碼來自己編譯一個。)

Java 也有類似的做法,在 class path 裡替換我們自己的 stub jar 檔案,以實現 link seam。不過 Java 有動態代理,很少用得著 link seam 來實現依賴注入。

相關文章

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.