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

本文涉及的产品
服务治理 MSE Sentinel/OpenSergo,Agent数量 不受限
简介:

陈硕 (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 来实现依赖注入。


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




相关实践学习
基于MSE实现微服务的全链路灰度
通过本场景的实验操作,您将了解并实现在线业务的微服务全链路灰度能力。
相关文章
|
2天前
|
敏捷开发 运维 监控
深入探索软件测试中的持续集成与持续部署(CI/CD)实践
【5月更文挑战第17天】 在现代软件开发过程中,"持续集成"(Continuous Integration, CI)和"持续部署"(Continuous Deployment, CD)已经成为提高开发效率、确保代码质量和加速产品上市速度的关键策略。本文将深入探讨CI/CD在软件测试中的应用,包括它们的定义、目的、实施策略以及面临的挑战。通过对自动化测试、版本控制、构建流程和反馈循环的详细分析,我们将揭示如何利用CI/CD实践来优化测试流程,减少错误,并确保高质量的软件交付。
|
2天前
|
敏捷开发 Java Devops
深入理解与应用软件测试中的Mock技术
【5月更文挑战第17天】 在现代软件开发过程中,单元测试是保证代码质量的重要手段。然而,对于依赖外部系统或服务的功能,如何有效进行单元测试一直是一大挑战。Mock技术的引入为这一难题提供了解决方案。本文将详细探讨Mock技术的概念、应用场景以及在实际软件测试中的优势和局限性,同时提供一些最佳实践和常见框架的使用指南。
|
2天前
|
Java 测试技术 数据库
深入理解与应用软件测试中的Mock对象
【5月更文挑战第17天】在软件开发过程中,单元测试是确保代码质量的重要环节。本文将深入探讨Mock对象在软件测试中的应用,分析其对提升测试效率和准确性的重要性。通过具体案例,我们将了解如何创建和使用Mock对象,以及它们如何帮助开发者隔离依赖,模拟外部系统行为,从而使得单元测试更加高效和可靠。
|
4天前
|
设计模式 敏捷开发 监控
深入理解自动化测试框架的设计原则与实践
【5月更文挑战第15天】在软件工程的领域里,自动化测试已成为提高软件开发效率、保障产品质量的重要手段。本文将深入探讨自动化测试框架的设计原则及其在实际项目中的应用实践。通过分析设计模式、模块化、可扩展性等关键因素,揭示构建高效、可靠自动化测试框架的策略和方法。同时,结合实际案例,展示如何在多变的测试需求中保持测试框架的稳定性和灵活性。
|
4天前
|
敏捷开发 监控 Devops
深入探索软件测试中的持续集成与持续部署(CI/CD)实践
【5月更文挑战第13天】 在现代软件开发的快节奏和复杂性中,持续集成(Continuous Integration,CI)与持续部署(Continuous Deployment,CD)成为确保软件质量和加速交付的关键策略。本文将深入探讨CI/CD在软件测试中的应用,解析其核心概念、流程以及面临的挑战,并分享实际案例分析以揭示如何在不断变化的开发环境中维持高效和可靠的软件发布周期。
|
4天前
|
机器学习/深度学习 敏捷开发 监控
深入探索软件测试中的持续集成与持续部署(CI/CD)实践
【5月更文挑战第10天】 在现代软件开发周期中,"持续集成"(CI)与"持续部署"(CD)是提升效率、确保质量的重要环节。本文将详细探讨CI/CD在软件测试中的应用,包括其基本概念、实施策略、工具应用及面临的挑战。不同于一般性概述,本文将重点分析如何优化测试流程以适应CI/CD环境,并提出针对性的改进措施。通过实际案例分析,揭示成功实施CI/CD的最佳实践,并讨论如何在不断变化的技术环境中保持测试策略的前瞻性和灵活性。
|
4天前
|
算法 测试技术 开发者
测试驱动开发(TDD)实战:从理论到实践
【5月更文挑战第8天】TDD实战指南:先测试后开发,确保代码质量与可维护性。核心思想是编写测试用例→实现代码→验证→重构。优点包括提高代码质量、促进设计思考和增强可测试性。实战步骤包括编写独立、明确的测试用例,遵循最小可用原则编写代码,运行测试并分析失败原因,以及在验证通过后进行代码重构与优化。通过TDD,开发者能提升编程技能和项目成功率。
|
4天前
|
Java 测试技术 Maven
5个编写技巧,有效提高单元测试实践
本文作者详细讲解了关于单元测试的相关知识,做好单元测试能有效地保障代码质量,本文将手把手教你学会应用单元测试并附有案例、测试插件。
|
4天前
|
Java 测试技术 开发者
卓越工程之单元测试在行权鉴权中的实践
这篇文章着重在“实践”上,是对Java编程技巧之单元测试用例编写流程这篇文章的实际应用,并没有高深的理论和技术。
53 10
|
4天前
|
Web App开发 JSON 前端开发
我理解的测试开发与实践总结——新人篇
本文以作者的视角,讲述了测试与开发、产品之间的关系,如何做好一个测试以及做好一个测试应当具有的素质与技能。