近半年在做分布式系统开发的同时,也做了不少的测试工作,软件工程教科书上描述软件项目的流程,基本上都会提到单元测试、集成测试、压力测试等名词,但对这些词汇一直停留在理论认识阶段。研究生阶段做的项目,因为要求不高,基本上也没做什么测试工作;去年在实习的时候,因为时间有限,主要接触单元测试和系统功能测试;直到现在才把这些词汇都近距离的感受了一下。
单元测试
分布式系统的开发工作通常会被划分成多个模块,由不同的开发人员分别编写程序,所以代码的单元测试工作通常是针对单个模块进行的。如果模块是独立的,并且功能集足够小,单元测试是很容易做的,构造一组case,尽量覆盖所有的分支基本上就OK。但实际上分布式系统里很少有完全独立的模块,大部分的模块都会跟其他的模块有依赖关系或是网络通信等。
对于有依赖的模块的单元测试,理想情况下,依赖的模块都已经准备好,并且被测试过没有问题(这个实际上是做不到的,而且模块间有时还会存在相互依赖的情况),这种理想情况会严重影响开发效率,使得有依赖的模块就只能串行开发测试。另外,如果依赖的模块在安装、部署上需要花费很长的时间(比如是一个配置比较麻烦的server),那么每次单元测试都需要把server部署起来,测试成本是很高的。
实际单元测试的过程中,我们经常会把依赖其他模块的地方用简单的代码代替,也就是做mock。最直观的mock方式就是通过宏来控制,即如果是以DEBUG模式运行,执行某段简单的代码(mock),如果非DEBUG模式运行,则执行实际的代码逻辑。比如通过下面一段代码把“从网络上获取数据”在DEBUG模式运行时替换为“从本地内存获取数据”,那么在执行单元测试时,我们就不需要依赖网络的对端来提供数据,从而方便的测试代码逻辑。而实际fetch_data_from_network()的测试可延迟到功能测试阶段。
#ifndef DEBUG_MODE fetch_data_from_network(); #else fetch_data_from_local_memory(); #fi
使用宏来控制有时会使得代码读起来很混乱,如果是使用C++开发(或其他面向对象编程语言),更好的方式是借助多态的性质来做mock,如下面一段代码,DataManager是一个负责管理数据的类,它需要从网络上其他的服务里获取数据,MockDataManager是一个继承自DataManager的类,它从本地内存获取数据,在测试时,我们可以将DataManager的实例换成MockDataManager的实例来运行(必须是指针或是引用),这样思路跟前面的思路其实是一样的,只不过借助多态,更清晰明了,需要做的事情更少。google test和google mock是开源的测试、以及mock框架,使用他们会使你的测试工作更简单,更有趣。
class DataManager { public: DataManager(); ~DataManager(); virtual int fetch_data() { fetch_data_from_network(); } ... }; class MockDataManager : public DataManager { public: virtual int fetch_data() { fetch_data_from_local_memory(); } };
测试久了之后,你会发现测试的工作量往往跟代码结构的设计有很大的关系,如果代码结构本身毫无章法,再加进去一堆为单元测试而写的代码逻辑,只会令代码看起来更加糟糕;如果设计之初就考虑测试需求,尽量把业务与逻辑层次分开降低模块间依赖性,把可能需要mock测试的地方设计为虚基类等,在做测试的时候需要做的工作就会很少,而且测试新增代码不会打乱现在的代码逻辑结构。
集成(功能)测试
完成单元测试仅仅只是一个开端,做好单元测试是开发人员对代码负责任的表现,一旦模块间有依赖,单元测试做得再好,到了集成测试阶段,问题还是少不了的。集成测试阶段需要把多个模块组合起来一起测试,原来mock测试的地方,现在要玩真的了,但一旦单元测试做得足够充分,在集成测试阶段如果发现问题,那就能很快定位出一定是模块间通信的时候出问题了,测试效率就会非常高。试想两个开发分别开发了客户端模块C和服务器模块S,在完全没有单元测试的情况下,CS组合起来测试,C发送请求给S,请求没有正确执行,这时错误可能在C发送请求开始到接受反馈路径上的任何一个地方,定位问题的难度可想而知,而且在一条长路径上,要保证测试覆盖率的难度也会成倍增长。在集成测试阶段,我们主要关注在功能的正确性上,因为对于异常的case(比如输入不合法等),大都在单元测试里已经完成了。
压力测试
在功能测试后,发现所有的测试结果都符合预期了,似乎一切都正常了,但这些远远不够,在无压力的情况下,很多代码都是“正确”的,但一旦压力打了之后,这些代码可能就有错误了,在压力测试阶段,要模拟各种恶劣的场景。比如在一个分布式系统里,一个客户端访问时服务正常,十个、一百个、一千个并发访问时呢? 可能出现某段代码因为存在内存泄露而OOM,某些地方出现数组访问越界而coredump等等,总之,在这个阶段,千万不要心软,尽可能用上“十大酷刑”,尽早把潜在的问题逼出来。
分享下我前段时间做分布式文件系统压力测试的两个例子。(1)在并发访问量很大很大时,客户端的请求会出现少量超时请求,调查后发现是在某个失败场景下,服务器没有给客户端回包导致的。(2)写入的大量数据后做读取验证时,发现有小部分请求读到的数据与写入的数据内容不匹配,调查后发现是在极少的文件更新场景下才会出现的bug。
性能测试
性能测试针对性很强,在系统测试之初,对系统的某些指标都是有预估的,期望他们能达到某个水平,满足某个业务的需求。性能测试阶段首先是要验证系统是否能符合期望值,其次还要发现系统瓶颈所在,并不断改善优化。对于一些通用的性能指标,推荐使用一些广泛被使用的工具(测出的数据能被认可,并且方便交流),比如测试文件系统的性能指标,使用IOZone, IOMeter等,测试web服务器使用loadrunner,AB等工具;只有实在没有现成工具可用时,才自己编写测试工具。
回归测试
系统一旦上线运行后,肯定会不断暴露出一些测试没有覆盖到的问题或是实现时考虑欠缺的问题,刚开始bug较多,随着不断修复bug及时间推移,bug数量越来越少,最终到达一个稳态。修复bug本身通常很简单,但有时在修复bug时,由于考虑不周全,或是“手抖了一下”,可能会引发新的bug,这时就是回归测试大显身手的时候,每次修复完bug,就对系统做一次全面的回归测试,没有问题才上线新版本。建立回归测试环境是有成本的,但对于比较大的项目(通常是迭代式开发的),修改代码通常都是“牵一发而动全身”,回归测试必不可少。
测试总结
回过头再想想经历的测试过程,我发现(1)单测不充分导致后期测试成本高的问题在分布式系统中表现尤为明显;(2)尽可能开发自动化测试工具代替人肉测试,即使后者可能看起来花的时间更短,但实际上工欲善其事必先利其器,武器锋利后(可复用),测试会越来越简单;(3)尽早做测试,问题越早被发现成本越低。