自动测试
手工测试比没有测试强一点,但是它存在的问题让它很难在实践中应用:手工输入数据的过程单调乏味,很难长期坚持。每次都要重新输入数据,浪费大量时间。测试用例不能累积,测试往往不完整。用人脑判断输出的正误,浪费人力也存在误差。要写得好测试自然不能省,要写得快就需要更好的测试方法。
更好的测试方法当然是自动测试了。幸运的是,刚进入这个行业我就接触了自动的测试 (呵,读本文的初学者就更幸运了),我的第一份正式工作是在测试组写测试程序。当时测试组也算是人才济济了,居然有几个北大毕业的,不过她们都不懂 Linux,所以我被指派去为移植到Linux上的模块写测试程序。这些模块都有测试程序,但这些测试程序的功能太弱了,我的上司要求开发人员改进,但那些开发人员太自以为是了,根本不理我们,所以我们只好自己重写这些测试程序。模块很多,大概有50多个模块,熟悉这些模块也需要不少时间,按每两个工作日写一个测试程序,上司给我5个月时间。
记得第一个模块是RDFParser,RDF(资源描述框架)是XML的一种应用,RDFParser实际上是一个XML解析器,并包装成RDF要求的接口。由于我对C/C++还不太熟悉,对RDF更不熟悉了,花了两周时间才写出这个测试程序。运行起来有些不正常,我确信不是测试程序的问题,就去请开发人员帮忙来看一下。负责RDFParser的那个程序员是人大毕业,我没有见过第二个比他更自以为是的程序员了,他刚在我座位上坐下就很大声说,你们 QA的人太蠢了!
当时一听就愣了,不过我是新来的,见上司都没反应,自然就忍了。我列举了一些证据是模块里面的问题,他听也不听,只是不断重复的说,不可能是我程序的问题,你们QA的人太蠢了,总是浪费我的时间。过了一会儿,他终于闭上了嘴巴,又等了一会儿才说,等会儿重新发个版本给你吧。后来又请他过来四五次,结果每次都是他的问题。
之后我再没有听到他说过你们QA的人太蠢了的话。为了避免让他抓到把柄来嘲笑测试组,我决定请他来查问题之前做更详细的测试。当时我写的测试程序和现在初学者写的测试程序没有两样,都是从教科书上学来的,先通过scanf从终端输入数据,调用被测函数,再把结果printf出来,这花了我太多时间。想到后面还有50多个模块的测试程序要写,这样下去不行,一定得想个办法。
后来我把输入的数据和期望的结果都写到一个INI文件中,测试程序从这个文件中读入数据,运行测试,再和预期结果比较,整个过程都自动化了。写了一个INI文件的解析器花了我一周时间,又重写了那个测试程序,整整花了我一个月时间完成RDFParser的测试程序。进度自然大落后了,还好上司知道后并没有责备我,让我慢慢做就好了。
写第二个测试程序时把INI解析的代码拷贝过去,再加一些调用模块的代码就写好了,第三个也是如此。写了几个之后,我发现了INI解析有个BUG,结果每个测试程序我都要去修改,想到维护起来太麻烦了,就把INI解析器的接口规范化了,编译成一个独立共享库。又写了几个测试程序,我写烦了,原因是测试程序无非就是读入数据,调用被测函数,再检查结果,这个过程太无聊了。想到后面还要把这个过程重复几十遍,郁闷了几天之后,突然灵机一动,我决定写了一个代码产生器来产生这些代码。开始的代码产生器用C写的,用一个简单的规则来描述被测函数,通过这些规则来产生测试程序。我把这些东西和INI解析器放在一个独立的库中,把它叫作TesterFrameWork,经过几个测试程序的验证和完善,后来利用这个TesterFrameWork,只要一两个小时就能完成一个测试程序了。有次请开发人员那边一个高手帮我查一个问题,他看一会儿我的TesterFrameWork之后,盯着我说,你太聪明了。我笑了笑说,刚刚开始写C/C++程序。
一年之后我知道了有个CPPUnit之后,为了赶时髦我把TesterFrameWork改名为CxxUnit,非典的时候放假无聊就把它重写了一遍放在cosoft上了(之后没有管过它,或许还在吧)。
一个大系统很难自动测试,而一个独立的模块则是最佳的自动测试单元。自动测试和单元测试几乎成了等价的概念,很多人都以为自动测试就是利用CPPUnit这样的单元测试框架写个测试程序而已,这完全是错误的,就像有人以为有个设计文档的模板,照着填空就能填出好设计一样。
我自己实现过单元测试框架,不是像有些人出于模仿去实现,而完全出于实际的需要,后来我也研究其它测试框架,应该说我对测试程序框架的认识比一般程序员要深刻。我认为测试程序框架可以减化一些测试程序的工作,但它与自动测试没有密切关系,用不用测试程序框架完全是个人喜好。用测试程序框架未必能写出好的测试程序,就像用C++未必能写出好的面向对象的程序一样。
虽然我顺利的完成了那个写测试程序的任务,但我一直被一个问题困扰:如何写测试用例,如何去检测结果?这是测试程序框架帮不上忙的。写测试用例还好说,通过边界值法,等价类法和路径覆盖法找到最常用的测试用例。检测结果呢?有人说很简单啊,判断返回值就好了。那我问一下dlist_insert返回 OK,就真的OK了吗?如果一个函数根本没有返回值,那你怎么判断呢?
测试程序框架是敏捷论者提倡的,在我看来它根本不够敏捷:你要去学习它,了解它的运行机制,要包含它的头文件,链接它的库,有比不用它更敏捷么?重要的是它根本帮不上什么有用的忙。前面的问题折磨了我一段时间,于是得出一个可能有点偏激的结论:测试程序框架都是愚蠢的,你真正需要的,它根本帮不了你 (我知道这样说会得罪一些用测试程序框架的朋友,如果你想找我讨论的话,请看完本节的附带示例代码再说)。
就在那个时候,我看到了孟岩老师翻译的《契约式设计(Design by Contract)》,读完之后豁然开朗。或许我还没有明白契约式设计的本质,但我确实知道了写自动测试程序的方法,下面我介绍一下:
o 在设计时,每个函数只完成单一的功能。单一功能的函数容易理解,也容易预测其行为。对测试来说,给定一些输入数据,就知道它的输出和影响,这样函数是最容易测试的。
o 在设计时,把函数分为查询和命令两类。查询函数只查询对象的状态,而不改变对象的状态。命令函数则只修改对象的状态,只返回其操作是否成功的标志,而不返回对象的状态。比如,dlist_length查询双向链表的长度,它不修改双向链表的任何状态。dlist_delete修改对象的状态(删除结点),并返回其操作是否成功,而不返回当前长度或者删除的结点之类的状态。
o 在设计时,把查询分为基本查询和复合查询两类。基本查询函数只查询单一的状态,而复合查询可以同时查询多个状态。比如,window_get_width 返回窗口的宽度,这是基本查询函数,widget_get_rect返回窗口的左上角坐标,宽度和高度,这是复合查询函数。
o在实现时,检验输入数据,确认使用者正确的调用了函数。契约式设计规定了调用者和实现者双方的责任,调用者需要使用正确的参数,才能保证有正确的结果。政治家告诉我们,信任但要检查,所以作为实现者就需要检查输入参数是否违背了契约。那怎么检查呢?有人说,如果检查到无效参数就返回一个错误码。这当然可以,只是不太好,因为大多数人都没有检查返回值的习惯,如果每个地方都检查函数的返回值,也是件很繁琐的事,代码看起来也比较乱。通常我们只检查一些关键的地方,对于无效参数这样的错误,可能就无声无息的隐藏起来了,这样不好,因为隐藏得越深,发现的时间越晚,修改的代价越大。
在C++和JAVA里,如果参数不正确,通常是throw一个无效参数之类的异常,C语言里面没有异常这个概念,我们需要其它办法才行。有人推荐用 assert来检查,这是一个好办法,assert只在调试版本中有效(没有定义NDEBUG),这样任何无效调用都在调试版本中暴露出来了。如果配合前面返回错误码的方法,在发布版本中也可能避免程序粗暴的死掉。使用方法如下:
assert(thiz != NULL);
if(thiz == NULL)
{
return DLIST_RET_INVALID_PARAMS;
}
我一直使用这种方法,但是有个问题:无法用自动测试验证assert是否正常的触发了,当用错误的参数测试时,我期望assert被触发,但如果assert被触发了,自动程序测试就死掉了,自动测试程序死掉了,就无法继续验证下一个assert。这是一个悖论!
后来我从glib里面学了一招,它检查时不用assert,只是打印出一个警告,代码也简明了,按它的方式,我们这样检查:
return_val_if_fail(cursor != NULL, DLIST_RET_INVALID_PARAMS);
我们需要定义两个宏,一个用于无返回值的函数,一个用于有返回值的函数:
#define return_if_fail(p) if(!(p)) \
{printf("%s:%d Warning: "#p" failed.\n", \
__func__, __LINE__); return;}
#define return_val_if_fail(p, ret) if(!(p)) \
{printf("%s:%d Warning: "#p" failed.\n",\
__func__, __LINE__); return (ret);}
这样一来,遇到无效参数时,可以看到一个警告信息,同时又不会影响自动测试。
o在测试时,用查询来验证命令。命令一般都有返回值,但只检查返回值是不够的。比如dlist_delete返回OK,它真的OK了吗?我们信任它,但还是要检查。怎么检查?很简单,用查询函数来检查对象的状态是不是预期的。
对于dlist_delete,我们预期:
1.输入无效参数,期望返回DLIST_RET_INVALID_PARAMS。
2.输入正确参数,期望:
函数返回DLIST_RET_OK
双向链表的长度减一。
删除的位置的下一个元素被移到删除的位置。
在测试程序中检查时,因为任何不符合期望的结果都是BUG,所以我们用assert检查。这样有问题马上暴露出来了,定位错误比较容易,通常都不需要调试器。我们这样来检查:
assert(dlist_length(dlist) == (n-i));
assert(dlist_delete(dlist, 0) == DLIST_RET_OK);
assert(dlist_length(dlist) == (n-i-1));
if((i + 1) < n)
{
assert(dlist_get_by_index(dlist, 0, (void**)&data) == DLIST_RET_OK);
assert((int)data == (i+1));
}
(完整的例子请看本节的示例代码)
o在测试时,用基本查询去验证复合查询。基本查询和复合查询返回的应该一致。比如:
Rect rect = {0};
widget_get_rect(widget, &rect);
assert(widget_get_width(widget) == rect.width);
assert(widget_get_height(widget)== rect.height);
o在测试时,预期结果依赖其执行上下文,我们要按逻辑组织测试用例。前面调用的函数可能改变了对象的状态,为了简化测试,在每组测试用例开始时,都重置对象到初始状态。
o 在测试时,第一次只写基本的测试用例,以后逐渐累积,每次发现新的BUG就把相应的测试用例加进去。每次修改了代码就运行一遍自动测试,保证修改没有引起其它副作用。
按着上面的原则,应付正常模块的测试没有问题了,但是下面的情况仍然比较棘手:
o 带有GUI的应用程序。有GUI的程序会给自动的输入数据和检查结果带来困难,有些工具可以部分解决这个问题,特别是针对Win32下的GUI,我很少在 Windows下写程序,所以对这方面了解不多。不过最好的办法还是用MVC模型等分离界面和实现,因为界面通常相对比较简单,可以手工测试,而实现的逻辑比较复杂,这部分可以自动测试。后面我们会专门讲解分离界面和实现的方法。
o 有随机数据输入。如果有些输入数据是内部随机产生的,那你根本无法预测它的输出结果和影响。比如游戏随机的步骤和无线网络信号的变化。对于我们可以控制的随机数据,可以提供额外的函数去获取这些数据。对于无法控制的随机输入数据,可以把它们隔离开,在自动测试中,使用固定的数据。
o 多线程运行的程序。多线程的程序也很难自动测试,比如向链表中插入一个元素,当你检查的时候,根本无法知道链表的长度是否增加,也无法知道刚才插入的位置是否是你插入的元素,因为这个时候,可能有另外一个线程已经把它删除了,或者又加入了新的数据。不过在单线程的自动测试通过之后,多线程的问题会大大减少,剩下的问题我们可以通过其它方式加以避免。
写自动测试程序会花你一些时间,但这个投资能带来最大的回报:减少后面调试时的浪费,提高代码的质量,更重要的是你可以安稳的睡个觉了。
本节示例请到这里下载。