- Espresso 测试框架教程
- Espresso 测试 - 首页
- 简介
- 安装指南
- 在 Android Studio 中运行测试
- JUnit概述
- 架构
- 视图匹配器
- 自定义视图匹配器
- 视图断言
- 视图操作
- 测试 AdapterView
- 测试 WebView
- 测试异步操作
- 测试意图
- 测试多个应用程序的UI
- 测试录制器
- 测试UI性能
- 测试辅助功能
- Espresso 测试资源
- Espresso 测试 - 快速指南
- Espresso 测试 - 有用资源
- Espresso 测试 - 讨论
异步操作
本章将学习如何使用 Espresso Idling Resources 测试异步操作。
现代应用程序面临的挑战之一是提供流畅的用户体验。提供流畅的用户体验需要在后台进行大量工作,以确保应用程序进程的耗时不超过几毫秒。后台任务范围从简单的任务到从远程 API/数据库获取数据的复杂且耗时的任务。
如果开发多线程应用程序很复杂,那么为其编写测试用例就更加复杂。例如,在从数据库加载必要数据之前,我们不应该测试 AdapterView。如果在单独的线程中完成数据获取,则测试需要等到线程完成。因此,需要在后台线程和 UI 线程之间同步测试环境。Espresso 为测试多线程应用程序提供了极好的支持。应用程序以以下方式使用线程,Espresso 支持每种场景。
用户界面线程
Android SDK 内部使用它来为复杂的 UI 元素提供流畅的用户体验。Espresso 透明地支持此场景,不需要任何配置和特殊编码。
异步任务
现代编程语言支持异步编程,可以在不增加线程编程复杂性的情况下进行轻量级线程处理。Espresso 框架也透明地支持异步任务。
用户线程
开发者可以启动一个新线程来从数据库获取复杂或大型数据。为了支持这种情况,Espresso 提供了空闲资源的概念。
让我们学习本章中的空闲资源概念以及如何使用它。
概述
空闲资源的概念非常简单直观。其基本思想是在单独的线程中启动任何长时间运行的进程时创建一个变量(布尔值),以识别该进程是否正在运行,并在测试环境中注册它。在测试期间,测试运行器将检查注册的变量(如果找到),然后查找其运行状态。如果运行状态为 true,测试运行器将等待直到状态变为 false。
Espresso 提供了一个接口 IdlingResources 用于维护运行状态。主要实现方法是 isIdleNow()。如果 isIdleNow() 返回 true,Espresso 将恢复测试过程;否则,将等待直到 isIdleNow() 返回 false。我们需要实现 IdlingResources 并使用派生类。Espresso 还提供了一些内置的 IdlingResources 实现,以减轻我们的工作量。它们如下:
CountingIdlingResource
它维护一个内部运行任务计数器。它公开了 increment() 和 decrement() 方法。increment() 将计数器加一,decrement() 将计数器减一。只有在没有活动任务时,isIdleNow() 才返回 true。
UriIdlingResource
这类似于 CountingIdlingResource,只是计数器需要为零一段时间才能考虑网络延迟。
IdlingThreadPoolExecutor
这是 ThreadPoolExecutor 的自定义实现,用于维护当前线程池中活动运行任务的数量。
IdlingScheduledThreadPoolExecutor
这类似于 IdlingThreadPoolExecutor,但它也调度任务,并且是 ScheduledThreadPoolExecutor 的自定义实现。
如果在应用程序中使用了上述任何 IdlingResources 实现或自定义实现,我们需要在使用 IdlingRegistry 类测试应用程序之前将其注册到测试环境中,如下所示:
IdlingRegistry.getInstance().register(MyIdlingResource.getIdlingResource());
此外,测试完成后可以将其移除,如下所示:
IdlingRegistry.getInstance().unregister(MyIdlingResource.getIdlingResource());
Espresso 在一个单独的包中提供此功能,需要在 app.gradle 中进行如下配置。
dependencies {
implementation 'androidx.test.espresso:espresso-idling-resource:3.1.1'
androidTestImplementation "androidx.test.espresso.idling:idlingconcurrent:3.1.1"
}
示例应用程序
让我们创建一个简单的应用程序,通过在单独的线程中从 Web 服务获取水果列表,然后使用空闲资源的概念对其进行测试。
启动 Android Studio。
创建新项目(如前所述),并将其命名为 MyIdlingFruitApp
使用“重构”→“迁移到 AndroidX”选项菜单将应用程序迁移到 AndroidX 框架。
在 app/build.gradle 中添加 Espresso 空闲资源库(并同步),如下所示:
dependencies {
implementation 'androidx.test.espresso:espresso-idling-resource:3.1.1'
androidTestImplementation "androidx.test.espresso.idling:idlingconcurrent:3.1.1"
}
删除主活动中的默认设计并添加 ListView。activity_main.xml 的内容如下所示:
<?xml version = "1.0" encoding = "utf-8"?>
<RelativeLayout xmlns:android = "http://schemas.android.com/apk/res/android"
xmlns:app = "http://schemas.android.com/apk/res-auto"
xmlns:tools = "http://schemas.android.com/tools"
android:layout_width = "match_parent"
android:layout_height = "match_parent"
tools:context = ".MainActivity">
<ListView
android:id = "@+id/listView"
android:layout_width = "wrap_content"
android:layout_height = "wrap_content" />
</RelativeLayout>
添加新的布局资源 item.xml 来指定列表视图的项目模板。item.xml 的内容如下所示:
<?xml version = "1.0" encoding = "utf-8"?> <TextView xmlns:android = "http://schemas.android.com/apk/res/android" android:id = "@+id/name" android:layout_width = "fill_parent" android:layout_height = "fill_parent" android:padding = "8dp" />
创建一个新类 – MyIdlingResource。MyIdlingResource 用于在一个地方保存我们的 IdlingResource,并在需要时获取它。在本例中,我们将使用
CountingIdlingResource。
package com.tutorialspoint.espressosamples.myidlingfruitapp;
import androidx.test.espresso.IdlingResource;
import androidx.test.espresso.idling.CountingIdlingResource;
public class MyIdlingResource {
private static CountingIdlingResource mCountingIdlingResource =
new CountingIdlingResource("my_idling_resource");
public static void increment() {
mCountingIdlingResource.increment();
}
public static void decrement() {
mCountingIdlingResource.decrement();
}
public static IdlingResource getIdlingResource() {
return mCountingIdlingResource;
}
}
在 MainActivity 类中声明一个全局变量 mIdlingResource,类型为 CountingIdlingResource,如下所示:
@Nullable private CountingIdlingResource mIdlingResource = null;
编写一个私有方法,从 Web 获取水果列表,如下所示:
private ArrayList<String> getFruitList(String data) {
ArrayList<String> fruits = new ArrayList<String>();
try {
// Get url from async task and set it into a local variable
URL url = new URL(data);
Log.e("URL", url.toString());
// Create new HTTP connection
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
// Set HTTP connection method as "Get"
conn.setRequestMethod("GET");
// Do a http request and get the response code
int responseCode = conn.getResponseCode();
// check the response code and if success, get response content
if (responseCode == HttpURLConnection.HTTP_OK) {
BufferedReader in = new BufferedReader(new InputStreamReader(conn.getInputStream()));
String line;
StringBuffer response = new StringBuffer();
while ((line = in.readLine()) != null) {
response.append(line);
}
in.close();
JSONArray jsonArray = new JSONArray(response.toString());
Log.e("HTTPResponse", response.toString());
for(int i = 0; i < jsonArray.length(); i++) {
JSONObject jsonObject = jsonArray.getJSONObject(i);
String name = String.valueOf(jsonObject.getString("name"));
fruits.add(name);
}
} else {
throw new IOException("Unable to fetch data from url");
}
conn.disconnect();
} catch (IOException | JSONException e) {
e.printStackTrace();
}
return fruits;
}
在 onCreate() 方法中创建一个新任务,使用我们的 getFruitList 方法从 Web 获取数据,然后创建一个新适配器并将其设置到列表视图。一旦我们的工作在线程中完成,也减少空闲资源。代码如下所示:
// Get data
class FruitTask implements Runnable {
ListView listView;
CountingIdlingResource idlingResource;
FruitTask(CountingIdlingResource idlingRes, ListView listView) {
this.listView = listView;
this.idlingResource = idlingRes;
}
public void run() {
//code to do the HTTP request
final ArrayList<String> fruitList = getFruitList("http://<your domain or IP>/fruits.json");
try {
synchronized (this){
runOnUiThread(new Runnable() {
@Override
public void run() {
// Create adapter and set it to list view
final ArrayAdapter adapter = new
ArrayAdapter(MainActivity.this, R.layout.item, fruitList);
ListView listView = (ListView)findViewById(R.id.listView);
listView.setAdapter(adapter);
}
});
}
} catch (Exception e) {
e.printStackTrace();
}
if (!MyIdlingResource.getIdlingResource().isIdleNow()) {
MyIdlingResource.decrement(); // Set app as idle.
}
}
}
此处,水果 URL 被视为 http://<你的域名或IP/fruits.json,并格式化为 JSON。内容如下所示:
[
{
"name":"Apple"
},
{
"name":"Banana"
},
{
"name":"Cherry"
},
{
"name":"Dates"
},
{
"name":"Elderberry"
},
{
"name":"Fig"
},
{
"name":"Grapes"
},
{
"name":"Grapefruit"
},
{
"name":"Guava"
},
{
"name":"Jack fruit"
},
{
"name":"Lemon"
},
{
"name":"Mango"
},
{
"name":"Orange"
},
{
"name":"Papaya"
},
{
"name":"Pears"
},
{
"name":"Peaches"
},
{
"name":"Pineapple"
},
{
"name":"Plums"
},
{
"name":"Raspberry"
},
{
"name":"Strawberry"
},
{
"name":"Watermelon"
}
]
注意 – 将文件放在本地 Web 服务器上并使用它。
现在,查找视图,通过传递 FruitTask 创建一个新线程,增加空闲资源,最后启动任务。
// Find list view ListView listView = (ListView) findViewById(R.id.listView); Thread fruitTask = new Thread(new FruitTask(this.mIdlingResource, listView)); MyIdlingResource.increment(); fruitTask.start();
MainActivity 的完整代码如下所示:
package com.tutorialspoint.espressosamples.myidlingfruitapp;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.appcompat.app.AppCompatActivity;
import androidx.test.espresso.idling.CountingIdlingResource;
import android.os.Bundle;
import android.util.Log;
import android.widget.ArrayAdapter;
import android.widget.ListView;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.ArrayList;
public class MainActivity extends AppCompatActivity {
@Nullable
private CountingIdlingResource mIdlingResource = null;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
// Get data
class FruitTask implements Runnable {
ListView listView;
CountingIdlingResource idlingResource;
FruitTask(CountingIdlingResource idlingRes, ListView listView) {
this.listView = listView;
this.idlingResource = idlingRes;
}
public void run() {
//code to do the HTTP request
final ArrayList<String> fruitList = getFruitList(
"http://<yourdomain or IP>/fruits.json");
try {
synchronized (this){
runOnUiThread(new Runnable() {
@Override
public void run() {
// Create adapter and set it to list view
final ArrayAdapter adapter = new ArrayAdapter(
MainActivity.this, R.layout.item, fruitList);
ListView listView = (ListView) findViewById(R.id.listView);
listView.setAdapter(adapter);
}
});
}
} catch (Exception e) {
e.printStackTrace();
}
if (!MyIdlingResource.getIdlingResource().isIdleNow()) {
MyIdlingResource.decrement(); // Set app as idle.
}
}
}
// Find list view
ListView listView = (ListView) findViewById(R.id.listView);
Thread fruitTask = new Thread(new FruitTask(this.mIdlingResource, listView));
MyIdlingResource.increment();
fruitTask.start();
}
private ArrayList<String> getFruitList(String data) {
ArrayList<String> fruits = new ArrayList<String>();
try {
// Get url from async task and set it into a local variable
URL url = new URL(data);
Log.e("URL", url.toString());
// Create new HTTP connection
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
// Set HTTP connection method as "Get"
conn.setRequestMethod("GET");
// Do a http request and get the response code
int responseCode = conn.getResponseCode();
// check the response code and if success, get response content
if (responseCode == HttpURLConnection.HTTP_OK) {
BufferedReader in = new BufferedReader(new InputStreamReader(conn.getInputStream()));
String line;
StringBuffer response = new StringBuffer();
while ((line = in.readLine()) != null) {
response.append(line);
}
in.close();
JSONArray jsonArray = new JSONArray(response.toString());
Log.e("HTTPResponse", response.toString());
for(int i = 0; i < jsonArray.length(); i++) {
JSONObject jsonObject = jsonArray.getJSONObject(i);
String name = String.valueOf(jsonObject.getString("name"));
fruits.add(name);
}
} else {
throw new IOException("Unable to fetch data from url");
}
conn.disconnect();
} catch (IOException | JSONException e) {
e.printStackTrace();
}
return fruits;
}
}
现在,在应用程序清单文件 AndroidManifest.xml 中添加以下配置
<uses-permission android:name = "android.permission.INTERNET" />
现在,编译上述代码并运行应用程序。“我的空闲水果应用程序”的屏幕截图如下所示:
现在,打开 ExampleInstrumentedTest.java 文件,并添加如下所示的 ActivityTestRule:
@Rule
public ActivityTestRule<MainActivity> mActivityRule =
new ActivityTestRule<MainActivity>(MainActivity.class);
Also, make sure the test configuration is done in app/build.gradle
dependencies {
testImplementation 'junit:junit:4.12'
androidTestImplementation 'androidx.test:runner:1.1.1'
androidTestImplementation 'androidx.test:rules:1.1.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1'
implementation 'androidx.test.espresso:espresso-idling-resource:3.1.1'
androidTestImplementation "androidx.test.espresso.idling:idlingconcurrent:3.1.1"
}
添加一个新的测试用例来测试列表视图,如下所示:
@Before
public void registerIdlingResource() {
IdlingRegistry.getInstance().register(MyIdlingResource.getIdlingResource());
}
@Test
public void contentTest() {
// click a child item
onData(allOf())
.inAdapterView(withId(R.id.listView))
.atPosition(10)
.perform(click());
}
@After
public void unregisterIdlingResource() {
IdlingRegistry.getInstance().unregister(MyIdlingResource.getIdlingResource());
}
最后,使用 Android Studio 的上下文菜单运行测试用例,并检查所有测试用例是否成功。