Software quality and architecture are concepts that feed each other. We can better ensure the quality of a system the better it is built. Android components, which allows to develop automatic tests for a premature detection of issues. Do not forget that the cost of fixing a bug increases exponentially respect to the time it takes to be detected.
Thanks to the decoupled architectures, the view is independent of the view-model and the model (case of MVVM arch), so the data to be displayed can easily be mocked (with the Mockito library, for example ) to test the UI.
For Android, Espresso is one of the best-known frameworks due to its easiness to perform actions and assert conditions. Let’s do a quick review of Espresso and some of its main features:
What is Espresso?
Espresso is an open source tool to perform user interface tests.
To start using Espresso we must include the following dependency in build.gradle file:
androidTestImplementation 'androidx.test.espresso: espresso-core:$version'
we will add the latest version of Espresso.
Espresso is based on the use of different sentences to reproduce gestures on the UI:
- Matchers: check in UI the element to interact
- Actions: to perform actions on those elements
- Assertions: check the final status. For a test to pass, every assertion must pass.
The most common behavior of Espresso is combining matchers, actions and assertions to reproduce user scenarios.
A Espresso sentence is in the following form:
- To execute an action: onView (MATCHER). perform (ACTION)
- To check an assertion: onView (MATCHER). check (ASSERTION)
Let’s see an example. Having the following layout, typically an authentication activity:
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical"> <EditText android:id="@+id/username" android:layout_width="match_parent" android:layout_height="wrap_content" android:contentDescription="@string/username_description" android:hint="@string/username_hint"> <EditText android:id="@+id/password" android:layout_width="match_parent" android:layout_height="wrap_content" android:contentDescription="@string/pwd_description" android:hint="@string/pwd_hint"> <Button android:id="@+id/loginButton" android:layout_width="match_parent" android:layout_height="wrap_content" android:contentDescription="@string/loginButton_description" android:text="@string/loginButton_text"/> <TextView android:id="@+id/status" android:layout_width="match_parent" android:layout_height="wrap_content" android:contentDescription="@string/status_description" android:visibility="gone" /> </LinearLayout>
we have two edit fields for the credentials (username and password), a button to submit, and a status message that is initially hidden, and will tell us what has happened (the regular case is that in case of correct login the activity finishes, but for this example we will release the result in status make to make the example clearer)
The test on a correct login would have this form:
//Enter username onView(withId(R.id.username)).perform(replaceText("userName")) //Enter password onView(withId(R.id.password)).perform(replaceText("password")) //Click button onView(withId(R.id.loginButton)).perform(click()) //Asserting that the status message is visible with the correct text onView(withId(R.id.status)).check(matches(withEffectiveVisibility(Visibility.VISIBLE))) onView(withText(R.string.success)).check(matches(isDisplayed())
It is noticeable that the EditText is matched by id, performing actions on them to end up asserting on the status field.
In case the submit button launches an intent to open another activity instead of seeing a status message, how to proceed?
To perform this type of checks, we need to add the Espresso intent library to the build.gradle:
androidTestImplementation 'androidx.test.espresso: espresso-intents:$version'
The process is based on catching intents, so we can check if it contains the expected data.
With our example on authentication, let’s say that the communication between activities is done in the following way:
val intent = Intent(this, nextActivity::class) intent.putExtra("ACCOUNT_USERNAME", username) startActivity(intent)
With Espresso, we could check that the intent is launched and the extra added is correct. We will use intended to assert about the properties of the intents:
//We start to catch intents Intents.init() //Here there are the actions to enter credentials and click the button ... //Verified that an intent is launched to the expected activity intended(hasComponent(nextActivity::class.name)) //Verified that the caught intent contains the correct extra intended(hasExtra("ACCOUNT_USERNAME", username)) //Stop catching intents Intents.release()
In the case of startActivityForResult waiting for a result to be returned, we would change a bit our way. Since we are only testing our current activity, we will use intending to mock the result that returns the started activity. Let’s put another example:
val RETURNED_VALUE_EXPECTED = 1 ... val intent = Intent(this, nextActivity::class) intent.putExtra("VALUE", value) startActivityForResult(intent, RETURNED_VALUE_EXPECTED);
And this is the code to test, in which we mock the result that returns such activity:
//We start to catch intents Intents.init() //This is the intent that makes the nextActivity val resultNextActivity = Intent() resultNextActivity.setAction("ACTION_TO_DO"); //Creating ActivityResult val intentResult = Instrumentation.ActivityResult(RESULT_OK, resultNextActivity) intending(hasAction("ACTION_TO_DO")).respondWith(intentResult) //Assertions assertThat(...) //Stop catching intents Intents.release()
The first thing is creating an Intent variable, that is equivalent to the one that would return nextActivity in production. This variable will be part of an ActivityResult, which will also contain the expected result. In the moment that the intent against nextActivity is launched during the execution, intending will take charge of returning the result we have set up to such aim. From there, we will set the assertions.
In the case that the application raises a webView, we can perform actions with Espresso to interact with it. Here, external factors such as the design of the web and the labelling of its elements, necessary to interact, are relevant.
Like the previous Espresso features, Espresso web needs its own library in build.gradle:
A simple example of how the authentication process takes form within a webView:
onWebView().withElement(findElement(Locator.NAME, webViewUsernameId)).perform(webKeys(testUser)); onWebView().withElement(findElement(Locator.NAME, webViewPasswordId)).perform(webKeys(testPassword)); onWebView().withElement(findElement(Locator.XPATH, webViewSubmitXPath)).perform(webClick());
As we can see, the main differences are the use of onWebView instead of onView, and the name of the actions.
The contrib library extends Espresso by adding some extras that are not covered by the core library. Here it is the list:
- AccessibilityChecks: as its name suggests, it includes accessibility checks.
- ActivityResultMatchers: hamcrest matchers for Instrumentation.ActivityResult.
- DrawerActions: actions to interact with DrawerLayout.
- DrawerMatchers: matchers for DrawerLayout.
- NavigationViewActions: actions to interact with NavigationView.
- PickerActions: actions to interact with Date / TimePickers.
- RecyclerViewActions: actions to interact with RecyclerView.
- ViewPagerActions: actions to interact with ViewPager.
It is available the quick access cheat sheet of Espresso version 2. It helps a lot during the development.
Finally, Espresso allows us to interact with the views in a simple, flexible and versatile way. The support of an architecture that uncouples the views makes the tests purely from UI, regardless of where and how the data is stored, stored or brought. This is pretty important and relevant because it allows us to make a division of responsibilities at testing leves and not just over the code.