Android UI 测试 - Espresso

Android UI 测试框架,在真机运行,相比手动测试,相当于把流程自动化了,并且自动监测结果。

这篇文章主要是阅读官方文档的结果,这渣英文,不敢说翻译。若有理解错误,望指正。

有些感觉用不着的就舍弃了没有看,当然整篇通读下来,感觉真的开发过程也不会去写这个测试吧,好像学了点用不着的屠龙术。不比单元测试,依然要编译运行到真机上,没敢用公司项目测,只是建了个最简单的 Demo,就感觉好慢,测试一次好慢。要是真的去写这测试,还得写许多代码,考虑许多过程,然后再编译,我怎么觉得,还不如 Instant Run 加自己手动操作测试来得快呢。

当然 Android 工程创建完就自动引入了这个框架,说明肯定是有作用的,大概是自己程度不够,没察觉它能提高多少效率。

设置

测试环境准备

开发者选项中关掉动画:

  • Window animation scale 窗口动画缩放
  • Transition animation scale 过渡动画缩放
  • Animator duration scale 动画程序时长缩放

Gradle 配置

Module 的 gradle 文件中配置

android {
    ...
    defaultConfig {
        ...
        testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
    }
}

dependencies {
    androidTestImplementation 'com.android.support.test:runner:1.0.2'
    androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2'
}

基本使用

src/androidTest 创建文件。

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

    @Rule
    public ActivityTestRule<MainActivity> mActivityRule =
            new ActivityTestRule(MainActivity.class);

    @Test
    public void listGoesOverTheFold() {
        onView(withText("Hello world!")).check(matches(isDisplayed()));
    }
}
onView(withId(R.id.my_view))        // withId(R.id.my_view) is a ViewMatcher
    .perform(click())               // click() is a ViewAction
    .check(matches(isDisplayed())); // matches(isDisplayed()) is a ViewAssertion
  • ViewMatchers – 当前 View 层级上匹配一个 View
  • ViewActions – 对 View 执行某种行为,如点击
  • ViewAssertions – 检查 View 的状态,类似单元测试中的断言

找到 View

有时候 View 可能没有对应的 R.id,或者虽然有但是不唯一。假设多个 View 共用 R.id.my_viewonView(withId(R.id.my_view)) 会报错,要通过额外内容进行过滤。

onView(allOf(withId(R.id.my_view), withText("Hello!")))
onView(allOf(withId(R.id.my_view), not(withText("Unwanted"))))

ViewMatchers 提供了若干过滤方法,具体参见 https://developer.android.com/reference/android/support/test/espresso/matcher/ViewMatchers

  • 页面上任何可与用户交互的 View 都应该有 text 或 content description,如果通过 withText()withContentDescription() 不能获取到 View,说明代码写的不好,补上 text 或 content description。
  • 用最少的过滤方法寻找 View,过滤方法越多,框架做的事情越重,比如能通过 withId 获取到唯一的 View,就不要再 withText 了。
  • 如果 View 在 AdapterView 里,比如 ListView、GridView、Spinner,onView() 方法可能无效,要用 onData() 替换。

View 上执行操作

// 执行点击
onView(...).perform(click());

// 执行多个操作
onView(...).perform(typeText("Hello"), click());

// 如果在 ScrollView 里,要先滚动使 View 在当前页面显示出来,然后再执行其它动作
// 如果 View 本身就在页面中显示,srollTo 不起作用
onView(...).perform(scrollTo(), click());

可执行的操作参见 https://developer.android.com/reference/android/support/test/espresso/action/ViewActions

检查状态

主要通过 .check(matches()) 方法,matches 里是寻找 View 的那些过滤方法,

// 断言 View 没有显示
onView(withId(R.id.bottom_left)).check(matches(not(isDisplayed())));

// 断言 View 不存在了,比如去了另一个 Activity
onView(withId(R.id.bottom_left)).check(doesNotExist());

Adapter View

比如 ListView、GridView、Spinner,有个 Adapter,它有好多个 Item,要寻找内容是 Americano 字符串的 Item

onData(allOf(is(instanceOf(String.class)), is("Americano")));

检查某个数据 Item 没有被加到一个 AdapterView 里,就是说还没有加载到它,Adapter 还没持有这个数据。先自定义一个 Matcher 类

private static Matcher<View> withAdaptedData(final Matcher<Object> dataMatcher) {
    return new TypeSafeMatcher<View>() {

        // 描述自己
        @Override
        public void describeTo(Description description) {
            description.appendText("with class name: ");
            dataMatcher.describeTo(description);
        }

        @Override
        public boolean matchesSafely(View view) {
            if (!(view instanceof AdapterView)) {
                return false;
            }

            @SuppressWarnings("rawtypes")
            Adapter adapter = ((AdapterView) view).getAdapter();
            // 遍历当前 Adapter 持有的数据
            for (int i = 0; i < adapter.getCount(); i++) {
                // 和参数传进来 datamatcher 进行匹配
                if (dataMatcher.matches(adapter.getItem(i))) {
                    return true;
                }
            }

            return false;
        }
    };
}

然后检查

@SuppressWarnings("unchecked")
public void testDataItemNotInAdapter(){
    onView(withId(R.id.list)) // 获取这个列表
        .check(matches(not(withAdaptedData(withItemContent("item: 168")))));
    }
}

list-showing-all-rows.png

假设 ListView,每个 Item 都是一个 Map,如 {"STR" : "item: 0", "LEN": 7},找到内容为 "item: 50" 的并点击

onData(allOf(is(instanceOf(Map.class)), hasEntry(equalTo("STR"), is("item: 50"))).perform(click());

框架会自动滚动以显示 Item 并点击。

为了寻找 "item: 50" 的 Item,先寻找 AdapterView 用 Map 类型数据填充的,然后再寻找内容,可以定义出一个 Matcher,

return new BoundedMatcher<Object, Map>(Map.class) {
    @Override
    public boolean matchesSafely(Map map) {
        return hasEntry(equalTo("STR"), itemTextMatcher).matches(map);
    }

    // 描述自己这个 Matcher
    @Override
    public void describeTo(Description description) {
        description.appendText("with item content: ");
        itemTextMatcher.describeTo(description);
    }
};

定义了 BoundedMatcher 作为 Matcher,便可以使用 withItemContent(equalTo("foo")) 方法,为了方便可以将这个方法再封装

public static Matcher<Object> withItemContent(String expectedText) {
    checkNotNull(expectedText);
    return withItemContent(equalTo(expectedText));
}

然后再使用

onData(withItemContent("item: 50")).perform(click());

找到列表的 Item,还想找到 Item 里面的 View,使用 onChildView(),比如

onData(withItemContent("item: 60")) // 找到 Item
    .onChildView(withId(R.id.item_size)) // 找到 ItemView 里 id 为 R.id.item_size 的 View
    .perform(click());

Recycler View

RecyclerView 的机制和过去的 ListView 这种不同,所以 onData() 方法也不适用了。它需要使用 RecyclerViewActions,有如下可执行的动作:

  • scrollTo() - Scrolls to the matched View.
  • scrollToHolder() - Scrolls to the matched View Holder.
  • scrollToPosition() - Scrolls to a specific position.
  • actionOnHolderItem() - Performs a View Action on a matched View Holder.
  • actionOnItem() - Performs a View Action on a matched View.
  • actionOnItemAtPosition() - Performs a ViewAction on a view at a specific position.

Demo地址

最后来张图

espresso-cheatsheet.png

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏developerHaoz 的安卓之旅

Android 录音功能直接拿去用

这个类可以说是这个包的核心了,如果理解了这个 Service,录音这一块基本就没什么问题了。

93330
来自专栏刘望舒

感受LiveData与ViewModel结合之美

虽说这篇是说LiveData与ViewModel,但是或多或少都有涉及另外一个组件:Lifecycles 。它们连同Room都是在17年谷歌IO大会推出的,当时...

16120
来自专栏分享达人秀

Intent 属性详解(上)

Android应用将会根据Intent来启动指定组件,至于到底启动哪个组件,则取决于Intent的各属性。本期将详细介绍Intent的各属性值,以及 A...

269100
来自专栏向治洪

开源项目Universal Image Loader for Android

In the previous article, we’ve initialized the ImageLoader with configuration; ...

19650
来自专栏潇涧技术专栏

Android Dependency Injection Libraries

本文总结并对比了三种Android依赖注入库:Butter Knife、RoboGuice、Android Annotations的使用

10010
来自专栏Android先生

Context都没弄明白,还怎么做Android开发?

作为Android开发者,不知道你有没有思考过这个问题,Activity可以new吗?Android的应用程序开发采用JAVA语言,Activity本质上也是一...

11320
来自专栏iOSDevLog

Android 闪屏 Splash

30860
来自专栏jianhuicode

如何使用MVP+Dagger2+RxJava+Retrofit开发(1)

概述 在2016年5,6月份开始在知乎上看到开发方法,那时候记得是看mvc,mvp,mvvm这三种开发模式区别,后面进一步了解到google在github上开源...

55080
来自专栏Android开发指南

13.json解析

50290
来自专栏移动开发

Glide 如何实现正确加载图片而没有错位

当我们在常见的列表界面中(如 recycleview 实现的列表),使用上面的代码,在我们快速滑动中,glide 是如何实现正确加载图片,而没有导致图片内容的错...

41930

扫码关注云+社区

领取腾讯云代金券