9

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

 3 years ago
source link: https://www.viseator.com/2018/02/09/android_ui_test/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

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

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

    测试框架功能对比

    参考:
  • https://stackoverflow.com/questions/20046021/google-espresso-or-robotium
    ui_test_2.png

    实际测试编写体验

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

      最终框架选择

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

      使用过程中踩的一些大坑

      变量必须使用static

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

      等待引发的问题

      在我们对View进行一个操作以后,框架会自动处理下一步动作触发的时机,比如点击一个Tab后,会自动等待下一个页面出现再执行下面的操作。这个等待判断的原理没有看过源码不能确定,但是实际中遇到比如WebView加载页这样等待时间较长的页面,就会触发下一个操作的执行。
      那么问题就出现了,如果想要进行这样的测试:点击打开一个文档,等待文档打开完毕以后检查标题是否是我们打开的文档,如果在文档没有加载完的时候就执行检查步骤,就会产生Element not found的错误。
  • 强行设置等待时间
    利用SystemClock.sleep()方法强行让测试暂停一段时间,这个方法比较暴力也不优雅,不到万不得已不要使用。
  • 使用Robotium提供的各种wait方法,通过设置退出条件来等待:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    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时条件满足,退出等待,若超时,则方法返回falseassert失败表示doc加载超时。此处的判断方法是等待loading隐藏。
    1
    2
    3
    4
    5
    6
    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对象,所以我们可以直接使用RecyclerViewfindViewHolderForAdapterPosition()方法去拿ViewHolder,但是事情并没有这么简单,我们知道RecyclerView的特点是没有在屏幕上显示的ViewHolder是没有被实例化的,这样拿到的会是null,所以为了拿到所有ViewHolder我们还需要使用
    scrollDownRecyclerView()方法让RecyclerView滚动起来,但是使用这个方法还会有问题,有时候它会失效(怀疑是没有完成滚动就执行了下一条语句),所以还需要在调用这个方法之后设一个延迟(100ms就行),样例代码如下:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    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还要等待几秒种的时间。

    输入字符的问题

    直接输入字符的方法

    1
    2
    3
    private void inputString(String text) {
    InstrumentationRegistry.getInstrumentation().sendStringSync(text);
    }
    会把string拆分成按键序列输入。

    丢失字符的问题

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

    方法调用顺序的问题

    写在AndroidTest文件夹下同一个测试类中的各个方法的调用顺序是未知的,而且没有找到好的办法可以直接在内部决定它们的调用顺序,本来这并不是一个大问题,但是在写测试的过程中出现了一个比较致命的问题:连续打开WebView会导致WebView无法加载。这个问题应该是由于复用了WebView或者loading view的判断出了问题导致的。单独单次地运行单个测试方法并不会出现这个问题,所以考虑转而使用手写测试脚本 的方式来决定方法的调用顺序并且单次地运行单个测试方法。同时脚本还应该支持重新编译测试Apk并且安装到手机,并且可以指定测试的方法与执行顺序。
    最终编写完成的脚本如下:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    #!/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 [ -n "$1" ] && [ "$1" = rebuild ]
    then
    rebuild_install
    else
    echo Not Rebuild
    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文件中:
    1
    2
    3
    4
    checkLogout
    checkNewDoc
    checkCreateDoc
    checkOpenShare
    在使用时可以传入rebuild参数来重新构建并安装,未传入可以直接开始测试:
    1
    2
    ./test.sh rebuild // 重新构建并安装
    ./test.sh //直接测试
    运行结果示例:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    Not Rebuild
    ┌--------------------------------------
    Start checkLogout
    OK (1 test)
    └--------------------------------------
    ┌--------------------------------------
    Start checkNewDoc
    OK (1 test)
    └--------------------------------------
    ┌--------------------------------------
    Start checkCreateDoc
    OK (1 test)
    └--------------------------------------
    ┌--------------------------------------
    Start checkOpenShare
    OK (1 test)
    └--------------------------------------

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK