浅谈测试之Espresso

2,971 阅读10分钟

Espresso的简介

Espresso官网指南

Google推行的测试库,用于编写简洁、漂亮、可靠的Android UI测试。缺点是需要真机或模拟器配合测试,比较慢。

Espresso的集成

1.app的build.gradle下添加依赖

androidTestImplementation 'androidx.test:runner:1.1.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.0'
androidTestImplementation 'androidx.test.espresso:espresso-intents:3.1.0'
androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.1.0'
androidTestImplementation 'androidx.test:runner:1.1.0'
androidTestImplementation 'androidx.test:core:1.1.0'

2.同样的build.gradle文件下的android.defaultConfig里添加一行

testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"

Espresso的使用

1.Espresso示例

1)用Espresso写的测试代码是放置项目自动生成的src/androidTest/java文件夹里的。

2)模板代码

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

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

    @Test
    public void testEspresso() {
        ...
    }
}

注意:

1.ActivityTestRule会立即初始化MainActivity,执行onCreate()、onResume()方法。

2.ActivityTestRule它是运行在@Before之前的。如果你不想立即初始化MainActivity,并且传递一些参数给MainActivity,可以使用ActivityTestRule另一个构造方法:ActivityTestRule( Class<T> activityClass, boolean initialTouchMode, boolean launchActivity)

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

    @Rule
    public ActivityTestRule<MainActivity> activityRule =
            new ActivityTestRule<>(MainActivity.class,true,false);
            
    @Before
    public void setup(){
        Intent intent = new Intent(ApplicationProvider.getApplicationContext(),
                            MainActivity.class);
        intent.putExtra("hello","nsnmn");
        intentsTestRule.launchActivity(intent);
    }
}

2.Context

在测试里,可以通过ApplicationProvider.getApplicationContext()获得Application的Context。

3.API

Espresso的核心类有4个。都是提供一系列静态方法的工具类:

1)Espresso:提供几个静态方法,如onView()或onData(),方便定位到相应的UI控件。还有几个不一定绑定到任何视图的api,比如pressBack()、closeSoftKeyboard()。

2)ViewMatchers:提供的静态方法,比如ViewAssertions.withId()、ViewAssertions.withText(),均会返回一个实现了Matcher<? super View>接口的类实例。你可以将一个或多个此类实例,作为参数传递给onView()方法,以便定位到相应的控件。

Espresso.onView(ViewMatchers.withId(R.id.my_view))
即:
onView(withId(R.id.my_view))

3)ViewActions:提供的静态方法,比如,ViewActions.click()、ViewActions.closeSoftKeyboard(),均会返回一个实现了ViewAction接口的类实例。你可以将一个或者多个此类实例,作为参数传递给ViewInteraction.perform()方法。

//onView方法,会返回ViewInteraction的实例
onView(withId(R.id.my_view)).perform(click(),closeSoftKeyboard())

4)ViewAssertions:提供的静态方法,均会返回一个实现了ViewAssertion接口的类实例。你可以将该实例,作为参数传递给ViewInteraction.check()方法。大多数情况下,我们使用ViewAssertions.matches()断言,断言当前选定控件的状态。

onView(withId(R.id.show_text_view)).check(matches(withText("text")))

4.定位一个View

最简单的是通过id来定位:

onView(withId(R.id.my_view))

或者通过特有的特征,比如文本:

onView(withText("Hello!"))

但有时候,使用withId()来定位一个控件,你可能会得到AmbiguousViewMatcherException异常。我们知道,R.id的值是可能被多个界面的控件共享的。所以,仅靠withId()来定位是不够的,必须加上额外的限制条件。比如:

onView(allOf(withId(R.id.my_view), withText("Hello!")));
又或者:
onView(allOf(withId(R.id.my_view), not(withText("Unwanted"))));

5.操作一个控件

最简单的就是点击一个控件:

onView(...).perform(click());

也可以对一个控件连续进行多个操作:

//输入文字,然后进行点击
onView(...).perform(typeText("Hello"), click());
//如果控件在ScrollView里面,可以先滑动,直到显示该控件,然后进行点击
onView(...).perform(scrollTo(), click());

6.断言

//断言控件可见
onView(...).check(matches(isDisplayed()))
//断言控件不可见
onView(...).check(matches(not(isDisplayed())))
//断言控件不存在
onView(...).check(doesNotExist())

7.列表中的定位

1)AdapterView

在AdapterView(比如ListView, GridView等)里面,多个条目复用同一个布局,onView()是不起作用的。这时候,要使用onData()。

比如,假设这样一个ListView。

它的adapter的数据类是Map<String,Integer>。 如:

{"STR" : "item: 0", "LEN": 7}

定位到该条目,并点击它:

//定位符合条件的item,如果不在屏幕上,Espresso会滑动屏幕,使其显示出来
onData(allOf(is(instanceOf(Map.class)), hasEntry(equalTo("STR"), is("item: 50"))))
    .perform(click());

如果是要定位到该条目中的某个子控件,比如,item右边的TextView:

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

示例源码:android-test里面的AdapterViewTest

2)RecyclerView

RecyclerView跟AdapterView是不同的,onData()对它并不起作用。需要espresso-contrib包里的工具类RecyclerViewActions帮助我们。它为我们提供了几个有用的静态方法: 滚动到匹配的视图。

scrollToHolder(Matcher<VH>)——滚动到匹配的ViewHolder。

scrollToPosition(int)——滚动到特定位置。

actionOnHolderItem(Matcher<VH>,ViewAction)——在匹配的ViewHolder上执行View操作。

actionOnItem(Matcher<View>,ViewAction)——对匹配的View执行View操作。

actionOnItemAtPosition(int,ViewAction)——对特定位置的View执行View操作。

下面是使用scrollToHolder(Matcher<VH>)方法,定位RecyclerView的中间条目:

1)先自定义一个匹配器Matcher

private static Matcher<CustomAdapter.ViewHolder> isInTheMiddle() {
        //ViewMatchers里很多方法,其实就是自定义一个匹配器进行校验,比如isDisplayed()
        return new TypeSafeMatcher<CustomAdapter.ViewHolder>() {
            @Override
            protected boolean matchesSafely(CustomAdapter.ViewHolder customHolder) {
                //检验item是否是中间的item
                return customHolder.getIsInTheMiddle();
            }

            /**
            * 生成一段对该对象的描述
            */
            @Override
            public void describeTo(Description description) {
                description.appendText("item in the middle");
            }
        };
}

2)定位RecyclerView的中间条目

//使用scrollToHolder(Matcher<VH>)方法,定位RecyclerView的中间条目
onView(ViewMatchers.withId(R.id.recyclerView))
                .perform(RecyclerViewActions.scrollToHolder(isInTheMiddle()));

//确认该条目有特定的文本描述
String middleElementText = "This is the middle!";
onView(withText(middleElementText)).check(matches(isDisplayed()));

示例源码:RecyclerViewSample里面的RecyclerViewSampleTest

参考资料:Espresso lists

8.Intent

Espresso提供了验证跳转其他界面的Intent的Api。

1)使用IntentsTestRule替代ActivityTestRule

@Rule
public IntentsTestRule<DialerActivity> mActivityRule = new IntentsTestRule<>(
            DialerActivity.class);

另外,如果是跳转到系统界面,比如拨打电话等,通常需要动态申请权限,而权限申请弹窗,会干扰测试,让我们失去对UI的控制。所以,需要使用GrantPermissionRule默认同意权限。

@Rule
public GrantPermissionRule grantPermissionRule = GrantPermissionRule
                                                .grant("android.permission.CALL_PHONE");

2)使用intended()和intending()进行验证。

intented()方法相当于是Mockito.verify()。 而intending()方法跟Mockito.when()类似,你可以提供一个自己设定的响应给startActivityForResult()。

@Test
public void typeNumber_ValidInput_InitiatesCall() {
    //输入一串有效的电话号码
    onView(withId(R.id.edit_text_caller_number))
            .perform(typeText(VALID_PHONE_NUMBER), closeSoftKeyboard());
    //点击跳转到拨打电话界面。会真的跳转。
    onView(withId(R.id.button_call_number)).perform(click());
    //验证跳转的Intent
    intended(allOf(
                hasAction(Intent.ACTION_CALL),
                hasData(INTENT_DATA_PHONE_NUMBER)));
}

@Test
public void pickContactButton_click_SelectsPhoneNumber() {
    //设定响应
    intending(hasComponent(hasShortClassName(".ContactsActivity")))
            .respondWith(new ActivityResult(Activity.RESULT_OK,
                    ContactsActivity.createResultData(VALID_PHONE_NUMBER)));
    //点击跳转到ContactsActivity,但前面有设定了响应,所以不会真的跳转。
    onView(withId(R.id.button_pick_contact)).perform(click());
    //验证响应结果
    onView(withId(R.id.edit_text_caller_number))
            .check(matches(withText(VALID_PHONE_NUMBER)));
}

3)防止界面跳转

Espresso写的测试代码是要运行在真机或者虚拟机上面的,点击跳转界面时,会真的发生跳转。如果你觉得这会干扰你的测试。可以通过下面的设定,避免这种情况。

@Before
public void stubAllExternalIntents() {
    //所有Intent都将被阻止
    intending(not(isInternal())).respondWith(new ActivityResult(Activity.RESULT_OK, null));
}

资料来源:Espresso-Intents

示例源码:IntentsBasicSampleIntentsAdvancedSample

9.测试异步代码

异步代码测试,会存在一个问题:异步代码通常比较耗时,可能它还没有执行完,相关的测试代码已经执行完了。这样,即使你的异步代码有误,但测试代码显示的结果永远都是正常的。

Espresso为我们提供了一套机制:Idling resources。使用方法:

1)app的build.gradle下添加依赖

//注意,不是androidTestImplementation
implementation 'androidx.test.espresso:espresso-idling-resource:3.1.0'

2)调整异步代码

//异步任务开始之前的地方,添加该代码
EspressoIdlingResource.increment();

//异步任务结束之后的地方,添加该代码
if (!EspressoIdlingResource.getIdlingResource().isIdleNow()) {
    EspressoIdlingResource.decrement();
}

EspressoIdlingResource是一个实现了IdlingResource接口的类。

3)在需要之前注册空闲资源

@Before
public void registerIdlingResource() {
    IdlingRegistry.getInstance().register(EspressoIdlingResource.getIdlingResource());
}

4)完成使用后取消注册闲置资源

@After
public void unregisterIdlingResource() {
    IdlingRegistry.getInstance().unregister(EspressoIdlingResource.getIdlingResource());
}

5)将异步代码视为同步代码,放心写测试代码即可

扩展:

如果是你的异步代码是RxJava写的,可以考虑下列的方法:

@Before
public void setup() {
    asyncToSync();
}

public static void asyncToSync() {
    RxJavaPlugins.reset();
    RxJavaPlugins.setIoSchedulerHandler(scheduler -> Schedulers.trampoline());
    RxAndroidPlugins.reset();
    RxAndroidPlugins.setInitMainThreadSchedulerHandler(
            schedulerCallable -> Schedulers.trampoline());
}

上面的设置,会利用RxJavaPlugins将io线程转换为trampoline,异步代码转换为同步代码。好处是不用像Espresso一样,入侵代码。坏处是,异步操作切换成同步,可能会导致ANR。

资料来源:Idling resource

示例源码:android-architectureIdlingResourceSample

10.Mock数据层

如果使用Espresso测试Activity,这已经算是一个端对端测试了。这时候,我们该考虑mock数据层了。因为Model层可能会通过请求网络等途径,去获取数据。而网络的不稳定性、不固定的网络请求结果,都会导致测试程序的不稳定性。

这里提供两种方案:

1)flavor

在gradle里面配置不同的flavor:mock和prod。这时候,项目源码的结构如下图。

这时候,通过Build Variants,我们就可以构建不同的包。如下图。

mock包使用的源码是main和mock里面的FakeTasksRemoteDataSource,顾名思义,model层使用的是假数据。而prod包使用的源码是main和prod里面TasksRemoteDataSource,是正式包,model层是从网络、数据库等处获取真实数据。

这样,我们build一个mock包,就可以使用假数据跑测试了。

Flavor的配置:Configure build variants

示例源码:android-architecture

2)MockWebServer

MockWebServer是跟随okhttp一起发布,我们可以用它来Mock服务器行为。

1.集成 app的build.gradle下添加依赖:

testImplementation 'com.squareup.okhttp3:mockwebserver:3.10.0'

2.本地提供json数据

在src/test目录下,新建resources文件夹,然后新建json文件夹,把响应的json放进里面。如下:

3.创建MockWebServer

public class NetworkMockTest {

    private GithubRepository mGithubRepository;
    //使用@Rule标注一下。
    @Rule
    public MockWebServer server = new MockWebServer();

    @Before
    public void setup() throws IOException {
        //重设BASE_URL。不要使用真实的URL,不然会直接请求真实网络。
        NetConstants.BASE_URL = server.url("/").toString();

        HttpService httpService = RetrofitFactory.createHttpService();
        mGithubRepository = new GithubRepository(httpService);
    }
}

4.模拟成功的网络请求

@Test
public void getUserOnSuccess() throws IOException {
    InputStream inputStream = getClass().getClassLoader().getResourceAsStream("json/user.json");
    String json = Okio.buffer(Okio.source(inputStream)).readString(StandardCharsets.UTF_8);
    server.enqueue(new MockResponse().setBody(json));
    mGithubRepository.getUser()
            .test()
            .assertNoErrors()
            .assertComplete()
            .assertValue(userBean ->
                    userBean.getLogin().equals("TuFei"));
    }

5.模拟失败的网络请求

@Test
public void getUserOnError() {
    server.enqueue(new MockResponse().setResponseCode(404));
    mGithubRepository.getUser()
            .test()
            .assertError(HttpException.class)
            .assertErrorMessage("HTTP 404 Client Error");
}

6.模拟弱网下的网络请求

@Test
public void getUserOnConnectTimeOut() throws IOException {
    InputStream inputStream = getClass().getClassLoader().getResourceAsStream("json/user.json");
    String json = Okio.buffer(Okio.source(inputStream)).readString(StandardCharsets.UTF_8);
    server.enqueue(new MockResponse()
            .setBody(json)
            .setResponseCode(504)
            //设置的响应超时时间是5秒
            //这里模拟弱弱弱网,每10秒传输1kb
            .throttleBody(1024, 10, TimeUnit.SECONDS));
    mGithubRepository.getUser()
            .test()
            .assertNotComplete()
            .assertError(SocketTimeoutException.class);
}

注意:

1)这里只是通过简单的单元测试例子,介绍一下MockWebServer的使用。当然,如果是在src/androidTest下写集成测试、端对端测试的时候要用,也需要通过androidTestImplementation引入依赖。

2)创建MockWebServer时,需要使用@Rule标注一下。不标注也可以,但你就得在测试开始前手动调用MockWebServer.start()启动服务器,测试结束后手动调用MockWebServer.shutdown()关闭服务器。MockWebServer本质就是TestRule,它帮我们封装了这些操作而已。(自定义TestRule,请参考浅谈测试之JUnit。)

3)示例使用的是Retrofit请求网络。所以,测试开启前要重设baseUrl,不要使用真实的Url去调用,不然会走真实网络。

示例源码:UnitTest

MockWebServer更多使用技巧,建议参考:okhttp源码

11.其他相关资料

1)测试Activity:Test your app's activities

2)测试Fragment:Test your app's fragments

3)测试WebView:Web

4)Espresso API备忘图:Espresso cheat sheet

后记

Espresso主要是用来写集成测试、端对端测试,也就是测试UI交互。我们只需要考虑对异步代码的处理,以及对数据层的mock。因为是在真机或者模拟器上运行的,不需要像在单元测试里面,忌惮Android类带来的影响。也因为集成测试、端对端测试,是在一个更大的范围内进行测试,所以旧代码的设计问题,比如,Presenter/Model层不是依赖注入、Presenter/Model层掺杂了过多的Android代码等等,都不影响你愉快地写测试代码。相比之下,单元测试,就痛苦得多了。

上述资料,源码大部分来自android-testing下的子module。知识点整理自Espresso官网指南,并结合了一些官网不涉及的资料。

推荐阅读官方的测试教程:Test apps on Android。它不仅包含了Espresso教程,还包括一些不需要使用到Espresso,但同样很重要的测试。下面列举一二:

测试服务:Test your service

测试内容提供者:Test your content provider

测试跨应用UI:UI Automator