Android Native - How to use CountingIdlingResource in Espresso tests

dimitrilc 1 Tallied Votes 453 Views Share

Introduction ##

When working with Espresso tests, you might have found it hard to make Espresso wait for background tasks to complete before performing other actions or assertions. Fortunately, the classes in the Espresso idling package exist to cover this use case.

In this tutorial, we will learn how to use one of those classes called CountingIdlingResource.

Goals

At the end of the tutorial, you would have learned:

  1. How to use CountingIdlingResource in Espresso tests.

Tools Required

  1. Android Studio. The version used in this tutorial is Android Studio Chipmunk 2021.2.1 Patch 1.

Prerequisite Knowledge

  1. Intermediate Android.
  2. Basic Espresso.
  3. Basic understanding of async operations.

Project Setup

To follow along with the tutorial, perform the steps below:

  1. Create a new Android project with the default Empty Activity.

  2. Add the dependencies below into your module build.gradle file.

     implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.1'
     implementation 'androidx.test.espresso:espresso-idling-resource:3.4.0'
     androidTestImplementation 'androidx.test:runner:1.4.0'
     androidTestImplementation 'androidx.test:rules:1.4.0'
  3. Replace activity_main.xml with the code below. We simply changed the textSize and added an android:id to TextView.

     <?xml version="1.0" encoding="utf-8"?>
     <androidx.constraintlayout.widget.ConstraintLayout 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">
    
        <TextView
            android:id="@+id/textView_helloWorld"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="Hello World!"
            android:textSize="32sp"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />
    
     </androidx.constraintlayout.widget.ConstraintLayout>
  4. Replace the entire class ExampleInstrumentedTest with the code below. Besides the commented out lines, it is just a simple Espresso test that performs a click on the TextView, and then verifies that the new text content is correct. You do not have to understand the commented out parts for now.

     @RunWith(AndroidJUnit4::class)
     class ExampleInstrumentedTest {
    
        //Gets the IdlingRegistry singleton
        //private val idlingResourceRegistry = IdlingRegistry.getInstance()
    
        @get:Rule
        val activityRule = ActivityScenarioRule(MainActivity::class.java)
    
        @Test
        fun doAsyncTest(){
            //Register the CountingIdlingResource before click()
            //idlingResourceRegistry.register(countingIdlingResource)
    
            onView(withId(R.id.textView_helloWorld))
                .perform(click())
                .check(matches(withText("WorldHello!")))
        }
    
     /*    @After
        fun unregisterIdlingResources(){
            //Unregisters the CountingIdlingResource
            idlingResourceRegistry.resources.forEach {
                idlingResourceRegistry.unregister(it)
            }
        }*/
     }
  5. Create a new class called HttpClient using the code below. This class contains a single function that will suspend for three seconds, and then assign a new text value to a TextView object.

     class HttpClient {
        fun doLongAsync(textView: TextView) {
            //Incrementing counter when work starts
            //countingIdlingResource.increment()
    
            CoroutineScope(Dispatchers.Main).launch {
                delay(3000)
                textView.text = "WorldHello!"
                //countingIdlingResource.decrement()
            }
        }
     }
  6. In the same file, add the singleton below. You do not need to understand it for now.

     /*
     We can use a global singleton for this project because
     We don't have more than one test using this IdlingResourceCounter
     It is recommended to add IdlingResourceCounter directly into your
     Production code
     */
     object IdlingResourceCounter {
        private const val IDLING_RESOURCE_NAME = "GlobalIdlingResourceCounter"
        val countingIdlingResource = CountingIdlingResource(IDLING_RESOURCE_NAME)
     }
  7. Finally, append the code below to MainActivity#onCreate().

     val textView = findViewById<TextView>(R.id.textView_helloWorld)
    
     val httpClient = HttpClient()
    
     textView.setOnClickListener {
        //Don't pass a View to a Service in a real app!
        httpClient.doLongAsync(it as TextView)
     }

Project Overview

The tutorial app contains a single TextView, after clicking on it and waits for 3 seconds, then its value will change to WorldHello! from Hello World!.

WorldHello.gif

Now let us look at the instrument test doAsyncTest() in ExampleInstrumentedTest.

Failed_Instrument_Test.gif

doAsyncTest() will fail because Espresso is not aware of the background task.

androidx.test.espresso.base.DefaultFailureHandler$AssertionFailedWithCauseError: 'an instance of android.widget.TextView and view.getText() with or without transformation to match: is "WorldHello!"' doesn't match the selected view.
Expected: an instance of android.widget.TextView and view.getText() with or without transformation to match: is "WorldHello!"

It tries to check for the WorldHello! value immediately after clicking on the TextView. There are only three conditions where Espresso will wait:

  1. The MessageQueue is empty.
  2. There is no running AsyncTask.
  3. There is no idling resource. CountingIdlingResource is one of such resources that we are learning about in this tutorial.

Creating the CountingIdlingResource object

The object IdlingResourceCounter contains a public field called countingIdlingResource, which is an instance of CountingIdlingResource. The only argument that the CountingIdlingResource constructor requires is a String value that explains what the instance of CountingIdlingResource is used for.

object IdlingResourceCounter {
    private const val IDLING_RESOURCE_NAME = "GlobalIdlingResourceCounter"
    val countingIdlingResource = CountingIdlingResource(IDLING_RESOURCE_NAME)
}

We are only able to use a global object in this tutorial because we only have one test. It is recommended that you provide a CountingIdlingResource for each instance of your async-worker class.

CountingIdlingResource and Service class

Your Service or Repository classes should increment() the counter when starting long-running async work and decrement() the counter when the work is complete. To achieve this, uncomment the respective lines in the HttpClient class, so it should look like the code below.

class HttpClient {
   fun doLongAsync(textView: TextView) {
       //Incrementing counter when work starts
       countingIdlingResource.increment()

       CoroutineScope(Dispatchers.Main).launch {
           delay(3000)
           textView.text = "WorldHello!"
           countingIdlingResource.decrement()
       }
   }
}

Register CountingIdlingResource to IdlingResourceRegistry

Simply having a working CountingIdlingResource is not enough. We still have to register the CountingIdlingResource instance to the IdlingResourceRegistry. IdlingResourceRegistry is a singleton. We already have the code to retrieve it in ExampleInstrumentedTest, so retrieve it by uncomment the line of code below.

private val idlingResourceRegistry = IdlingRegistry.getInstance()

Now that we have the IdlingResourceRegistry, we can register CountingIdlingResource to it by uncommenting this line of code inside doAsyncTest().

idlingResourceRegistry.register(countingIdlingResource)

We also have to unregister the idling resource after the test is done, so uncomment the function unregisterIdlingResources() as well.

@After
fun unregisterIdlingResources(){
   //Unregisters the CountingIdlingResource
   idlingResourceRegistry.resources.forEach {
       idlingResourceRegistry.unregister(it)
   }
}

We should have everything we need for a working test. If we run the test now, we can see that Espresso will wait for 3 seconds before performing the check().

Passed_Idling_Resource_Test.gif

Solution Code

ExampleInstrumentedTest.kt

@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {

   //Gets the IdlingRegistry singleton
   private val idlingResourceRegistry = IdlingRegistry.getInstance()

   @get:Rule
   val activityRule = ActivityScenarioRule(MainActivity::class.java)

   @Test
   fun doAsyncTest(){
       //Register the CountingIdlingResource before click()
       idlingResourceRegistry.register(countingIdlingResource)

       onView(withId(R.id.textView_helloWorld))
           .perform(click())
           .check(matches(withText("WorldHello!")))
   }

   @After
   fun unregisterIdlingResources(){
       //Unregisters the CountingIdlingResource
       idlingResourceRegistry.resources.forEach {
           idlingResourceRegistry.unregister(it)
       }
   }
}

HttpClient.kt

class HttpClient {
   fun doLongAsync(textView: TextView) {
       //Incrementing counter when work starts
       countingIdlingResource.increment()

       CoroutineScope(Dispatchers.Main).launch {
           delay(3000)
           textView.text = "WorldHello!"
           countingIdlingResource.decrement()
       }
   }
}

/*
We can use a global singleton for this project because
We don't have more than one test using this IdlingResourceCounter
It is recommended to add IdlingResourceCounter directly into your
Production code
*/
object IdlingResourceCounter {
   private const val IDLING_RESOURCE_NAME = "GlobalIdlingResourceCounter"
   val countingIdlingResource = CountingIdlingResource(IDLING_RESOURCE_NAME)
}

MainActivity.kt

class MainActivity : AppCompatActivity() {
   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       setContentView(R.layout.activity_main)

       val textView = findViewById<TextView>(R.id.textView_helloWorld)

       val httpClient = HttpClient()

       textView.setOnClickListener {
           //Don't pass a View to a Service in a real app!
           httpClient.doLongAsync(it as TextView)
       }
   }
}

activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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">

   <TextView
       android:id="@+id/textView_helloWorld"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:text="Hello World!"
       android:textSize="32sp"
       app:layout_constraintBottom_toBottomOf="parent"
       app:layout_constraintEnd_toEndOf="parent"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

Module build.gradle

plugins {
   id 'com.android.application'
   id 'org.jetbrains.kotlin.android'
}

android {
   compileSdk 32

   defaultConfig {
       applicationId "com.example.daniwebandroidcountingidlingresource"
       minSdk 21
       targetSdk 32
       versionCode 1
       versionName "1.0"

       testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
   }

   buildTypes {
       release {
           minifyEnabled false
           proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
       }
   }
   compileOptions {
       sourceCompatibility JavaVersion.VERSION_1_8
       targetCompatibility JavaVersion.VERSION_1_8
   }
   kotlinOptions {
       jvmTarget = '1.8'
   }
}

dependencies {

   implementation 'androidx.core:core-ktx:1.7.0'
   implementation 'androidx.appcompat:appcompat:1.4.1'
   implementation 'com.google.android.material:material:1.6.0'
   implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
   implementation 'androidx.test.espresso:espresso-idling-resource:3.4.0'
   testImplementation 'junit:junit:4.13.2'
   androidTestImplementation 'androidx.test.ext:junit:1.1.3'
   androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
   androidTestImplementation 'androidx.test:runner:1.4.0'
   androidTestImplementation 'androidx.test:rules:1.4.0'
   implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.1'

}

Summary

We have learned how to use CountingIdlingResource to make Espresso wait in this tutorial. The full project code can be found at https://github.com/dmitrilc/DaniwebAndroidCountingIdlingResource.

Be a part of the DaniWeb community

We're a friendly, industry-focused community of developers, IT pros, digital marketers, and technology enthusiasts meeting, networking, learning, and sharing knowledge.