UI-Tests
In classic programming, the codebase is mainly covered with unit and integration tests.
This should also be done in programming for Android. However, this article focuses on
ensuring an error-free user interface (UI).
On Android, UI tests can be mapped natively using the Espresso framework. However, there
are also other test frameworks such as Robotium, which can also be used to create UI
tests for Android.
Espresso
Espresso is a framework from Google that was developed specifically for testing Android
user interfaces. It was introduced at the Google Test Automation Conference in April
2013 and made available to developers in October of the same year. Espresso makes it
possible to write native UI tests and thus directly access code from the
development.
Setup
Since Espresso is a native framework, the setup is comparatively simple. The first step
is to add the latest versions of the following dependencies to the application's
build.gradle:
androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2'
androidTestImplementation 'com.android.support.test:runner:1.0.2'
androidTestImplementation 'com.android.support.test:rules:1.0.2'
Also, the test runner must be added to the defaultConfig section of build.gradle:
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
Afterwards, the setup is complete and you can start writing the tests.
Tests with Espresso
In this example, we will test a small application that consists of two screens. The first
screen contains two buttons and a TextView, the second screen contains a RecyclerView
and a FloatingActionButton (FAB). Above the list there should be a TextView. Two
fragments in an activity were used to implement the screens.
The list was created using
a RecyclerView.
If you click on the left button in the first screen, the counter in the TextView is incremented.
Figure 1: Clicking the right button opens the second screen
Figure 2: In the second screen, an element is to be added to the
list when the FAB is clicked. An element of the list consists of a TextView and a
checkbox.
The TextView should contain the element with number, i.e. "Item 0" for the first element,
"Item 1" for the second element and so on. In addition, the number of elements that are
selected and the number of the total elements should be displayed in the TextView above
the list. This should be displayed as follows: "2/5 elements selected". After clicking
on the "Back" button in the upper left corner, the view returns to the first screen.
Figure 3
UI tests are now defined for this application. The first step is to create a rule for the activity used.
public class MainActivityTestRule extends ActivityTestRule{
public MainActivityTestRule(Class activityClass) {
super(activityClass);
}
}
The standard ActivityTestRule can also be used here. However, since something will be
overwritten at this point later, a separate rule should be created for the
MainActivity.
The first espresso test can now be written. For this you create a class in the
"androidTest" folder, in which integration tests are searched for by default. Since no
extremely large test class is needed, one class can be created for the first screen and
one for the second screen. So that the rule can be used in both, you create the
"BaseTest" class, from which both test classes can inherit. Then a new instance of the
rule can be created:
@Rule
public MainActivityTestRule mActivityRule = new MainActivityTestRule<>(MainActivity.class);
In the next step, a test class can be created for the first screen. This inherits from
"BaseTest". With the annotation @Test
a method is now
marked that represents a test. It is recommended to name the test methods in a
meaningful way, so that it is immediately obvious what exactly is being tested. For UI
tests, the following notation is recommended:
test_wasSollImTestPassieren_WasWirdErwartet()
This approach can lead to the names of the test methods becoming very long, but allows
the developer to quickly see what is being tested in this method even without
documentation.
The first test is to check if the UI was initialized with the correct values:
- The correct content is displayed in the text field
- Both buttons have the matching label
@Test
public void test_uiHasSetupCorrectly_AllViewsShouldBeVisibleAndHaveCorrectValues(){
onView(withId(R.id.fragment_button_counter_text_view)).check(
matches(withText("Number of Button Clicks: 0")));
onView(withId(R.id.fragment_button_counter_button)).check(matches(withText("Count")));
onView(withId(R.id.fragment_button_fragment_change_button)).check(
matches(withText("Change Fragment")));
}
With onView()
the view to be tested is selected. The
id is the "android:id" specified in the XML layout file for a UI element. Then a matcher
is expected via check()
. In our case, only the string
is compared in each case. Here it is better to use string resources instead of fixed
strings. This has two advantages:
- One change immediately affects the test and application
- If the application is translated, you can run the tests regardless of the device language
For the test application, however, it is recommended to stay with fixed strings.
Now it is ensured that the screen has been built up properly. The next function of the
first screen is to click on the left button and count up the counter in the text field.
In addition to checking an element as in the previous test, clicking the button is also
added here, i.e. an action:
@Test
public test_ButtonClickUpdatesCounter_CounterCountsUp(){
onView(withId(R.id.first_screen_text_view)).check(matches(withText(“0 Klicks”)));
onView(withId(R.id.first_screen_left_button)).perform(click());
onView(withId(R.id.first_screen_text_view)).check(matches(withText(“1 Klicks”)));
}
First it checks if the initial value is correct, then the onView(withId())
selects the left button and perform()
selects an
action to be executed on the object. In our case, the action is click()
.
Subsequently, it should be checked whether the counter has been incremented. A
distinction between singular and plural for one or more elements was omitted here. To
ensure that this also works more than once, another test is added in which the button is
clicked ten times:
@Test
public test_ButtonClickUpdatesCounter_CounterCountsUp(){
onView(withId(R.id.first_screen_text_view)).check(
matches(withText(“0 Klicks”)));
for(int i = 0; i<10 ; i++{
onView(withId(R.id.first_screen_left_button)).perform(click());
}
onView(withId(R.id.first_screen_text_view)).check(
matches(withText(“10 Klicks”)));
}
The click()
action is now executed ten times in a
loop. Subsequently, the check is made whether it was counted correctly.
There is a second action in the first screen, whose correct execution is to be ensured by
means of a UI test. Clicking on the right button should open the second screen.
To test whether the view has actually opened, you can check whether a layout element of
the second screen is visible. Alternatively, you could also use the title of the toolbar
or another unique feature of the view. In our case, the FAB was selected in the second
screen:
@Test
public test_ButtonClickUpdatesCounter_CounterCountsUp(){
onView(withId(R.id.first_screen_right_button)).perform(click());
onView(withId(R.id.second_screen_floating_action_button)).check(matches(isDisplayed()));
}
After clicking on the button in the first screen, the FAB can be found via the layout ID
and it is checked whether the button is displayed.
Now all functions of the first screen are covered. The tests for the second screen are
created in the second test class. It is noticeable that all tests must first navigate to
the second screen before they can be executed. Since this is true for all tests in this
test class, the @Before
annotation can be used here
and executed before each test can be executed. Otherwise, a method would have to be
called in each test that navigates to the second screen.
@Before
public void setUp(){
onView(withId(R.id.first_screen_right_button)).perform(click());
}
Now you can start testing the second screen, the following should be tested:
- The UI has been built correctly in the basic state.
Clicking the FAB adds an item to the list, the TextView in the added item contains the
correct text, and the TextView above the list has updated correctly.
- Selecting a checkbox updates the TextView above the list.
- Selecting a checkbox and then adding an item leaves the checkbox selected and the
TextView above the list is updated correctly.
- Deselecting a checkbox and then adding an item leaves the checkbox deselected and
the TextView above the list is updated correctly.
To check the UI in the basic state, only the content of the TextView above the list and
the visibility of the FAB need to be checked here.
Now to the first test, which contains an element of a RecyclerView. The click on the FAB
can be realized analogously to the clicks on the buttons in the first screen. To examine
an element of a RecyclerView, you need your own ViewMatcher. With custom ViewMatchers,
you can examine almost any element in Espresso. Often there are already implementations
of ViewMatchers for UI elements. For the RecyclerView
ViewMatcher was used.
Then, a method withRecyclerView()
is introduced in the
"BaseTest" class to call the watcher like the standard Espresso Matcher.
public static RecyclerViewMatcher withRecyclerView(final int recyclerViewId) {
return new RecyclerViewMatcher(recyclerViewId);
}
Now the elements of the RecyclerView can be tested. Here you should get into the habit of
scrolling to the index of the element before testing it. This is necessary because
Espresso can only test an element that is displayed on the screen. To make the scrolling
of the RecyclerView possible, we must now include the "espresso-contrib" repository.
This contains additional possibilities UI elements, to match or interact with them. For
our test we need these RecyclerViewActions:
@Test
public void test_clickFAB_itemIsAddedToListTextViewUpdated() {
onView(withId(R.id.list_fragment_floating_action_button)).perform(click());
onView(withId(R.id.list_fragment_recycler_view)).perform(RecyclerViewActions.scrollToPosition(0));
onView(withRecyclerView(R.id.list_fragment_recycler_view).atPosition(0)).check(
matches(hasDescendant(withText("Item 0"))));
onView(withId(R.id.list_fragment_title_text_view)).check(matches(withText("0/1 items selected")));
}
After clicking the FAB, you scroll to the index of the list to be checked using the
RecyclerViewAction scrollToPosition(index)
. While
this is not strictly necessary here, since the first element of the List is normally
always visible, but this way the step is not forgotten in other cases. Additionally one
should consider that the device, with which one develops, is not necessarily the same
device on which the tests must be executed.
Then, the matcher can be used to access the list element at location 0 and check whether
one of its subviews contains the correct text. Since only one element with the
corresponding text is provided here, this is sufficient.
The next step is to select a checkbox and then check whether the text above the list changes correctly:
@Test
public void test_addElementSelectElementCheckBox_shouldRefreshTextViewCorrectly() {
onView(withId(R.id.list_fragment_floating_action_button)).perform(click());
onView(withId(R.id.list_fragment_recycler_view)).perform(RecyclerViewActions.scrollToPosition(0));
onView(withId(R.id.list_fragment_recycler_view)).perform(
RecyclerViewActions.actionOnItemAtPosition(0, clickChildViewWithId(R.id.row_list_item_checkbox)));
onView(withId(R.id.list_fragment_title_text_view)).check(matches(withText("1/1 items selected")));
}
A new feature is the click on the checkbox. Since the checkbox is located in a list
element, you can use a RecyclerViewAction here again. With actionOnItemAtPosition
an index and a ViewAction can be provided:
public class ChildClickViewAction {
public static ViewAction clickChildViewWithId(final int resourceId) {
return new ViewAction() {
@Override
public Matcher getConstraints() {
return null;
}
@Override
public String getDescription() {
return "Click on a child view with specified id.";
}
@Override
public void perform(UiController uiController, View view) {
View v = view.findViewById(resourceId);
v.performClick();
}
};
}
}
In this case, the corresponding element within the given view is searched for via the
ResourceId and this view is clicked on. After clicking on the checkbox, it is checked
whether the TextView above the list has also been updated accordingly.
No new concepts are needed for the latest tests, so they are not explicitly listed here.
However, both tests can be viewed via Github.
Finally, it should be checked whether the navigation via the "Back" button in the upper
left corner works. Here we have to pay attention to a special feature: Unfortunately,
Espresso cannot access the button with the id. At least not if it was created with setHomeAsUpEnabled(true)
. Instead, a description is
used for this:
@Test
public void test_pressBackButton_shouldReturnToButtonFragment(){
onView(withContentDescription(R.string.abc_action_bar_up_description)).perform(click());
onView(withId(R.id.fragment_button_counter_text_view)).check(matches(isDisplayed()));
}
In order to click on the "Back" button, the description must first be determined. This
has a fixed resource id, i.e. this test also works language-independently. The last step
checks whether the TextView is displayed in the first screen.
Now the correct functioning of the app was almost completely ensured with UI tests.
However, one special case should still be taken into account. So far, the tests would
only run when the test device is unlocked. When the screen is off or the device is
locked, all tests fail because the activity is not found. Therefore, the device should
be unlocked and the screen should be turned on before the tests are executed. For this,
the test device must not have a lock like a PIN.
For this we need to go back into our MainActivityTestRule
class and implement the
interface ActivityLifecycleCallback
. This allows us
to implement the onActivityLifecycleChanged()
method.
@Override
public void onActivityLifecycleChanged(Activity activity, Stage stage) {
if (stage == Stage.PRE_ON_CREATE) {
activity.getWindow()
.addFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED
| WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
| WindowManager.LayoutParams.FLAG_ALLOW_LOCK_WHILE_SCREEN_ON);
}
}
Here you check if the activity is in PRE_ON_CREATE
state, that is before it is created, and try to turn on the screen and unlock the
device.
Additionally, the beforeActivityLaunched
method must
be overridden to set the listener. This way the tests will be executed even if the
screen is turned off.
@Override
protected void beforeActivityLaunched() {
super.beforeActivityLaunched();
ActivityLifecycleMonitorRegistry.getInstance().addLifecycleCallback(this);
}
In conclusion, Espresso is a valuable tool in app development for Android, and its use in
conjunction with other types of testing is highly recommended.
The entire application including all tests described here is available on GitHub.