Android UI测试实战

UI测试对于我们是一个熟悉又陌生的字眼,我们能从很多渠道听到这个字眼,都在说测试的自动化是多么多么的重要,但是无论我们是查看了官方文档,还是看了一圈博客回来,都很难对我们的现有的项目去展开,有时为了测试甚至要更改原有框架,许多的不便让我们望而却步,但是如果你走出第一步,就会发现其实并没有想象中的那么难,等你走完,更会发觉这个东西是如此的重要能让我们的开发效率得到不小的提升,对于一个团队的标准化更是有质的提升。

1. 搭建UI测试框架

在Android的自动化UI测试中「Espresso」能完成我们大部分的需求,我们的测试流程也是围绕Espresso展开的。

由于Google现在在逐步将各种支持库都转移到AndroidX中,我们的讲解也会涉及到两个库的不同实现,同时我们以AndroidX为主来讲解,只是引入方式稍有不同,使用起来大同小异。

首先,引入Espresso库

//  可以放在根.gradle里控制版本,这里为了紧凑写在了一起。
    testImplementation 'junit:junit:4.12'
    androidTestImplementation 'androidx.test:core:1.2.0-alpha03'
    androidTestImplementation 'androidx.test.ext:truth:1.2.0-alpha03'
    androidTestImplementation 'androidx.test.ext:junit:1.1.1-alpha03'
    androidTestImplementation 'androidx.test:runner:1.2.0-alpha03'
    androidTestImplementation 'androidx.test:rules:1.2.0-alpha03'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0-alpha03' 
    androidTestImplementation 'androidx.test.espresso:espresso-intents:3.2.0-alpha03'
    androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.2.0-alpha03'

//    testImplementation 'junit:junit:4.12'
//    androidTestImplementation 'com.android.support.test:runner:1.0.2'
//    androidTestImplementation 'com.android.support.test:rules:1.0.2'
//    androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2'
//    androidTestImplementation 'com.android.support.test.espresso:espresso-intents:3.0.2'
//    androidTestImplementation 'com.android.support.test.espresso:espresso-contrib:3.0.2'//recyclerView

同时在项目build.gradle中加入

defaultConfig {
        //...
        //如果使用androidx
        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
        //如果使用support
        //testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
    }

我们引入这些lib之后,就可以开始编写测试用例了,但是你可能会遇到一些问题。

Test running failed: Instrumentation run failed due to 'Process crashed.'

如果遇到这个错误,一般是我们的引入的包不一致或者testInstrumentationRunner与引入包不一致。注意:如果我们的项目中还在使用Support包中的内容,比如RecyclerView,必须要使用Support包,因为在测试包中引用的都是相同路径下的控件,比如androidx.test.espresso:espresso-contrib包中引用的就是androidx下的RecyclerView,如果混用是会报错的。
其他解决方案

2. 测试入门

大家可以先想象一下,我们在测试一个UI的时候流程是什么样的。1.打开界面 2.找到相应的控件 3.点击控件 4.查看效果

倘若让你设计一个UI测试框架你会怎么设计呢?想必我们的思路也是相似的,只不过是使用代码控制,我们先来编写一个简单的例子来感受一下吧。


basic

我们输入文字,点击change text按钮,上面的文字就可以变为输入的文字。
依照处理流程,我们写出测试代码。

@RunWith(AndroidJUnit4.class)
@LargeTest
public class BasicActivityTest {
    public static final String STRING_TO_BE_TYPED = "Hello";
    @Rule
    public ActivityScenarioRule<BasicTestActivity> activityScenarioRule
            = new ActivityScenarioRule<>(BasicTestActivity.class);

    @Test
    public void changeText_sameActivity() {
        onView(withId(R.id.editTextUserInput)).perform(click());
        // 输入文字,注意:这里输入法通常会有问题,建议使用Google输入法或者直接使用模拟器,或者把系统设置为英文
        onView(withId(R.id.editTextUserInput))
                .perform(typeText(STRING_TO_BE_TYPED), closeSoftKeyboard());
        // 点击按钮
        onView(withId(R.id.changeTextBt)).perform(click()); 
        // 检查是否与输入相符
        onView(withId(R.id.textToBeChanged)).check(matches(withText(STRING_TO_BE_TYPED)));
    }
}

我们已经完成了一个用例编写,运行之后它会自动键入文字,并且点击按钮,最后检查是否与输入相同。下面我们具体介绍一下这些代码的含义,让你自己也能写出同样的测试用例。

解析
  1. 首先我们先看下最开始的两个注解,当一个类使用@RunWith注解类时,JUnit将调用它引用的类来运行该类中的测试而不是内置于JUnit中的运行器,这里引用了AndroidJUnit4的运行器,是一个用于Android测试的跨环境JUnit4运行器,这里我们不深究,有兴趣的可以研究一下,我们可以理解为是一种Android的测试环境。
    @LargeTest 其实是一种规定,相对的还有@SmallTest@MediumTest
test类型 运行时长 使用限制 测试场景
SmallTest <200ms 网络、文件、数据不能使用 运行在一个孤立的环境,用于调用非常频繁的场景,多是单元测试
MediumTest <1000ms 可以通过定义的接口访问文件、数据库、ContentProviders,但是不能访问网络,长时间的阻塞操作应该直接使用Mock 多用于单个组件的测试
LargeTest >1000ms 基本无限制 用于测试应用组件,UI测试
  1. @Rule 这个规则提供单个活动的功能测试,通过这个注解可以自动调起目标Activity。
    @Test 就是标记的要测试的流程。
  2. 测试中我们很明显能看到几个关键方法,而这些方法,都遵循着下图的的三个步骤。


    测试流程
  • onView() 就是找到要测试的View,里面既可以通过标准的id(withId),也可以通过变更的text(withText)
  • perform() 就是所有操作的总称,可以是click、longClick、scrollTo等等,我们一般需要的都能找到。
  • check() 就是所有检验方法的总称,通常借助Matcher来进行结果的校验,比如字符显示正确与否、View是否显示、View是否处于正确的位置等等。

怎么样?是不是很简单的样子,依靠这个我们可以把应用中的点逐个击破,在开发和重构中事半功倍。

进阶

通常在我们学习完TextView、Button等等普通View之后,我们就要学习一些复杂的View,这个View往往就是RecyclerView。在RecyclerView中我们不能以普通的思路用id去寻找某个View,但是作为应用中非常重要的一部分,Google已经为我们想好了解决方案,就是espresso-contrib包,里面封装了大部分RecyclerView的操作,我们可以以平常使用的思维来使用这些API。

@RunWith(AndroidJUnit4.class)
@LargeTest
public class RecyclerViewTest {

    //如果使用Support包,需要替换为ActivityTestRule
    @Rule
    public ActivityScenarioRule<MainActivity> activityScenarioRule
            = new ActivityScenarioRule<>(MainActivity.class);

    private void openActivity() {
        onView(withId(R.id.button_test_recyclerView)).perform(click());
    }

    @Test
    public void clickPhone(){
        openActivity();
        onView(withId(R.id.recycler_view_test)).perform(
                RecyclerViewActions.actionOnItemAtPosition(0, click()));
    }
}

使用RecyclerViewActions中的方法,我们可以很容易的操作RecyclerView,比如可以通过Scroll来滑动到某个位置,通过actionOnItemAtPosition来获取Item Layout进而进行点击操作。
但是你会发现一个问题,如果点击的事件不是这个Item而是Item中的一个View呢?
如果查看源码的话你会发现所有的perform方法中都是一个ViewAction,而这个ViewAction是一个接口,我们可以查看ViewAction的众多实现,其实都是做了特别简单的操作,由此我们可以自定一个ViewAction来实现我们的需求。

public class RecyclerViewActionItem implements ViewAction {

    private int mViewId;

    public RecyclerViewActionItem(int mViewId) {
        this.mViewId = mViewId;
    }

    @Override
    public Matcher<View> getConstraints() {
        return null;
    }

    @Override
    public String getDescription() {
        return "Click on a child view with specified id.";
    }

    @Override
    public void perform(UiController uiController, View view) {
        view.findViewById(mViewId).performClick();
    }

    public static ViewAction clickRecyclerChildWithId(@IdRes int viewId) {
        return new RecyclerViewActionItem(viewId);
    }
}

在我们的实现中仅仅是在perform方法中,使用findViewById()找到View,继而调用View的performClick(),在实际的操作中,只要我们知道了具体的View类型,其实还可以有更多的操作。
RecyclerViewTest

延时操作

在一个普通的App中,网络的请求可谓是不可或缺的,而这也为我们的UI自动化测试设下了一个阻拦,我们的测试都是代码一行一行的执行,如果没有相应的页面显示,就会报错失败,(对了,通常在UI测试中,我们会在开发者选项中,把动画都关闭,避免动画赶不上变化)这也是我们在写测试用例时比较麻烦的一个地方。

Google给出了几个方案

  1. 调用Thread.sleep()
  2. 通过一个无限循环去获取数据
  3. 使用CountDownLatch
    但是这几个方案都有问题,就是不稳定,效率也不高,如果网络特别差的话,有可能直接报错,于是Google给出了espresso-idling-resource的解决方案。

在gradle中加入(注意不是androidTestImplementation,而是implementation)

implementation 'androidx.test.espresso:espresso-idling-resource:3.2.0-alpha03'

自定义一个IdlingResource

public class SimpleIdlingResource implements IdlingResource {

    @Nullable
    private volatile ResourceCallback mCallback;

    // Idleness is controlled with this boolean.
    private AtomicBoolean mIsIdleNow = new AtomicBoolean(true);
    //用来标识 IdlingResource 名称
    @Override
    public String getName() {
        return this.getClass().getName();
    }
    //当前 IdlingResource 是否空闲
    @Override
    public boolean isIdleNow() {
        return mIsIdleNow.get();
    }
    //注册一个空闲状态变换的ResourceCallback回调
    @Override
    public void registerIdleTransitionCallback(ResourceCallback callback) {
        mCallback = callback;
    }


    /**
     * 设置idle的状态,如果成功请求到了数据将isIdleNow设为true
     */
    public void setIdleState(boolean isIdleNow) {
        mIsIdleNow.set(isIdleNow);
        if (isIdleNow && mCallback != null) {
            mCallback.onTransitionToIdle();
        }
    }
}

需要改变一些原有的代码

handler.postDelayed(new Runnable() {
    @Override
    public void run() {
        if (callback != null) {
            callback.onDone(message);
            if (idlingResource != null) {
                idlingResource.setIdleState(true);
            }
        }
    }
}, DELAY_MILLIS);

在用例中也需要特殊的处理

@RunWith(AndroidJUnit4.class)
@LargeTest
public class ChangeTextBehaviorTest {

    private static final String STRING_TO_BE_TYPED = "Espresso";

    private IdlingResource mIdlingResource;

    @Rule
    public ActivityScenarioRule<IdlingTestActivity> mActivityRule = new ActivityScenarioRule<>(
            IdlingTestActivity.class);
    /**
     * 注册mIdlingResource
     */
    @Before
    public void registerIdlingResource() {
        mActivityRule.getScenario().onActivity(new ActivityScenario.ActivityAction<IdlingTestActivity>() {
            @Override
            public void perform(IdlingTestActivity activity) {
                mIdlingResource = activity.getIdlingResource();
                // To prove that the test fails, omit this call:
                IdlingRegistry.getInstance().register(mIdlingResource);
            }
        });
    }

    @Test
    public void changeText_sameActivity() {
        // Type text and then press the button.
        onView(withId(R.id.editTextUserInput))
                .perform(typeText(STRING_TO_BE_TYPED), closeSoftKeyboard());
        onView(withId(R.id.changeTextBt)).perform(click());
        // Check that the text was changed.
        onView(withId(R.id.textToBeChanged)).check(matches(withText(STRING_TO_BE_TYPED)));
    }

    @After
    public void unregisterIdlingResource() {
        if (mIdlingResource != null) {
            IdlingRegistry.getInstance().unregister(mIdlingResource);
        }
    }
}

具体例子可以查看官方
我们可以看出这个测试其实是有侵入性的,但是这也是加入一些必要功能要做出的取舍,Google给出的方案其实是在IdlingResource的基础上改造架构,但这付出的努力恐怕还是要远远多于Thread.sleep()。
如果有更好的方案,希望大家提出来一起讨论。

总结

无论是单元测试还是UI测试,在我们没有使用的时候似乎觉得可有可无,总是听到很多大牛在说它的重要性,但是却无法感受到它的重要程度,但是当你开始使用之后就会发现,如果一个项目拥有了自动化的测试,你的代码将得到一个提升,因为它迫使你去做封装和改造,让项目去更优雅的测试;添加新功能之后也可以免除可能会对旧功能的后顾之忧;提升了你和测试同学的效率;最后,你拥有了重构的底气,因为,所有的功能都是一样的,但是代码变的更健壮了,UI变的更流畅了,你的技术也变的更好了。
所以,开始你的『测试之旅』吧。

官方文档
官方范例
文中例子
androidx和Support包的对应关系

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 157,198评论 4 359
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,663评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 106,985评论 0 237
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,673评论 0 202
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 51,994评论 3 285
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,399评论 1 211
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,717评论 2 310
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,407评论 0 194
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,112评论 1 239
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,371评论 2 241
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,891评论 1 256
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,255评论 2 250
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,881评论 3 233
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,010评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,764评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,412评论 2 269
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,299评论 2 260

推荐阅读更多精彩内容