无标题

[易木]

记一次基于 Robotium 改造的测试实践


1、前言

去年年终复盘,测试这边留了两个 Action:一是自动化工具推广,提高开发可操作性;二是 App 自动化稳定性及推广。如何提高可操作性?如何推广?由此便萌生了要做一个专门的 App。
今年初,我们上线了买家端入口,产品采用大量 H5 开发,随着产品迭代的加快以及开发测试比的增加,老的框架已经无法满足新的挑战:

无论如何,对于本篇读者我都默认你已经会了一点 Android 测试开发的基础。本篇不谈论做 UI 自动化的投入产出比,而是对 Android 测试技术的研究和一些想法,希望能给到测试开发们工作上的一些帮助。

2、我们的需求

其中如何能让开发方便使用是本次改造的核心需求。按我的理解,越是简单的操作方式越是方便,所以需求又具体到:

所以,最终的界面成了这样:
1 个页面 + 1 个按钮。空白的部分是给结果打印留的位置(虽然有点丑,但简单)。
整体流程如下:

3、实现

先说说工具大致的架构:

解释:

下面是主要实现过程。

3.1 框架选择

有了需求就拿需求去套,看目前哪个开源框架满足。比较了下业界比较流行的框架,最后还是选择了 Robotium。首先它是基于 Android 官方 Instrumentation 测试框架上做的封装,支持所有的 Api 版本,其次通过 Js 注入的方式,对 H5 测试更好的支持,并且可以使用 Js 进行功能扩展,而且能够很方便的进行二次开发,在 BAT 自主研发的 Android 测试框架中很多是基于它。
顺便提下为什么不是 UIAutomator?Espresso?答:因为不支持 WebView 哦。

3.2 框架改造

本次主要基于 Robotium 对 Webview 相关的测试能力进行改造,加了一些自定义的 JS 方法,用来满足 WebCache 组件的测试需求。

3.2.1 原理

先说说 Robotium 是如何来操控 Webview 的:如上图,主要涉及到的类有两个 WebUtils 和 RobotiumWebClient。 简单来讲,过程是这样的:在执行某个方法,如 getWebElements() 时,Robotium 先将当前 webview 的 WebChromeClient 替换为自己的 RobotiumWebClient,在 RobotiumWebClient 里面重写了 onJsPrompt(),当 Js 里面有调用到 prompt() 的地方,都会走到这个方法里面,收到内容后再封装成 WebElement。
替换后,getJavaScriptAsString() 再拿到 RobotiumWeb.js 的内容通过 webview.loadUrl("javascript:") 的方式注入到当前页面中。
所以,从这里能看出来,主要实现的方法都在 RobotiumWeb.js 这个文件里面,我们的目的也是往里面增加方法。

3.2.2 动手

根据需求,本次我们要完成 WebCache 组件的测试。测试用例需要验证每个 H5 页面中的静态资源部分可用性,包括 js、css 和图片,另外还需要对组件性能进行评估,最后,还有机型兼容。
如果是手工测试,需要将我们产品下面所有页面都点一遍,看图片有没有正常加载,操作是否顺利,完了后还要记录页面加载时间、资源个数等数据,然而肯定不是测一遍就完事,肯定是要测多遍取平均值。因此我们要自动化,要改造 RobotiumWeb.js。
与前端同学沟通过,判断当前页面图片是否加载,可以从以下两个方面:

因此,我们改动的内容如下:

function allDirtyImgs(){  
    var imgs = document.querySelectorAll('img');
    for (var key in imgs){
        try{
            if(typeof(imgs[key]) == "number"){// 这个情况过滤
                return
            }
            promptDirtyImg(imgs[key]);          
        }catch(ignored){}
    }
    finished();
}
123456789101112
// promptDirtyImg部分 -->
    var w = element.naturalWidth;
    var h = element.naturalHeight;
    var src = element.getAttribute('src');
    var da = element.getAttribute('data-src');
    if(da ==null && (w == 0 || h == 0) ){
        prompt('');
    }else if(da != null && url !=null && url != da) { 
        prompt('');
    }
12345678910

然后,在 WebUtils 类里面封装一个 getAllDirtyImgs() 的方法暴露给 Solo,就能直接调用 Solo 了。

boolean javaScriptWasExecuted = executeJavaScriptFunction("allDirtyImgs();");  
1

获取性能数据需要开发同学配合写到 SD 卡中,再常规测试结束后,在通过访问 Sd 卡来拿到数据。最后清理当前数据,这样保证每次测试都是最新。

3.2.3 免不了的失败重试

对于 UI 测试,总会有不稳定的时候,所以此时失败了重新再跑一遍少不了。 我们在测试基类里面同样加入了重试机制:

@Override
protected void runTest() throws Throwable {  
        int retryTimes = 3;
        while(retryTimes > 0)
        {
            try{
                super.runTest();
                break;
            } catch (Throwable e)
            {
                if(retryTimes > 1) {
                    retryTimes--;
                    tearDown();
                    setUp();
                    continue;
                }
                else
                    throw e;  //记得抛出异常,否则case永远不会失败
            }
        }
    }
123456789101112131415161718192021

3.3 脱机

刚开始,也和网上大部分人一样,在 App 里面我是通过命令方式来启动测试:

Runtime.getRuntime().exec("am instrument");  
1

但是很快就发现,在 Android4.2(Api 17)以上由于权限问题导致不可运行。最终解决方案调整为调用在 Context 下面的 startInstrumentation (ComponentName className, String profileFile, Bundle arguments) 方法。比如:

ComponentName componentName = new ComponentName(packageName, InstrumentationTestRunnerClassName);
mContext.startInstrumentation(componentName, null, null);  
123

参数: packageName: 测试 App 的包名。 InstrumentationTestRunnerClassName: TestRunner 的类名。

3.3.1 脱机持续集成

以往,我们的 CI(持续集成)构建是基于虚拟机或者有 USB 连接线通过 ADB 的方式进行,那么现在是脱机了如何做到?答案是:PUSH。
我们在客户端上集成了三方平台的云推送,在 CI 环境(如 Jenkins)上,封装了云推的透传 API 接口,大概的构建命令像这样:

java -jar /Users/youzan/apis/AppPushServer.jar -run true -mail true -address xx@xx.com -channelId xxx  
1

-channelId不传,默认推给所有手机,如果要单点推送,需要知道手机的channelId。 -mail true :运行结果会通过邮件,以HTML报告的形式发送。
这种方式缺陷很明显:不能保证手机 100% 收到消息,受网络和软件环境的影响较大。
目前,我们主要使用这种方式来做 H5 页面的回归测试和线上接口的稳定性监控。
最后补充一下,毕竟还是涉及 UI 的测试,考虑到执行效率和稳定,我们最终选用作为持续集成的用例仅占到全部用例的 10% 左右。

4、集成其他工具

4.1 集成 UiAutomator

用过 Robotium 的同学都应该知道,我们的测试应用和被测应用处在同一进程的不同线程中,所以如果有需求是跳其他进程(如:调摄像头、相册、手机通知栏)进行操作,Robotium 自己是做不到的。好在 google 在 uiautomator2.0 以上为我们提供了这样的能力,并且可以直接使用 getInstance() 拿到 device 实例。

UiDevice device;  
device = UiDevice.getInstance(getInstrumentation());  
12

值得注意的是: 如果通过上面我们使用的 mContext.startInstrumentation() 去启动测试是拿不到device实例的,此时就必须是通过执行"am instrument"命令的方式去启动。

4.2 覆盖率统计

由于我们的开发都是使用 gradle 进行打包,而 gradle 自带了 jacoco 的插件,所以我们在被测应用的 build 中加入

buildTypes {  
        debug{
            testCoverageEnabled = true // 主要是这里
        }
}        
12345

便开启了统计。
为了生成可读性更好的报告,可以再增加task命令来指定源码路径和忽略一定规则的 class,如:

def coverageSourceDirs = [  
    '../app/src/main/java'
]
task jacocoTestReport(type: JacocoReport) {  
    ...
    classDirectories = fileTree(
            dir: './build/intermediates/classes/debug',
            excludes: ['**/R*.class',
                       '**/*$InjectAdapter.class',
                       '**/*$ModuleAdapter.class',
                       '**/*$ViewInjector*.class'
            ])
    sourceDirectories = files(coverageSourceDirs)
    executionData = files("$buildDir/outputs/code-coverage/connected/coverage.ec")
}
123456789101112131415

然后,需要测试应用执行测试结束的时候,在指定路径生成覆盖率文件coverage.ec,主要调用是:

out = new FileOutputStream(CoverageFilePath(),false);  
Object agent = Class.forName("org.jacoco.agent.rt.RT").getMethod("getAgent").invoke(null);
out.write((byte[])agent.getClass().getMethod("getExecutionData", boolean.class).invoke(agent, false));  
1234

最后,将coverage.ec从手机 pull 下来放到被测应用下outputs/code-coverage/connected下面(注意一定要是这个路径),执行gradle jacocoTestReport最终生成 html 报告。

5、为了改进

到此,我们这个测试 App 能满足我们 90% 日常测试的需求,可是,要完全当作持续集成来用,还有一些事情要做:

以上就是我在平常工作中从一些想法到最后实施的过程。虽然实现上可能不是最好的,也还有些功能待完善,但希望可以借此起到抛砖引玉的效果,帮助无线测试开发们提升产品的测试效率和提高覆盖率。
欢迎关注我们的公众号


↙↙↙阅读原文可查看相关链接,并与作者交流