分布式程序的自动化回归测试
陈硕 (giantchen_AT_gmail)
Blog.csdn.net/Solstice t.sina.com.cn/giantchen
陈硕关于分布式系统的系列文章:http://blog.csdn.net/Solstice/category/802325.aspx
本作品采用“Creative Commons 署名-非商业性使用-禁止演绎 3.0 Unported 许可协议(cc by-nc-nd)”进行许可。
http://creativecommons.org/licenses/by-nc-nd/3.0/
本文所谈的“测试”全部指的是“开发者测试/developer testing”,由程序员自己来做,不是由 QA 团队进行的系统测试。这两种测试各有各的用途,不能相互替代。
我在《朴实的C++设计》一文中谈到“为了确保正确性,我们另外用Java写了一个测试夹具(test harness)来测试我们这个C++程序。这个测试夹具模拟了所有与我们这个C++程序打交道的其他程序,能够测试各种正常或异常的情况。基本上任何代码改动和bug修复都在这个夹具中有体现。如果要新加一个功能,会有对应的测试用例来验证其行为。如果发现了一个bug,先往夹具里加一个或几个能复现bug的测试用例,然后修复代码,让测试通过。我们积累了几百个测试用例,这些用例表示了我们对程序行为的预期,是一份可以运行的文档。每次代码改动提交之前,我们都会执行一遍测试,以防低级错误发生。”
今天把 test harness 这个做法仔细说一说。
自动化测试的必要性
我想自动化测试的必要性无需赘言,自动化测试是 absolutely good stuff。
基本上,要是没有自动化的测试,我是不敢改产品代码的(“改”包括添加新功能和重构)。自动化测试的作用是把程序已经实现的 features 以 test case 的形式固化下来,将来任何代码改动如果破坏了现有的功能需求就会触发测试 failure。好比 DNA 双链的互补关系,这种互补结构对保持生物遗传的稳定有重要作用。类似的,自动化测试与被测程序的互补结构对保持系统的功能稳定有重要作用。
单元测试的能与不能
一提到自动化测试,我猜很多人想到的是单元测试(unit testing)。单元测试确实有很大的用处,对于解决某一类型的问题很有帮助。粗略地说,单元测试主要用于测试一个函数、一个 class 或者相关的几个 classes。
最典型的是测试纯函数,比如计算个人所得税的函数,输出是“起征点、扣除五险一金之后的应纳税所得额、税率表”,输出是应该缴的个税。又比如我在《〈程序中的日期与时间〉第一章 日期计算》中用单元测试来验证 Julian day number 算法的正确性。再比如我在《“过家家”版的移动离线计费系统实现》和《模拟银行窗口排队叫号系统的运作》中用单元测试来检查程序运行的结果是否符合预期。(最后这个或许不是严格意义上的单元测试,更像是验收测试。)
为了能用单元测试,主代码有时候需要做一些改动。这对 Java 通常不构成问题(反正都编译成 jar 文件,在运行的时候指定 entry point)。对于 C++,一个程序只能有一个 main() 入口点,要采用单元测试的话,需要把功能代码(被测对象)做成一个 library,然后让单元测试代码(包含 main() 函数)link 到这个 library 上;当然,为了正常启动程序,我们还需要写一个普通的 main(),并 link 到这个 library 上。
单元测试的缺点
根据我的个人经验,我发现单元测试有以下缺点。
- 阻碍大型重构。
单元测试是白盒测试,测试代码直接调用被测代码,测试代码与被测代码紧耦合。从理论上说,“测试”应该只关心被测代码实现的功能,不用管它是如何实现的(包括它提供什么样的函数调用接口)。比方说,以前面的个税计算器函数为例,作为使用者,我们只关心它算的结果是否正确。但是,如果要写单元测试,测试代码必须调用被测代码,那么测试代码必须要知道个税计算器的 package、class、method name、parameter list、return type 等等信息,还要知道如何构造这个 class。以上任何一点改动都会造成测试失败(编译就不通过)。
在添加新功能的时候,我们常会重构已有的代码,在保持原有功能的情况下让代码的“形状”更适合实现新的需求。一旦修改原有的代码,单元测试就可能编译不过:比如给成员函数或构造函数添加一个参数,或者把成员函数从一个 class 移到另一个 class。对于 Java,这个问题还比较好解决,因为 IDE 的重构功能很强,能自动找到 references,并修改之。
对于 C++,这个问题更为严重,因为一改功能代码的接口,单元测试就编译不过了,而 C++ 通常没有自动重构工具(语法太复杂,语意太微妙)可以帮我们,都得手动来。要么每改动一点功能代码就修复单元测试,让编译通过;要么留着单元测试编译不通过,先把功能代码改成我们想要的样子,再来统一修复单元测试。
这两种做法都有困难,前者,C++ 编译缓慢,如果每改动一点就修复单元测试,一天下来也前进不了几步,很多时间浪费在等待编译上;后者,问题更严重,单元测试与被测代码的互补性是保证程序功能稳定的关键,如果大幅修改功能代码的同时又大幅修改了单元测试,那么如何保证前后的单元测试的效果(测试点)不变?如果单元测试自身的代码发生了改动,如何保证它测试结果的有效性?会不会某个手误让功能代码和单元测试犯了相同的错误,负负得正,测试还是绿的,但是实际功能已经亮了红灯?难道我们要为单元测试写单元测试吗?
有时候,我们需要重新设计并重写某个程序(有可能换用另一种语言)。这时候旧代码中的单元测试完全作废了(代码结构发生巨大改变,甚至连编程语言都换了),其中包含的宝贵的业务知识也付之东流,岂不可惜?
- 为了方便测试而施行依赖注入,破坏代码的整体性。
为了让代码具有“可测试性”,我们常会使用依赖注入技术,这么做的好处据说是“解耦”(其实,有人一句话道破真相:但凡你在某个地方切断联系,那么你必然会在另一个地方重新产生联系),坏处就是割裂了代码的逻辑:单看一块代码不知道它是干嘛的,它依赖的对象不知道在哪儿创建的,如果一个 interface 有多个实现,不到运行的时候不知道用的是哪个实现。(动态绑定的初衷就是如此,想来读过“以面向对象思想实现”的代码的人都明白我在说什么。)
以《Muduo 网络编程示例之二:Boost.Asio 的聊天服务器》中出现的聊天服务器 ChatServer 为例,ChatServer 直接使用了 muduo::net::TcpServer 和 muduo::net::TcpConnection 来处理网络连接并收发数据,这个设计简单直接。如果要为 ChatServer 写单元测试,那么首先它肯定不能在构造函数里初始化 TcpServer 了。
稍微复杂一点的测试要用 mock object。ChatServer 用 TcpServer 和 TcpConenction 来收发消息,为了能单元测试,我们要为 TcpServer 和 TcpConnection 提供 mock 实现,原本一个具体类 TcpServer 就变成了一个 interface TcpServer 加两个实现 TcpServerImpl 和 TcpServerMock,同理 TcpConnection 也一化为三。ChatServer 本身的代码也变得复杂,我们要设法把 TcpServer 和 TcpConnection 注入到其中,ChatServer 不能自己初始化 TcpServer 对象。
这恐怕是在 C++ 中使用单元测试的主要困难之一。Java 有动态代理,还可以用 cglib 来操作字节码以实现注入。而 C++ 比较原始,只能自己手工实现 interface 和 implementations。这样原本紧凑的以 concrete class 构成的代码结构因为单元测试的需要而变得松散(所谓“面向接口编程”嘛),而这么做的目的仅仅是为了满足“源码级的可测试性”,是不是有一点因小失大呢?(这里且暂时忽略虚函数和普通函数在性能上的些微差别。)对于不同的 test case,可能还需要不同的 mock 对象,比如 TcpServerMock 和 TcpServerFailureMock,这又增加了编码的工作量。
此外,如果程序中用到的涉及 IO 的第三方库没有以 interface 方式暴露接口,而是直接提供的 concrete class (这是对的,因为C++中应该《避免使用虚函数作为库的接口》),这也让编写单元变得困难,因为总不能自己挨个 wrapper 一遍吧?难道用 link-time 的注入技术?
- 某些 failure 场景难以测试,而考察这些场景对编写稳定的分布式系统有重要作用。比方说:网络连不上、数据库超时、系统资源不足。
- 对多线程程序无能为力。如果一个程序的功能涉及多个线程合作,那么就比较难用单元测试来验证其正确性。
- 如果程序涉及比较多的交互(指和其他程序交互,不是指图形用户界面),用单元测试来构造测试场景比较麻烦,每个场景要写一堆无趣的代码。而这正是分布式系统最需要测试的地方。
总的来说,单元测试是一个值得掌握的技术,用在适当的地方确实能提高生产力。同时,在分布式系统中,我们还需要其他的自动化测试手段。
分布式系统测试的要点
在分布式系统中,class 与 function 级别的单元测试对整个系统的帮助不大,当然,这种单元测试对单个程序的质量有帮助;但是,一堆砖头垒在一起是变不成大楼的。
分布式系统测试的要点是测试进程间的交互:一个进程收到客户请求,该如何处理,然后转发给其他进程;收到响应之后,又修改并应答客户。测试这些多进程协作的场景才算测到了点子上。
假设一个分布式系统由四五种进程组成,每个程序有各自的开发人员。对于整个系统,我们可以用脚本来模拟客户,自动化地测试系统的整体运作情况,这种测试通常由 QA 团队来执行,也可以作为系统的冒烟测试。
对于其中每个程序的开发人员,上述测试方法对日常的开发帮助不大,因为测试要能通过必须整个系统都正常运转才行,在开发阶段,这一点不是时时刻刻都能满足(有可能你用到的新功能对方还没有实现,这反过来影响了你的进度)。另一方面,如果出现测试失败,开发人员不能立刻知道这是自己的程序出错,有可能是环境原因造成的错误,这通常要去读程序日志才能判定。还有,作为开发者测试,我们希望它无副作用,每天反复多次运行也不会增加整个环境的负担,以整个 QA 系统为测试平台不可避免要留下一些垃圾数据,而清理这些数据又会花一些宝贵的工作时间。(你得判断数据是自己的测试生成的还是别人的测试留下的,不能误删了别人的测试数据。)
作为开发人员,我们需要一种单独针对自己编写的那个程序的自动化测试方案,一方面提高日常开发的效率,另一方面作为自己那个程序的功能验证测试集(即回归测试/regression tests)。
分布式系统的抽象观点
一台机器两根线
形象地来看,一个分布式系统就是一堆机器,每台机器的屁股上拖着两根线:电源线和网线(不考虑 SAN 等存储设备),电源线插到电源插座上,网线插到交换机上。
这个模型实际上说明,一台机器的表现出来的行为完全由它接出来的两根线展现,今天不谈电源线,只谈网线。(“在乎服务器的功耗”在我看来就是公司利润率很低的标志,要从电费上抠成本。)
如果网络是普通的千兆以太网,那么吞吐量不大于 125MB/s。这个吞吐量比起现在的 CPU 运算速度和内存带宽简直小得可怜。这里我想提的是,对于不特别在意 latency 的应用,只要能让千兆以太网的吞吐量饱和或接近饱和,用什么编程语言其实无所谓。Java 做网络服务端开发也是很好的选择(不是指 web 开发,而是做一些基础的分布式组件,例如 ZooKeeper 和 Hadoop 之类)。尽管可能 C++ 只用了 15% 的 CPU,而 Java 用了 30% 的 CPU,Java 还占用更多的内存,但是千兆网卡带宽都已经跑满,那些省下在资源也只能浪费了;对于外界(从网线上看过来)而言,两种语言的效果是一样的,而通常 Java 的开发效率更高。(Java 是比 C++ 慢一些,但是透过千兆网络不一定还能看得出这个区别来。)
进程间通过 TCP 相互连接
陈硕在《多线程服务器的常用编程模型》第 5 节“进程间通信”中提倡仅使用 TCP 作为进程间通信的手段,今天这个观点将再次得到验证。
以下是 Hadoop 的分布式文件系统 HDFS 的架构简图。
HDFS 有四个角色参与其中,NameNode(保存元数据)、DataNode(存储节点,多个)、Secondary NameNode(定期写 check point)、Client(客户,系统的使用者)。这些进程运行在多台机器上,之间通过 TCP 协议互联。程序的行为完全由它在 TCP 连接上的表现决定(TCP 就好比前面提到的“网线”)。
在这个系统中,一个程序其实不知道与自己打交道的到底是什么。比如,对于 DataNode,它其实不在乎自己连接的是真的 NameNode 还是某个调皮的小孩用 Telnet 模拟的 NameNode,它只管接受命令并执行。对于 NameNode,它其实也不知道 DataNode 是不是真的把用户数据存到磁盘上去了,它只需要根据 DataNode 的反馈更新自己的元数据就行。这已经为我们指明了方向。
一种自动化的回归测试方案
假如我是 NameNode 的开发者,为了能自动化测试 NameNode,我可以为它写一个 test harness (这是一个独立的进程),这个 test harness 仿冒(mock)了与被测进程打交道的全部程序。如下图所示,是不是有点像“缸中之脑”?
对于 DataNode 的开发者,他们也可以写一个专门的 test harness,模拟 Client 和 NameNode。
Test harness 的优点
- 完全从外部观察被测程序,对被测程序没有侵入性,代码该怎么写就怎么写,不需要为测试留路。
- 能测试真实环境下的表现,程序不是单独为测试编译的版本,而是将来真实运行的版本。数据也是从网络上读取,发送到网络上。
- 允许被测程序做大的重构,以优化内部代码结构,只要其表现出来的行为不变,测试就不会失败。(在重构期间不用修改 test case。)
- 能比较方便地测试 failure 场景。比如,若要测试 DataNode 出错时 NameNode 的反应,只要让 test harness 模拟的那个 mock DataNode 返回我们想要的出错信息。要测试 NameNode 在某个 DataNode 失效之后的反应,只要让 test harness 断开对应的网络连接即可。要测量某请求超时的反应,只要让 Test harness 不返回结果即可。这对构建可靠的分布式系统尤为重要。
- 帮助开发人员从使用者的角度理解程序,程序的哪些行为在外部是看得到的,哪些行为是看不到的。
- 有了一套比较完整的 test cases 之后,甚至可以换种语言重写被测程序(假设为了提高内存利用率,换用 C++ 来重新实现 NameNode),测试用例依旧可用。这时 test harness 起到知识传承的作用。
- 发现 bug 之后,往 test harness 里添加能复现 bug 的 test case,修复 bug 之后,test case 继续留在 harness 中,反正出现回归(regression)。
实现要点
- Test harness 的要点在于隔断被测程序与其他程序的联系,它冒充了全部其他程序。这样被测程序就像被放到测试台上观察一样,让我们只关注它一个。
- Test harness 要能发起或接受多个 TCP 连接,可能需要用某个现成的 NIO 网络库,如果不想写成多线程程序的话。
- Test harness 可以与被测程序运行在同一台机器,也可以运行在两台机器上。在运行被测程序的时候,可能要用一个特殊的启动脚本把它依赖的 host:port 指向 test harness。
- Test harness 只需要表现得跟它要 mock 的程序一样,不需要真的去实现复杂的逻辑。比如 mock DataNode 只需要对 NameNode 返回“Yes sir, 数据已存好”,而不需要真的把数据存到硬盘上。若要 mock 比较复杂的逻辑,可以用“记录+回放”的方式,把预设的响应放到 test case 里回放(replay)给被测程序。
- 因为通信走 TCP 协议,test harness 不一定要和被测程序用相同的语言,只要符合协议就行。试想如果用共享内存实现 IPC,这是不可能的。陈硕在《在 muduo 中实现 protobuf 编解码器与消息分发器》中提到利用 protobuf 的跨语言特性,我们可以采用 Java 为 C++ 服务程序编写 test harness。其他跨语言的协议格式也行,比如 XML 或 Json。
- Test harness 运行起来之后,等待被测程序的连接,或者主动连接被测程序,或者兼而有之,取决于所用的通信方式。
- 一切就绪之后,Test harness 依次执行 test cases。一个 NameNode test case 的典型过程是:test harness 模仿 client 向被测 NameNode 发送一个请求(eg. 创建文件),NameNode 可能会联络 mock DataNode,test harness 模仿 DataNode 应有的响应,NameNode 收到 mock DataNode 的反馈之后发送响应给 client,这时 test harness 检查响应是否符合预期。
- Test harness 中的 test cases 以配置文件(每个 test case 有一个或多个文本配置文件,每个 test case 占一个目录)方式指定。test harness 和 test cases 连同程序代码一起用 version controlling 工具管理起来。这样能复现以外任何一个版本的应有行为。
- 对于比较复杂的 test case,可以用嵌入式脚本语言来描述场景。如果 test harness 是 Java 写的,那么可以嵌入 Groovy,就像陈硕在《“过家家”版的移动离线计费系统实现》中用 Groovy 实现计费逻辑一样。Groovy 调用 test harness 模拟多个程序分别发送多份数据并验证结果,groovy 本身就是程序代码,可以有逻辑判断甚至循环。这种动静结合的做法在不增加 test harness 复杂度的情况下提供了相当高的灵活性。
- Test harness 可以有一个命令行界面,程序员输入“run 10”就选择执行第 10 号 test case。
几个实例
Test harness 这种测试方法适合测试有状态的、与多个进程通信的分布式程序,除了 Hadoop 中的 NameNode 与 DataNode,我还能想到几个例子。
1. chat 聊天服务器
聊天服务器会与多个客户端打交道,我们可以用 test harness 模拟 5 个客户端,模拟用户上下线,发送消息等情况,自动检测聊天服务器的工作情况。
2. 连接服务器、登录服务器、逻辑服务器
这是云风在他的 blog 中提到的三种网游服务器(http://blog.codingnow.com/2007/02/user_authenticate.html,http://blog.codingnow.com/2006/04/iocp_kqueue_epoll.html,http://blog.codingnow.com/2010/11/go_prime.html),我这里借用来举例子。
如果要为连接服务器写 test harness,那么需要模拟客户(发起连接)、登录服务器(验证客户资料)、逻辑服务器(收发网游数据),有了这样的 test harness,可以方便地测试连接服务器的正确性,也可以方便地模拟其他各个服务器断开连接的情况,看看连接服务器是否应对自如。
同样的思路,可以为登录服务器写 test harness。(我估计不用为逻辑服务器再写了,因为肯定已经有自动测试了。)
3. 多 master 之间的二段提交
这是分布式容错的一个经典做法。用 test harness 能把 primary master 和 secondary masters 单独拎出来测试。在测试 primary master 的时候,test harness 扮演 name service 和 secondary masters。在测试 secondary master 的时候,test harness 扮演 name service、primary master、其他 secondary masters。可以比较容易地测试各种 failure 情况。如果不这么做,而直接部署多个 masters 来测试,恐怕很难做到自动化测试。
4. paxos 的实现
Paxos 协议的实现肯定离不了单元测试,因为涉及多个角色中比较复杂的状态变迁。同时,如果我要写 paxos 实现,那么 test harness 也是少不了的,它能自动测试 paxos 节点在真实网络环境下的表现,并且轻易模拟各种 failure 场景。
局限性
如果被测程序有 TCP 之外的 IO,或者其 TCP 协议不易模拟(比如通过 TCP 连接数据库),那么这种测试方案会受到干扰。
对于数据库,如果被测程序只是简单的从数据库 select 一些配置信息,那么或许可以在 test harness 里内嵌一个 in-memory H2 DB engine,然后让被测程序从这里读取数据。当然,前提是被测程序的 DB driver 能连上 H2 (或许不是大问题,H2 支持 JDBC 和 部分 ODBC)。如果被测程序有比较复杂的 SQL 代码,那么 H2 表现的行为不一定和生产环境的数据库一致,这时候恐怕还是要部署测试数据库(有可能为每个开发人员部署一个小的测试数据库,以免相互干扰)。
如果被测程序有其他 IO (写 log 不算),比如 DataNode 会访问文件系统,那么 test harness 没有能把 DataNode 完整地包裹起来,有些 failure cases 不是那么容易测试。这是或许可以把 DataNode 指向 tmpfs,这样能比较容易测试磁盘满的情况。当然,这样也有局限性,因为 tmpfs 没有真实磁盘那么大,也不能模拟磁盘读写错误。我不是分布式存储方面的专家,这些问题留给分布式文件系统的实现者去考虑吧。(测试 paxos 节点似乎也可以用 tmpfs 来模拟 persist storage,由 test case 填充所需的初始数据。)
其他用处
Test harness 除了实现 features 的回归测试,它还有别的用处。
- 加速开发,提高生产力。
前面提到,如果有个新功能(增加一种新的 request type)需要改动两个程序,有可能造成相互等待:客户程序 A 说要先等服务程序 B 实现对应的功能响应,这样 A 才能发送新的请求,不然每次请求就会被拒绝,无法测试;服务程序 B 说要先等 A 能够发送新的请求,这样自己才能开始编码与测试,不然都不知道请求长什么样子,也触发不了新写的代码。(当然,这是我虚构的例子。)
如果 A 和 B 都有各自的 test harness,事情就好办了,双方大致商量一个协议格式,然后分头编码。程序 A 的作者在自己的 harness 里边添加一个 test case,模拟他认为 B 应有的响应,这个响应可以 hard code 某种最常见的响应,不必真的实现所需的判断逻辑(毕竟这是程序 B 的作者该干的事情),然后程序 A 的作者就可以编码并测试自己的程序了。同理,程序 B 的作者也不用等 A 拿出一个半成品来发送新请求,他往自己的 harness 添加一个 test case,模拟他认为 A 应该发送的请求,然后就可以编码并测试自己的新功能。双方齐头并进,减少扯皮。等功能实现得差不多了,两个程序互相连一连,如果发现协议有不一致,检查一下 harness 中的新 test cases(这代表了 A/B 程序对对方的预期),看看那边改动比较方便,很快就能解决问题。
- 压力测试。
Test harness 稍作改进还可以变功能测试为压力测试,供程序员 profiling 用。比如反复不间断发送请求,向被测程序加压。不过,如果被测程序是 C++ 写的,而 test harness 是 Java 写的,有可能出现 test harness 占 100% CPU,而被测程序还跑得优哉游哉的情况。这时候可以单独用 C++ 写一个负载生成器。
小结
以单独的进程作为 test harness 对于开发分布式程序相当有帮助,它能达到单元测试的自动化程度和细致程度,又避免了单元测试对功能代码结构的侵入与依赖。