分布式程序的自动化回归测试
作者:网络转载 发布时间:[ 2011/5/17 13:53:51 ] 推荐标签:
自动化测试的必要性
我想自动化测试的必要性无需赘言,自动化测试是 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 系统为测试平台不可避免要留下一些垃圾数据,而清理这些数据又会花一些宝贵的工作时间。(你得判断数据是自己的测试生成的还是别人的测试留下的,不能误删了别人的测试数据。)
作为开发人员,我们需要一种单独针对自己编写的那个程序的自动化测试方案,一方面提高日常开发的效率,另一方面作为自己那个程序的功能验证测试集。
我想自动化测试的必要性无需赘言,自动化测试是 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 系统为测试平台不可避免要留下一些垃圾数据,而清理这些数据又会花一些宝贵的工作时间。(你得判断数据是自己的测试生成的还是别人的测试留下的,不能误删了别人的测试数据。)
作为开发人员,我们需要一种单独针对自己编写的那个程序的自动化测试方案,一方面提高日常开发的效率,另一方面作为自己那个程序的功能验证测试集。
本文内容不用于商业目的,如涉及知识产权问题,请权利人联系SPASVO小编(021-61079698-8054),我们将立即处理,马上删除。

sales@spasvo.com