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
示例源码:IntentsBasicSample、IntentsAdvancedSample
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-architecture、IdlingResourceSample
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