异步操作
在本章中,我们将学习如何使用 Espresso Idling Resources 测试异步操作。
现代应用程序面临的挑战之一是提供流畅的用户体验。提供流畅的用户体验需要大量后台工作,以确保应用程序进程不会超过几毫秒。后台任务范围从简单任务到从远程 API/数据库获取数据的昂贵而复杂的任务。为了应对过去的挑战,开发人员习惯于在后台线程中编写昂贵且长时间运行的任务,并在后台线程完成后与主 UIThread 同步。
如果开发多线程应用程序很复杂,那么为其编写测试用例就更加复杂。例如,在从数据库加载必要数据之前,我们不应该测试 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
这与 CounintIdlingResource 类似,不同之处在于计数器需要长时间为零以同时考虑网络延迟。
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
使用Refactor将应用程序迁移到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" }
在主 Activity 中移除默认设计,并添加 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模板,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;
编写一个私有方法从网络获取水果列表,如下所示
private ArrayList<String> getFruitList(String data) { ArrayList<String> fruits = new ArrayList<String>(); try { // 从异步任务中获取 URL 并将其设置为本地变量 URL url = new URL(data); Log.e("URL", url.toString()); // 创建新的 HTTP 连接 HttpURLConnection conn = (HttpURLConnection) url.openConnection(); // 将 HTTP 连接方法设置为"Get" conn.setRequestMethod("GET"); // 执行 http 请求并获取响应代码 int responseCode = conn.getResponseCode(); // 检查响应代码,如果成功,则获取响应内容 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 方法从网络获取数据,然后创建一个新适配器并将其设置为列表视图。此外,一旦我们的工作在线程中完成,就减少空闲资源。代码如下,
// 获取数据 class FruitTask implements Runnable { ListView listView; CountingIdlingResource idlingResource; FruitTask(CountingIdlingResource idlingRes, ListView listView) { this.listView = listView; this.idlingResource = idlingRes; } public void run() { //执行 HTTP 请求的代码 final ArrayList<String> fruitList = getFruitList("http://<your domain or IP>/fruits.json"); try { synchronized (this){ runOnUiThread(new Runnable() { @Override public void run() { // 创建适配器并将其设置为列表视图 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://<your domain or 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 创建一个新线程,增加空闲资源并最终启动任务。
// 查找列表视图 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); // 获取数据 class FruitTask implements Runnable { ListView listView; CountingIdlingResource idlingResource; FruitTask(CountingIdlingResource idlingRes, ListView listView) { this.listView = listView; this.idlingResource = idlingRes; } public void run() { //执行 HTTP 请求的代码 final ArrayList<String> fruitList = getFruitList( "http://<yourdomain or IP>/fruits.json"); try { synchronized (this){ runOnUiThread(new Runnable() { @Override public void run() { // 创建适配器并将其设置为列表视图 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. } } } // 查找列表视图 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 { // 从异步任务中获取 url 并将其设置到局部变量中 URL url = new URL(data); Log.e("URL", url.toString()); // 创建新的 HTTP 连接 HttpURLConnection conn = (HttpURLConnection) url.openConnection(); // 设置 HTTP 连接方法为"Get" conn.setRequestMethod("GET"); // 执行 http 请求并获取响应代码 int responseCode = conn.getResponseCode(); // 检查响应代码,如果成功,获取响应内容 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" />
现在,编译上述代码并运行应用程序。My Idling Fruit App 的屏幕截图如下,
现在,打开 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 的上下文菜单运行测试用例并检查所有测试用例是否成功。