Sikuli: GUI功能测试

与Sikuli的相遇,缘起于几个月前。当时为了测试产品界面的功能,迫切需要一个GUI的自动化工具;由于产品界面的技术组成较为复杂(既有Web的,又有Java的),因此既无法使用Selenium这样的Web自动化测试工具,也无法使用Abbot这样的Java GUI测试软件。在经过一番调查之后,最终选择了Sikuli — 在Stack Overflow上,似乎Sikuli的口碑很不错。

对于一个GUI自动化工具而言,其原理不外乎以下四种:

  1. 与操作系统的GUI API绑定,直接调用操作系统来模拟图形用户界面的操作。这种方式的GUI自动化,我只听同事介绍过而没有实践。
  2. 与产品的源代码进行绑定,测试软件进行代码层面的识别及操作。这种方式的好处是显而易见的:可以达到非常精确的模拟;但是局限性也一样明显:界面的技术组成必须比较单纯,无法测试多种技术搭配出来的界面(比如在Web上内嵌了Flash或者内嵌了Applet的情况);同时,编写测试代码的时候,必须对UI的源代码有一定的了解,编写出来的测试脚本更倾向于面向机器,而不是面向人。
  3. 对屏幕操作进行录入,然后在测试过程中回放。这种方法的好处在于,编写测试脚本非常简单,手工操作一次之后,大部分工作即告完成(对某些测试案例,可能需要微调一下录入的测试脚本)。可惜的是,该方法有一个比较大的问题:录入的屏幕操作信息往往过于绝对 — 比如:“在屏幕坐标值(200,300)上点击鼠标左键”;这导致了重复跑测试脚本的时候,往往会因为一些窗口大小微调或者操作系统弹出对话框等因素而测试失败。
  4. 对需要操作的GUI组件进行识别,在识别成功的情况下,进行操作模拟。这是最贴近”人”的方法,因为我们在做GUI手工测试的时候,就是用肉眼来辨识屏幕上的图形元素,然后采取相应的操作。就GUI自动化原理来说,我认为这种方法是最好的。当然,这种方法也有其局限之处:图像的识别能力必须很高,如果在图像元素的识别上假阴性/假阳性过高(识别不出来/误识别),那么相关工具的实用性就要大打折扣了。

使用Sikuli进行GUI测试,采取的是第四种方法。需要说明的是,Sikuli并不是一个为了GUI测试而开发的软件。Sikuli的作者张琮翔是一在MIT进行人机交互研究的博士,在相关论文发表于UIST(ACM在人机交互方向上的顶级会议之一)之后,为了把成果推广应用,开发了Sikuli这一工具。

对于任何一款GUI自动化测试工具而言,测试中断是一个令人头疼的问题。与CLI的自动化测试不同,GUI在自动化过程中更容易受到干扰,有时屏幕上一个小小的不同就可以导致前功尽弃。下面是我碰到过的几个容易导致测试中断的情况:

  • 对于通过UI源代码绑定来做操作模拟的测试工具(比如Abbot),有时UI源代码有了一些小修改,导致虽然界面在肉眼看来并无改变,但测试却已经没法跑下去了。
  • 对于通过录屏来实现操作模拟的测试工具,因为界面上UI组件的大小/位置的不同(比如:弹出对话框的位置变了),导致测试中断。
  • 对于通过图像识别来进行操作模拟的测试工具(比如Sikuli),有时肉眼看到需要操作的UI组件就在那里,而工具却没有识别,很遗憾的报错说“要操作的组件没找到”。
  • 意外出现的界面行为。比如,需要操作的UI组件因为业务逻辑的关系,而变得不可用(或者根本就没出现在屏幕上);又比如,操作系统或者后台运行的其它程序弹出了一些对话框导致测试中途出错。

很明显,Sikuli不会有前两个问题,那么在面对后两个问题的时候,我们可以怎么办呢?如何降低图像识别的错误率,又如何写出一个容错性较好的脚本呢?如何来处理GUI自动化测试中Test Case之间的依赖性呢?

Sikuli提供了单元测试的功能(集成JUnit),但是我简单尝试了一下,发现并不好用,主要是因为自己的GUI自动化测试中Test Case之间的依赖性比较复杂,而一旦某个Test Case出错,对其出错后的处理也需要比较灵活。因此,最终我采取了下面的这种写法来处理容错以及依赖:

import traceback  
import sys  
import STcommon  
import STmodule1  
import STmodule2

def test(testCase):  
    try:
        testCase()
        STcommon.log("PASSED! " + str(testCase))
        return True
    except FindFailed:
        print "FindFailed Exception:"
        traceback.print_exc(file=sys.stdout)
        STcommon.log("FindFailed! " + str(testCase))
        STcommon.reset()
        return False

if (test(STmodule1.test1)):  
    test(STmodule1.test2)
    test(STmodule1.test3)

test(STmodule2.testA)  
test(STmodule2.testB)  

其中,STcommon,、STmodule1与STmodule2都是自定义的Sikuli模块(STcommon包含一些公用的函数,而STmodule1与STmodule2则包含具体的Test Case)。我认为这种写法可以很好的解决容错以及测试案例之间的依赖问题。

当然,这种写法只能说是一种经验性的编码范式。目前来看,效果还不错;但也有待更多的实践来优化。