© Denys Zelenchuk 2019
Denys ZelenchukAndroid Espresso Revealedhttps://doi.org/10.1007/978-1-4842-4315-2_2

2. Customizing Espresso for Our Needs

Denys Zelenchuk1 
(1)
Zürich, Switzerland
 

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 1, 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.

Exercise 6

Writing a Test Case with a Custom Swipe Action
  1. 1.

    Write a test case that refreshes the TO-DO list by performing the swipeDown() action on the TO-DO list view with ID R.id.tasks_list.

     
  2. 2.

    Replace the swipeDown() action from the first task with the swipeCustom() view action.

     

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();
    }
}

Exercise 7

Writing a Custom RecyclerView Action
  1. 1.

    Based on the clickTodoCheckBoxWithTitle() action, implement a RecyclerView action that verifies that the TO-DO item is not present in the list. Hint: Use one of the JUnit assert methods inside the perform() method. The final use may look like the following:

     
onView(withId(R.id.tasks_list)).perform(assertNotInTheListTodoWithTitle("title"))

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:
  • Add a new TO-DO without a title and description, and as a result, the TO-DO title field’s hint color should become red.

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();
}

Exercise 8

Applying a CustomFailureHandler to a New Test Class
  1. 1.

    Create a new test class with a test method that will fail on every run. Apply CustomFailureHandler to it.

     

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. 1.

    Identify the moment when the test fails.

     
  2. 2.

    Take a screenshot and name it appropriately.

     
  3. 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:
  • android.Manifest.permission.READ_EXTERNAL_STORAGE

  • android.Manifest.permission.WRITE_EXTERNAL_STORAGE

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.

Exercise 9

Failing One of the Tests and Observing the Screenshots
  1. 1.

    Modify one of the tests so that it will fail. Run the test. After the test runs, with the help of the adb command, start the shell session on the device or emulator and navigate to the folder that contains the screenshot.

     
  2. 2.

    Pull the screenshot taken in Step 1 from your device to your hard disk.

     

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.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.148.144.228