C++ 工程实践(6):单元测试如何 mock 系统调用
陈硕 (giantchen_AT_gmail)
摘要:本文讨论了在编写单元测试时 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 inte易做图ce,把程序里用到的 open、close、read、write、connect、bind、listen、accept、gethostname、getpeername、getsockname 等等函数统统用虚函数封装一层。然后在代码里不要直接调用 open(),而是调用 System::instance().open()。
这样代码主动把控制权交给了 System inte易做图ce,我们可以在这里动动手脚。在写单元测试的时候,把这个 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 inte易做图ce 或 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
补充:软件开发 , Vc ,