Android UI自动化测试技术选择与踩坑

ClareGregary 发布于3月前 阅读58次
0 条评论

官方文档关于测试一节 中,介绍了测试金字塔这一概念:

Android UI自动化测试技术选择与踩坑

即我们应该包括三个层次的测试:小型、中型、与大型:

  • 小型测试是单元测试,即可以独立运行在每个模块上的测试。它们通常模拟了每个主要的组件并且应该快速地运行。
  • 中型测试是介于大型与小型测试之间的综合测试,它们集成了数个组件并且运行在模拟器或实机上。
  • 大型测试是以模拟UI工作流方式进行的综合UI测试,它们保证了关键的终端用户的使用可以符合我们的预期。
    虽然小型测试迅速并且专注,可以让我们很快地发现错误,但它们同样是低仿真且自成一体的,这使得我们很难保证通过了所有的单元测试就可以成功地让App运行。而大型测试的优缺点恰恰与上述相反。
    由于每个层次的测试的角色各不相同,我们应该进行所有这三个层次的测试,尽管各个层次使用比例需要根据App的使用特点,通常建议三个测试的比例为1:2:7。
    UI自动化测试即属于上面说的大型测试。

    测试框架功能对比

    概览

    参考:
  • https://stackoverflow.com/questions/20046021/google-espresso-or-robotiumAndroid UI自动化测试技术选择与踩坑

    实际测试编写体验

    实际的编写中,主要的步骤可以总结为三步:
  1. 如何定位想要操作的 View
  2. 如何施加想要进行操作
  3. 如何判断App的行为符合我们预期
    三种框架都为我们提供了一系列方法,但细节与效果略有不同:
  • Espresso
    • 白盒测试,体现在可以直接拿到显示中的 View 实例,拿到 WebView DOM 树中的 Element
    • 一般场景下,区分度较为明显的 View (有唯一的 id tag )等,可以通过多种途径定位,较为便捷
    • 面对特殊场景:如 TabLayout 中的 Tab 时,由于它们拥有相同的类型与 id ,难以定位 view
    • 出现多窗口情况(如 dialog ),可以正常处理
    • 不能触发按返回键、改变屏幕方向等操作
  • UI Automator
    • 黑盒测试,体现在无法拿到具体的 View ,只能拿到基类( LinearLayout 等),无法看到 WebView 的 DOM 树
    • 一般场景下,定位 View 没有差别
    • 面对特殊场景,可以通过找出所有符合条件的 View 再按索引找到想要的 View
    • 出现多窗口情况:处理出现异常
  • Robotium
    • 集合了上述框架的优点,既可以拿到显示中的 View 实例与 WebView 的 DOM 树
    • 对上述框架的接口进行了统一,调用比较方便

      最终框架选择

      通过上述比较,可以看到Robotium在满足我们要求的同时统一了接口,故选择Robotium作为使用的框架。

      使用过程中踩的一些大坑

      变量必须使用static

      在 AndroidTest 类中,期望使用一个 boolean 标志来判断是否已经登陆过(避免重复检查登陆状态),发现在 login() 方法中置标志为true后进入下一个测试时这个值仍为 false ,推测运行测试方法时各个方法的运行是独立的,故不使用静态变量则无法保存状态。

      等待引发的问题

      等待?

      在我们对 View 进行一个操作以后,框架会自动处理下一步动作触发的时机,比如点击一个 Tab 后,会自动等待下一个页面出现再执行下面的操作。这个等待判断的原理没有看过源码不能确定,但是实际中遇到比如 WebView 加载页这样等待时间较长的页面,就会触发下一个操作的执行。
      那么问题就出现了,如果想要进行这样的测试:点击打开一个文档,等待文档打开完毕以后检查标题是否是我们打开的文档,如果在文档没有加载完的时候就执行检查步骤,就会产生 Element not found 的错误。

      解决方法

  • 强行设置等待时间
    利用 SystemClock.sleep() 方法强行让测试暂停一段时间,这个方法比较暴力也不优雅,不到万不得已不要使用。
  • 使用 Robotium 提供的各种 wait 方法,通过设置退出条件来等待:
    private void waitForWebView(){
        assertTrue(mSolo.waitForCondition(new Condition() {
            @Override
            public boolean isSatisfied(){
                View loading = null;
                try {
                    loading = mSolo.getView(R.id.loading);
                } catch (AssertionFailedError e) {
                    Log.e(TAG, e.getMessage());
                }
                return null == loading || View.GONE == loading.getVisibility();
            }
        }, DOC_LOAD_TIMEOUT));
    }

在解决上面的问题的时候就使用了上面的代码来等待 WebView 文档加载完毕,返回 true 时条件满足,退出等待,若超时,则方法返回 false , assert 失败表示 doc 加载超时。此处的判断方法是等待 loading 隐藏。

private void waitForActivity(Class<? extends Activity> activity){
    if (mSolo.getCurrentActivity().getClass() == activity) {
        return;
    }
    assertTrue(mSolo.waitForActivity(activity));
}

上述代码是为了等待 activity 启动,可以用于判断新的 activity 是否正常启动。

跨进程引发的问题

在应用中,打开的文档运行在一个新的进程中,在使用 Espresso 的时候就遇到了问题:无法拿到新进程中 WebView 的信息,原因没有仔细分析,但可以确定是跨进程的问题。在 Robotium 中这个问题同样存在。

不但如此,多进程还会导致当前 activity 的判断出错,本应判断在 DocActivity 中,但实际上得到的是在原进程的 activity 中。

在多种方法尝试无果后,只能暂时修改源码,将 doc 放在同进程打开。

View获取的问题

获取想要的 View 是编写用例最主要的难点所在,在获取 View 的时候也遇到了不少的坑:

重复出现的View

实际上通过 getCurrentViews() 获取到的View对象包括所有 activity 的所有 View ,比如主页面有3个 tab ,每个 tab 中有一个 RecyclerView , 这三个RecyclerView都是可以被获取到的(而不是想象中的只获取到当前可见的这个),甚至在打开新的 activity 后,后台的 activity 中的 RecyclerView 还是可以被获取到的。但是使用 getView() 方法获取的范围是当前 activity 。

这意味着什么呢?如果这些 RecyclerView 有相同的 id ,使用 getView(int id) 方法获取到的只是第一个,即使切到了第二个 tab ,获取到的还是第一个 tab 中的 RecyclerView 。

面对这个情况我们可以用三种方法:

  • 如果它们 id 不同,使用 getView(int id) 就可以拿到特定的。如果 id 相同,可以传入第二个 index 参数来获取同 id 的第n个实例
  • 使用 getView(类名, int index) 拿到该类所有实例中的第n个,因为各个 RecyclerView 加载的顺序是相对固定的,所以每次运行拿到的 RecyclerView 是同一个。拿上面的例子来说,如果要拿到第二个 tab 中的 RecyclerView ,要获取的应该是第2个。
  • 先获取它的任意一个 ParentView ,然后通过 getCurrentViews(类名,ViewGroup) 方法拿到 List ,如果 ViewGroup 是唯一的,这个 List 中应该只会有我们想要的那个,也可以用 ViewGroup 来缩小我们搜索的范围。

    View获取的技巧

    总结一下
  • 定位 View 最为方便的就是使用 getView(int id/类名) 这个方式,如果 id /类名的实例唯一,就可以直接拿到。
  • 如果同 id /类名有很多个 view 存在,要使用 getView(int id/类名, int index) ,拿到第n个 view 实例。
  • 如果该 view 所处的 任一个 ViewGroup 很好获取(有唯一id/类名) ,可以通过 getCurrentViews(类名, ViewGroup) 这个方式迅速缩小范围,拿到想要的 View 。

    RecyclerView中获取ViewHolder

    Robotium 允许我们直接拿到 View 对象,所以我们可以直接使用 RecyclerView 的 findViewHolderForAdapterPosition() 方法去拿 ViewHolder ,但是事情并没有这么简单,我们知道 RecyclerView 的特点是没有在屏幕上显示的 ViewHolder 是没有被实例化的,这样拿到的会是 null ,所以为了拿到所有 ViewHolder 我们还需要使用
    scrollDownRecyclerView() 方法让 RecyclerView 滚动起来,但是使用这个方法还会有问题,有时候它会失效(怀疑是没有完成滚动就执行了下一条语句), 所以还需要在调用这个方法之后设一个延迟(100ms就行) ,样例代码如下:
    for (int i = 0; i < listAdapter.getItemCount(); i++) {
        RecyclerView.ViewHolder viewHolder = recyclerView.findViewHolderForAdapterPosition(i);
        if (null == viewHolder) {
            mSolo.scrollDownRecyclerView(0);
            SystemClock.sleep(100);
            viewHolder = recyclerView.findViewHolderForAdapterPosition(i);
        }
        if (testAction.test(listAdapter, viewHolder, i)) {
            break;
        }
    }

WebView中的WebElement获取需要延迟

之前介绍了等待 WebView 加载的方法,但是实际上这个方法返回后通过 getWebElements() 拿到的 WebElements 是空的,实际上想要拿到 WebElement 还要等待几秒种的时间。

输入字符的问题

直接输入字符的方法

private void inputString(String text){
    InstrumentationRegistry.getInstrumentation().sendStringSync(text);
}

会把 string 拆分成按键序列输入。

丢失字符的问题

调用上面的方法输入的字符过长的时候会偶发出现字符丢失的问题,暂时不知道解决方法,只能输入短一点的字符。

方法调用顺序的问题

写在 AndroidTest 文件夹下同一个测试类中的各个方法的调用顺序是未知的,而且没有找到好的办法可以直接在内部决定它们的调用顺序,本来这并不是一个大问题,但是在写测试的过程中出现了一个比较致命的问题:连续打开 WebView 会导致 WebView 无法加载。这个问题应该是由于复用了 WebView 或者 loading view 的判断出了问题导致的。单独单次地运行单个测试方法并不会出现这个问题,所以考虑转而使用手写测试脚本 的方式来决定方法的调用顺序并且单次地运行单个测试方法。同时脚本还应该支持重新编译测试Apk并且安装到手机,并且可以指定测试的方法与执行顺序。

最终编写完成的脚本如下:

#!/bin/bash
function rebuild_install() {
    ./gradlew --build-cache :app:assemblePublishxxxDebug :app:assemblePublishxxxDebugAndroidTest
    adb push app/build/outputs/apk/publishxxx/debug/app-publish-xxx-debug.apk /data/local/tmp/com.xxx.xx.xxxx
    adb shell pm install -t -r "/data/local/tmp/com.xxx.xx.xxxx"
    adb push app/build/outputs/apk/androidTest/publishxxx/debug/app-publish-xxx-debug-androidTest.apk /data/local/tmp/com.xxx.xx.xxxx
    adb shell pm install -t -r "/data/local/tmp/com.xxx.xx.xxxx"
}

cd ..
if test -z "$1"
then
    echo Not Rebuild
else
    if [ "$1"="rebuild" ]
    then
        rebuild_install
    fi
fi

test_funcs=($(awk '{print $0}' ui_test/funtion_names.txt))
for funcs in ${test_funcs[@]}
do
    echo ┌--------------------------------------
    echo Start $funcs
    adb shell am force-stop com.xxx.xx.xxxx
    adb shell am instrument -w -r   -e debug false -e class com.xxx.xx.xxxx.MainInstrumentedTest#$funcs com.xxx.xx.xxxx.test/android.support.test.runner.AndroidJUnitRunner | sed -En -e '/There was/,/FAILURES/p;/OK/p'
    echo └--------------------------------------
done

需要测试的方法按顺序放在funtion_names.txt文件中:

checkLogout
checkNewDoc
checkCreateDoc
checkOpenShare

在使用时可以传入 rebuild 参数来重新构建并安装,未传入可以直接开始测试:

./test.sh rebuild // 重新构建并安装
./test.sh //直接测试

运行结果示例:

Not Rebuild
┌--------------------------------------
Start checkLogout
OK (1 test)
└--------------------------------------
┌--------------------------------------
Start checkNewDoc
OK (1 test)
└--------------------------------------
┌--------------------------------------
Start checkCreateDoc
OK (1 test)
└--------------------------------------
┌--------------------------------------
Start checkOpenShare
OK (1 test)
└--------------------------------------

查看原文: Android UI自动化测试技术选择与踩坑

  • beautifulpanda
  • goldenelephant
  • whitesnake
  • bluepanda
  • lazyelephant
  • bigbutterfly
  • yellowbear
需要 登录 后回复方可回复, 如果你还没有账号你可以 注册 一个帐号。