异步操作

在本章中,我们将学习如何使用 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"
/>
  • 创建一个新类 - MyIdlingResourceMyIdlingResource 用于将我们的 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 的屏幕截图如下,

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 的上下文菜单运行测试用例并检查所有测试用例是否成功。