Espresso is a really good testing framework, but it is not possible to cover all the test automation cases with a predefined set of methods and classes. In the same way that Android’s fundamental components can be customized during application development, Espresso enables us to customize its components. Engineers are free do create their own actions, matchers, and failure handlers and plug them into the tests. In this chapter, we learn how to create our custom view, swipe, and recycler view actions; understand how to build different types of matchers; handle test failures in a customized way, and take the proper screenshots on failure.
Writing Custom ViewActions
ViewActions are one of the most commonly used Espresso
functionalities. Espresso provides a big list of them, but we need more just because they may not suit our specific needs. In my practice, most of the time, the following view action types require customization:
Swipe actions
Recycler view actions
ViewActions
We also discuss examples of customizing a simple click action for specific cases in this chapter.
Adapting Espresso Swipe Actions
In Chapter
, we mentioned four
swipe actions that Espresso provides—
swipeUp(),
swipeDown(),
swipeLeft(), and
swipeRight(). This is how the
swipeUp() action
is implemented:
public static ViewAction swipeUp() {
return actionWithAssertions(new GeneralSwipeAction(Swipe.FAST,
GeneralLocation.translate(GeneralLocation.BOTTOM_CENTER, 0, -EDGE_FUZZ_FACTOR),
GeneralLocation.TOP_CENTER, Press.FINGER));
}
As you may guess, GeneralLocation.BOTTOM_CENTER and GeneralLocation.TOP_CENTER represent the from and to coordinates inside the view we would like to swipe. The full positions list, which can be used as from and to coordinates, are TOP_LEFT, TOP_CENTER, TOP_RIGHT, CENTER_LEFT, CENTER, CENTER_RIGHT, BOTTOM_LEFT, and BOTTOM_CENTER, BOTTOM_RIGHT.
Swipe.FAST represents the length of time a “fast” swipe should last, in milliseconds. For now, Swipe has FAST (100 milliseconds) and SLOW (1500 milliseconds) swipe speeds.
The Press.FINGER returns a touch target with the size 16x16 mm. Other press options are PINPOINT 1x1 mm and THUMB 25x25 mm press areas.
The -EDGE_FUZZ_FACTOR value defines the distance from the edge to the swipe action’s starting point in terms of the view’s length. This is helpful when swiping from the exact edge can lead to undesired behavior—for example, opening the navigation drawer.
The other three swipe actions happen in a similar way, with the difference only in the from and to coordinates.
There may be cases when these four swipe actions are not enough. You may need swiping left or right slowly or swiping up or down from the middle of the screen. In such cases, you can create your own custom swipe action.
To implement our own action, we will follow the approach of how Espresso swipe actions like swipeDown()
are implemented. First, we add our own CustomSwipe type and call it CUSTOM. This enum class should implement the Espresso Swiper interface like Swipe enum does, where the FAST and SLOW swiping types are declared.
chapter2.customswipe.CustomSwipe.java.
public enum CustomSwipe implements Swiper {
CUSTOM{
@Override
public Status sendSwipe(UiController uiController,
float[] startCoordinates,
float[] endCoordinates,
float[] precision) {
return sendLinearSwipe(
uiController,
startCoordinates,
endCoordinates,
precision,
swipeCustomDuration);
}
};
/** The number of motion events to send for each swipe. */
private static final int SWIPE_EVENT_COUNT = 10;
/** The duration of a swipe */
private static int swipeCustomDuration = 0;
/**
* Setting duration to our custom swipe action
* @param duration length of time a custom swipe should last for in milliseconds.
*/
public void setSwipeDuration(int duration) {
swipeCustomDuration = duration;
}
private static Swiper.Status sendLinearSwipe(UiController uiController,
float[] startCoordinates,
float[] endCoordinates,
float[] precision,
int duration) {
...
}
private static float[][] interpolate(float[] start, float[] end, int steps) {
...
return res;
}
}
In our implementation, we can control the swipe duration by setting it in the setSwipeDuration() method, which modifies the swipeCustomDuration static variable. We also have to paste the interpolate() and sendLinearSwipe() methods from the Espresso Swipe enum because they are not public. The full source code is available in the chapter2.customswipe.CustomSwipe.java class.
So, at this moment, we already have a fully customizable swipe type. Now we add the swipeCustom() view action.
chapter2.customactions.CustomSwipeActions.java.
public class CustomSwipeActions {
/**
* Fully customizable Swipe action for any need
* @param duration length of time a custom swipe should last for, in milliseconds.
* @param from for example [GeneralLocation.CENTER]
* @param to for example [GeneralLocation.BOTTOM_CENTER]
*/
public ViewAction swipeCustom(int duration, GeneralLocation from, GeneralLocation to) {
CustomSwipe.CUSTOM.setSwipeDuration(duration);
return actionWithAssertions(new GeneralSwipeAction(
CustomSwipe.CUSTOM,
translate(from, 0f, 0f),
to, Press.FINGER)
);
}
/**
* Translates the given coordinates by the given distances.
* The distances are given in term of the view's size
* -- 1.0 means to translate by an amount equivalent
* to the view's length.
*/
private static CoordinatesProvider translate(final CoordinatesProvider coords, final float dx, final float dy) {
return new CoordinatesProvider() {
@Override
public float[] calculateCoordinates(View view) {
float xy[] = coords.calculateCoordinates(view);
xy[0] += dx * view.getWidth();
xy[1] += dy * view.getHeight();
return xy;
}
};
}
}
The swipeCustom() method
first sets the swipe duration and then performs GeneralSwipeAction with our CUSTOM swipe type. Again, we have to paste the translate() method from inside the GeneralSwipeAction class, as it cannot be accessed from outside of the class.
Creating Custom RecyclerView Actions
The RecyclerViewActions
class provides a limited amount of actions that can be used inside a recycler view or recycler view item. For example, clicking on the whole TO-DO item in the TO-DO recycler view is useful and can be used to open item details. But what if we need to click on the checkbox to mark a TO-DO item as done. Of course, we can do this based on position. As an engineer who owns the test data, I have the full control over each TO-DO name and I can make all the names unique. This enables me to identify each TO-DO item based on its name and then narrow down the focus to the specific element inside the TO-DO item. In our case, we want to click on the checkbox. Take a look at how this custom recycler view action may look on the clickTodoCheckBoxWithTitle() method from the CustomRecyclerViewActions.java class.
chapter2.customactions.CustomRecyclerViewActions.java.
class ClickTodoCheckBoxWithTitleViewAction implements CustomRecyclerViewActions {
private String toDoTitle;
public ClickTodoCheckBoxWithTitleViewAction(String toDoTitle) {
this.toDoTitle = toDoTitle;
}
public static ViewAction clickTodoCheckBoxWithTitle(final String toDoTitle) {
return actionWithAssertions(new ClickTodoCheckBoxWithTitleViewAction(toDoTitle));
}
@Override
public Matcher<View> getConstraints() {
return allOf(isAssignableFrom(RecyclerView.class), isDisplayed());
}
@Override
public String getDescription() {
return "Completes the task by clicking its checkbox.";
}
@Override
public void perform(UiController uiController, View view) {
try {
RecyclerView recyclerView = (RecyclerView) view;
RecyclerView.Adapter adapter = recyclerView.getAdapter();
if (adapter instanceof TasksFragment.TasksAdapter) {
int itemCount = adapter.getItemCount();
for (int i = 0; i < itemCount; i++) {
View taskItemView = recyclerView.getLayoutManager().findViewByPosition(i);
TextView textView = taskItemView.findViewById(R.id.title);
if (textView != null && textView.getText() != null) {
if (textView.getText().toString().equals(toDoTitle)) {
CheckBox completeCheckBox = taskItemView.findViewById(R.id.todo_complete);
completeCheckBox.performClick();
}
} else {
throw new RuntimeException(
"Unable to find view with ID R.id.todo_title as child of TO-DO item at position " + i);
}
}
}
uiController.loopMainThreadForAtLeast(ViewConfiguration.getTapTimeout());
} catch (RuntimeException e) {
throw new PerformException.Builder().withActionDescription(this.getDescription())
.withViewDescription(HumanReadables.describe(view)).withCause(e).build();
}
}
}
The
clickTodoCheckBoxWithTitle() view
action returns a new
ClickTodoCheckBoxWithTitleViewAction class where the
getConstraints() method filters out views that are assignable from the
RecyclerView.class and are visible on the screen:
public Matcher<View> getConstraints() {
return allOf(isAssignableFrom(RecyclerView.class), isDisplayed())
The
getDescription() method
describes our
ViewAction. This is what you will see if the test fails in the Espresso exception trace.
public String getDescription() {
return "Completes the task by clicking its checkbox.";
}
The perform() method
is doing the heavy work here—we already can rely on the fact that our view is RecyclerView. Then we get the adapter from it and ensure that the adapter is an instance of the TasksFragment.TasksAdapter class. After that, we iterate through each item inside the adapter and fetch an item title from TextView with an ID of R.id.title. If the item’s title is equal to the title from TaskItem, we search for the CheckBox element with a R.id.todo_complete ID and call a click action on it. In the end, we loop the main thread for a short period of time to let the application handle our tap event. If a TO-DO with the expected title doesn’t exist in the list, it will throw an exception with the help of Espresso’s PerformException class.
chapter2.customactions.CustomRecyclerViewActions.java.
public void perform(UiController uiController, View view) {
try {
RecyclerView recyclerView = (RecyclerView) view;
RecyclerView.Adapter adapter = recyclerView.getAdapter();
if (adapter instanceof TasksFragment.TasksAdapter) {
int itemCount = adapter.getItemCount();
for (int i = 0; i < itemCount; i++) {
View taskItemView = recyclerView.getLayoutManager().findViewByPosition(i);
TextView textView = taskItemView.findViewById(R.id.title);
if (textView != null && textView.getText() != null) {
if (textView.getText().toString().equals(toDoTitle)) {
CheckBox completeCheckBox = taskItemView.findViewById(R.id.todo_complete);
completeCheckBox.performClick();
}
} else {
throw new RuntimeException(
"Unable to find TO-DO item with title " + toDoTitle);
}
}
}
uiController.loopMainThreadForAtLeast(ViewConfiguration.getTapTimeout());
} catch (RuntimeException e) {
throw new PerformException.Builder().withActionDescription(this.getDescription())
.withViewDescription(HumanReadables.describe(view)).withCause(e).build();
}
}
Another example of
RecyclerViewAction is shown in the same
CustomRecyclerViewActions.java class inside the
scrollToLastHolder() method and it explains how to implement the scroll action on
RecyclerView. We will not discuss the
getConstraints() and getDescription() methods since they are the same. As for the
perform() method, you can see that it retrieves the items count from the
RecyclerView adapter and scrolls to the last item using the
scrollToPosition() RecyclerView method:
public void perform(UiController uiController, View view) {
RecyclerView recyclerView = (RecyclerView) view;
int itemCount = recyclerView.getAdapter().getItemCount();
try {
recyclerView.scrollToPosition(itemCount - 1);
uiController.loopMainThreadUntilIdle();
} catch (RuntimeException e) {
throw new PerformException.Builder().withActionDescription(this.getDescription())
.withViewDescription(HumanReadables.describe(view)).withCause(e).build();
}
}
Writing Custom Matchers
Espresso matchers are powerful tools that help locate or validate elements in the application layout. Espresso view matchers may not fully fit your use cases or needs. In that case, you can create custom matchers.
Creating Custom Matchers for Simple UI Elements
We will start using the simple
matchers as an introduction. The following use case will be used as an example:
In this case, BoundedMatcher is the perfect candidate since it returns the Matcher<View> type but will operate only on elements with EditText type. Refer to the CustomViewMatchers.java class, which contains the withHintColor() matcher implementation that matches the color of the EditText hint.
chapter2.custommatchers.CustomViewMatchers.java.
public static Matcher<View> withHintColor(final int expectedColor) {
return new BoundedMatcher<View, EditText>(EditText.class) {
@Override
protected boolean matchesSafely(EditText editText) {
return expectedColor == editText.getCurrentHintTextColor();
}
@Override
public void describeTo(Description description) {
description.appendText("with TO-DO title: " + expectedColor);
}
};
}
Here, BoundedMatcher enables us to match the EditText view that’s the subtype of the Android View type and return to the end object of the Matcher<View> type. When the EditText element is identified on the screen, its hint color is compared to the expected color, returning a true or false value. Whenever a true value is returned, it means that EditText with the expected hint color was found.
Here is how the usage of the withHintColor() matcher looks in a real test case (refer to the CustomViewMatchers.java class for more details).
chapter2
.custommatchers.CustomViewMatchersTest.java.
@Test
public void addsNewToDoError() {
// adding new TO-DO
onView(withId(R.id.fab_add_task)).perform(click());
onView(withId(R.id.fab_edit_task_done)).perform(click());
onView(withId(R.id.add_task_title))
.check(matches(hasErrorText("Title cannot be empty!")))
.check(matches(withHintColor(Color.RED)));
}
Implementing Custom RecyclerView Matchers
From my point of view, the RecyclerView matchers
are the most hidden part in Espresso. The Android documentation does not explain how to implement them but, based on the past examples from this book, you may guess that the BoundedMatcher class can be used to create them.
We will refer to our sample application and create the RecyclerView matcher that matches the TO-DO item in the TO-DO list based on its title. Again, the title is assumed to be unique since we have the full control over the test data.
chapter2.custommatchers.RecyclerViewMatchers.java.
public static Matcher<RecyclerView.ViewHolder> withTitle(final String taskTitle) {
Checks.checkNotNull(taskTitle);
return new BoundedMatcher<RecyclerView.ViewHolder, TasksFragment.TasksAdapter.ViewHolder>(
TasksAdapter.ViewHolder.class) {
@Override
protected boolean matchesSafely(TasksAdapter.ViewHolder holder) {
final String holderTaskTitle = holder.getHolderTask().getTitle();
return taskTitle.equals(holderTaskTitle);
}
@Override
public void describeTo(Description description) {
description.appendText("with task title: " + taskTitle);
}
};
}
Here it is important to understand the application under test and know which ViewHolder to use. In the sample, we put TasksFragment.TasksAdapter.ViewHolder as the second parameter into BoundedMatcher. Whenever our matcher identifies elements on the screen with the type, we retrieve the title from the holder and compare it to the title we provided as a matcher parameter.
chapter2.custommatchers.RecyclerViewMatchers.java.
public static Matcher<RecyclerView.ViewHolder> withTask(final TaskItem taskItem) {
Checks.checkNotNull(taskItem);
return new BoundedMatcher<RecyclerView.ViewHolder, TasksFragment.TasksAdapter.ViewHolder>(
TasksAdapter.ViewHolder.class) {
@Override
protected boolean matchesSafely(TasksAdapter.ViewHolder holder) {
final String holderTaskTitle = holder.getHolderTask().getTitle();
final String holderTaskDesc = holder.getHolderTask().getDescription();
return taskItem.getTitle().equals(holderTaskTitle)
&& taskItem.getDescription().equals(holderTaskDesc);
}
@Override
public void describeTo(Description description) {
description.appendText("task with title: " + taskItem.getTitle()
+ " and description: " + taskItem.getDescription());
}
};
}
public static Matcher<RecyclerView.ViewHolder> withTaskTitleFromTextView(final String taskTitle) {
Checks.checkNotNull(taskTitle);
return new BoundedMatcher<RecyclerView.ViewHolder, TasksFragment.TasksAdapter.ViewHolder>(
TasksAdapter.ViewHolder.class) {
@Override
protected boolean matchesSafely(TasksAdapter.ViewHolder holder) {
final TextView titleTextView = (TextView) holder.itemView.findViewById(R.id.title);
return taskTitle.equals(titleTextView.getText().toString());
}
@Override
public void describeTo(Description description) {
description.appendText("with task title: " + taskTitle);
}
};
}
}
Handling Errors with a Custom FailureHandler
The Espresso testing framework is very flexible and customizable, and error handling is no exception. Espresso provides an interface called FailureHandler that can be implemented in a custom failure handler to manage failures that happen during test execution.
The reason to implement a custom FailureHandler may be to reduce the exception text or to save on screenshots or other application data, such as saving device dumps, etc.
As an example, the sample TO-DO application codebase contains a CustomFailureHandler.
chapter2.customfailurehandler.CustomFailureHandler.java.
public class CustomFailureHandler implements FailureHandler{
private final FailureHandler delegate;
public CustomFailureHandler(Context targetContext) {
delegate = new DefaultFailureHandler(targetContext);
}
@Override
public void handle(Throwable error, Matcher<View> viewMatcher) {
try {
delegate.handle(error, viewMatcher);
} catch (NoMatchingViewException e) {
// For example save device dump, take screenshot, etc.
throw e;
}
}
}
You can see the try...catch block in the handle() method. That’s where we catch the error and can do whatever we want with it. Usually the exception is propagated further after all needed steps are complete.
To let Espresso intercept each test failure with a
CustomFailureHandler, it is important to register it inside the test class or inside the base test
class, as shown in the
BaseTest.java class:
@Before
public void setUp() throws Exception {
setFailureHandler(new CustomFailureHandler(
InstrumentationRegistry.getInstrumentation().getTargetContext()));
}
If you register it in a base test class, don’t forget to call
super.setUp() from inside your test class:
@Before
public void setUp() throws Exception {
super.setUp();
}
Taking and Saving Screenshots Upon Test Failure
Running tests is important, but it is also important to get proper and descriptive test results, especially when you have a test failure, so they can be easily analyzed. The JUnit reporter that is used by AndroidJUnitRunner reports test results in old, simple raw text format. Engineers then have to adapt it to their needs. Of course, one of those needs is to create a screenshot when a test fails. There are many third-party libraries and tools that can take screenshots upon test failure. A good example is Spoon from Square. But here we will talk about the native solution that comes with JUnit and Espresso.
Let’s identify what we want to achieve in the test run flow:
- 1.
Identify the moment when the test fails.
- 2.
Take a screenshot and name it appropriately.
- 3.
Save the screenshot on the given device or emulator.
The JUnit Library starting with version 4.9 provides a
TestWatcher mechanism that allows us to monitor and log passing and failing tests. It is an abstract class that extends
TestRule and enables us to react to the following test states:
succeeded(Description description)—Invoked when a test succeeds.
failed(Throwable e, Description description)—Invoked when a test fails.
skipped(AssumptionViolatedException e, Description description)—Invoked when a test is skipped due to a failed assumption.
starting(Description description)—Invoked when a test is about to start.
finished(Description description)—Invoked when a test method finishes (whether passing or failing).
Here we are interested in the failed() method, which we will override the BaseTest class (however, other methods can be also helpful in many cases). This addresses our first point (identify the moment when the test fails).
The Android Testing support library provides the
Screenshot and
ScreenshotCapture classes, which capture the screenshot in bitmap format during instrumentation tests on an Android device or an emulator:
private void captureScreenshot(final String name) throws IOException {
ScreenCapture capture = Screenshot.capture();
capture.setFormat(Bitmap.CompressFormat.PNG);
capture.setName(name);
capture.process();
}
As to the screenshot name, we need help from the
TestName() JUnit rule available from JUnit version 4.7. The
TestName rule makes the current test name available from inside the test. It returns the currently-running test method name via the
getMethodName() function:
@Rule
public TestName testName = new TestName();
The second point has also been addressed (take a screenshot and name it appropriately).
Actually, it’s almost solved since we need the following permissions to be granted in order to let the
Screenshot class save screenshots to an external storage location:
Luckily, the Android Testing support library provides
GrantPermissionRule to do this at runtime. The only limitation is that it can be used only from Android M (API level 23):
@Rule
public GrantPermissionRule mRuntimePermissionRule = GrantPermissionRule
.grant(android.Manifest.permission.WRITE_EXTERNAL_STORAGE,
android.Manifest.permission.READ_EXTERNAL_STORAGE);
At this moment, all three points have been addressed (the final one being to save the screenshot on a given device or emulator), and this is how it looks in the BaseTest.class.
com.example.android.architecture.blueprints.todoapp.test.BaseTest.java.
@RunWith(AndroidJUnit4.class)
public class BaseTest {
@Rule
public GrantPermissionRule mRuntimePermissionRule = GrantPermissionRule
.grant(android.Manifest.permission.WRITE_EXTERNAL_STORAGE,
android.Manifest.permission.READ_EXTERNAL_STORAGE);
@Rule
public TestName testName = new TestName();
public class ScreenshotWatcher extends TestWatcher {
@Override
protected void succeeded(Description description) {
// all good, tell everyone
}
@Override
protected void failed(Throwable e, Description desc) {
try {
captureScreenshot(testName.getMethodName());
} catch (IOException e1) {
e1.printStackTrace();
}
}
private void captureScreenshot(final String name) throws IOException {
ScreenCapture capture = Screenshot.capture();
capture.setFormat(Bitmap.CompressFormat.PNG);
capture.setName(name);
capture.process();
}
}
}
One last note—screenshots will be saved in the sdcard/Pictures/screenshots directory. On Android emulator, it is /storage/emulated/0/Pictures/screenshots.
Summary
As you can see, Espresso for Android is a flexible and customizable framework that allows us to create custom classes and methods to meet specific testing needs. There are, of course, some limitations, such as the missing RecyclerView matchers. These limitations can be mitigated by using a custom ViewAction. Creating custom ViewActions, ViewMatchers, and other methods and classes is essential knowledge, sometimes even a must-have for an experienced Espresso user. In addition to that, you can fully customize UI error handling and perform desired actions on each test error.