C++ 工程实践(6):单元测试如何 mock 系统调用

本文涉及的产品
注册配置 MSE Nacos/ZooKeeper,118元/月
服务治理 MSE Sentinel/OpenSergo,Agent数量 不受限
云原生网关 MSE Higress,422元/月
简介:

陈硕 (giantchen_AT_gmail)

Blog.csdn.net/Solstice

陈硕关于 C++ 工程实践的系列文章

陈硕博客文章合集下载: 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 来实现依赖注入。


    本文转自 陈硕  博客园博客,原文链接:http://www.cnblogs.com/Solstice/archive/2011/05/16/2047255.html,如需转载请自行联系原作者




相关实践学习
基于MSE实现微服务的全链路灰度
通过本场景的实验操作,您将了解并实现在线业务的微服务全链路灰度能力。
相关文章
|
1月前
|
存储 算法 C++
【C++数据结构——查找】二分查找(头歌实践教学平台习题)【合集】
二分查找的基本思想是:每次比较中间元素与目标元素的大小,如果中间元素等于目标元素,则查找成功;顺序表是线性表的一种存储方式,它用一组地址连续的存储单元依次存储线性表中的数据元素,使得逻辑上相邻的元素在物理存储位置上也相邻。第1次比较:查找范围R[0...10],比较元素R[5]:25。第1次比较:查找范围R[0...10],比较元素R[5]:25。第2次比较:查找范围R[0..4],比较元素R[2]:10。第3次比较:查找范围R[3...4],比较元素R[3]:15。,其中是顺序表中元素的个数。
136 68
【C++数据结构——查找】二分查找(头歌实践教学平台习题)【合集】
|
1月前
|
存储 C语言 C++
【C++数据结构——栈与队列】顺序栈的基本运算(头歌实践教学平台习题)【合集】
本关任务:编写一个程序实现顺序栈的基本运算。开始你的任务吧,祝你成功!​ 相关知识 初始化栈 销毁栈 判断栈是否为空 进栈 出栈 取栈顶元素 1.初始化栈 概念:初始化栈是为栈的使用做准备,包括分配内存空间(如果是动态分配)和设置栈的初始状态。栈有顺序栈和链式栈两种常见形式。对于顺序栈,通常需要定义一个数组来存储栈元素,并设置一个变量来记录栈顶位置;对于链式栈,需要定义节点结构,包含数据域和指针域,同时初始化栈顶指针。 示例(顺序栈): 以下是一个简单的顺序栈初始化示例,假设用C语言实现,栈中存储
142 77
|
1天前
|
JSON 前端开发 API
以项目登录接口为例-大前端之开发postman请求接口带token的请求测试-前端开发必学之一-如果要学会联调接口而不是纯写静态前端页面-这个是必学-本文以优雅草蜻蜓Q系统API为实践来演示我们如何带token请求接口-优雅草卓伊凡
以项目登录接口为例-大前端之开发postman请求接口带token的请求测试-前端开发必学之一-如果要学会联调接口而不是纯写静态前端页面-这个是必学-本文以优雅草蜻蜓Q系统API为实践来演示我们如何带token请求接口-优雅草卓伊凡
22 5
以项目登录接口为例-大前端之开发postman请求接口带token的请求测试-前端开发必学之一-如果要学会联调接口而不是纯写静态前端页面-这个是必学-本文以优雅草蜻蜓Q系统API为实践来演示我们如何带token请求接口-优雅草卓伊凡
|
1月前
|
存储 C++
【C++数据结构——树】哈夫曼树(头歌实践教学平台习题) 【合集】
【数据结构——树】哈夫曼树(头歌实践教学平台习题)【合集】目录 任务描述 相关知识 测试说明 我的通关代码: 测试结果:任务描述 本关任务:编写一个程序构建哈夫曼树和生成哈夫曼编码。 相关知识 为了完成本关任务,你需要掌握: 1.如何构建哈夫曼树, 2.如何生成哈夫曼编码。 测试说明 平台会对你编写的代码进行测试: 测试输入: 1192677541518462450242195190181174157138124123 (用户分别输入所列单词的频度) 预
58 14
【C++数据结构——树】哈夫曼树(头歌实践教学平台习题) 【合集】
|
1月前
|
存储 C++ 索引
【C++数据结构——栈与队列】环形队列的基本运算(头歌实践教学平台习题)【合集】
【数据结构——栈与队列】环形队列的基本运算(头歌实践教学平台习题)【合集】初始化队列、销毁队列、判断队列是否为空、进队列、出队列等。本关任务:编写一个程序实现环形队列的基本运算。(6)出队列序列:yzopq2*(5)依次进队列元素:opq2*(6)出队列序列:bcdef。(2)依次进队列元素:abc。(5)依次进队列元素:def。(2)依次进队列元素:xyz。开始你的任务吧,祝你成功!(4)出队一个元素a。(4)出队一个元素x。
43 13
【C++数据结构——栈与队列】环形队列的基本运算(头歌实践教学平台习题)【合集】
|
1月前
|
算法 C++
【C++数据结构——查找】二叉排序树(头歌实践教学平台习题)【合集】
【数据结构——查找】二叉排序树(头歌实践教学平台习题)【合集】 目录 任务描述 相关知识 测试说明 我的通关代码: 测试结果: 任务描述 本关任务:实现二叉排序树的基本算法。 相关知识 为了完成本关任务,你需要掌握:二叉树的创建、查找和删除算法。具体如下: (1)由关键字序列(4,9,0,1,8,6,3,5,2,7)创建一棵二叉排序树bt并以括号表示法输出。 (2)判断bt是否为一棵二叉排序树。 (3)采用递归方法查找关键字为6的结点,并输出其查找路径。 (4)分别删除bt中关键
53 11
【C++数据结构——查找】二叉排序树(头歌实践教学平台习题)【合集】
|
1月前
|
C++ 芯片
【C++面向对象——类与对象】Computer类(头歌实践教学平台习题)【合集】
声明一个简单的Computer类,含有数据成员芯片(cpu)、内存(ram)、光驱(cdrom)等等,以及两个公有成员函数run、stop。只能在类的内部访问。这是一种数据隐藏的机制,用于保护类的数据不被外部随意修改。根据提示,在右侧编辑器补充代码,平台会对你编写的代码进行测试。成员可以在派生类(继承该类的子类)中访问。成员,在类的外部不能直接访问。可以在类的外部直接访问。为了完成本关任务,你需要掌握。
68 19
|
1月前
|
存储 编译器 数据安全/隐私保护
【C++面向对象——类与对象】CPU类(头歌实践教学平台习题)【合集】
声明一个CPU类,包含等级(rank)、频率(frequency)、电压(voltage)等属性,以及两个公有成员函数run、stop。根据提示,在右侧编辑器补充代码,平台会对你编写的代码进行测试。​ 相关知识 类的声明和使用。 类的声明和对象的声明。 构造函数和析构函数的执行。 一、类的声明和使用 1.类的声明基础 在C++中,类是创建对象的蓝图。类的声明定义了类的成员,包括数据成员(变量)和成员函数(方法)。一个简单的类声明示例如下: classMyClass{ public: int
50 13
|
1月前
|
Java C++
【C++数据结构——树】二叉树的基本运算(头歌实践教学平台习题)【合集】
本关任务:编写一个程序实现二叉树的基本运算。​ 相关知识 创建二叉树 销毁二叉树 查找结点 求二叉树的高度 输出二叉树 //二叉树节点结构体定义 structTreeNode{ intval; TreeNode*left; TreeNode*right; TreeNode(intx):val(x),left(NULL),right(NULL){} }; 创建二叉树 //创建二叉树函数(简单示例,手动构建) TreeNode*create
48 12
|
1月前
|
C++
【C++数据结构——树】二叉树的性质(头歌实践教学平台习题)【合集】
本文档介绍了如何根据二叉树的括号表示串创建二叉树,并计算其结点个数、叶子结点个数、某结点的层次和二叉树的宽度。主要内容包括: 1. **定义二叉树节点结构体**:定义了包含节点值、左子节点指针和右子节点指针的结构体。 2. **实现构建二叉树的函数**:通过解析括号表示串,递归地构建二叉树的各个节点及其子树。 3. **使用示例**:展示了如何调用 `buildTree` 函数构建二叉树并进行简单验证。 4. **计算二叉树属性**: - 计算二叉树节点个数。 - 计算二叉树叶子节点个数。 - 计算某节点的层次。 - 计算二叉树的宽度。 最后,提供了测试说明及通关代
44 10

热门文章

最新文章