C++ 工程实践(6):单元测试如何 mock 系统调用
陈硕 (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.h namespace sockets { int connect(int sockfd, const struct sockaddr_in& addr); } // SocketsOps.cc int 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.cc int 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 connect extern "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.cc int File::readn(void* data, int len) { return -1; }
(这个做法对动态库是可行的,静态库会报错。我们要么让对方提供专供单元测试的动态库,要么拿过源码来自己编译一个。)
Java 也有类似的做法,在 class path 里替换我们自己的 stub jar 文件,以实现 link seam。不过 Java 有动态代理,很少用得着 link seam 来实现依赖注入。