Android Testing

Android testing: Espresso (Part 3)

Alex Zhukovich 9 min read
Android testing: Espresso (Part 3)
Table of Contents

This is a third article from series of articles about testing Android project.

The first article about "Unit testing" you can read here.
The second article about "Mockito and Robolectric" you can read here.

Full source code you can find on Github. The project on github contains different branches (basic version of app, unit tests for basic version of app, basic version + payment activity, mockito and robolectric tests, implementation lists (ListView, RecyclerView) in an app, espresso tests for application)

What is Espresso?

The Espresso testing framework, provided by the Android Testing Support Library, provides APIs for writing UI tests to simulate user interactions within a single target app. Espresso tests can run on devices running Android 2.2 (API level 8) and higher. A key benefit of using Espresso is that it provides automatic synchronization of test actions with the UI of the app you are testing. Espresso detects when the main thread is idle, so it is able to run your test commands at the appropriate time, improving the reliability of your tests. This capability also relieves you from having to add any timing workarounds, such as a sleep period, in your test code.

The Espresso testing framework is an instrumentation-based API and works with the AndroidJUnitRunner test runner.

Prepare for using Espresso

Install / Update Android Support Libraries

First of all, you need to install/update Android Support Repository>. For it, you can use SDK Manager.

Install Android support repository

Integrate Espresso to the project

After it you must add dependency for android test. You need to update a build.gradle file for it.

dependencies {
    ...
   androidTestCompile('com.android.support.test.espresso:espresso-core:2.2.2'){
        exclude group: 'com.android.support', module: 'support-annotations'
    }

    androidTestCompile('com.android.support.test:runner:0.5'){
        exclude group: 'com.android.support', module: 'support-annotations'
    }
}

You can add exclude module to dependencies for avoiding version conflicts. Next step is adding Instrumentation runner for your configuration.

android {
    defaultConfig {
        testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
    }
}

Recommendation for setting up the device/emulator

One small recommendation for avoiding flakiness it’s turning off animation on real or virtual devices.

  • Window animation scale
  • Transition animation scale
  • Animator duration scale

You need to open "Settings -> Developer Options for changing these parameters. After it, find and turn off these animations.

Developer options - animations

Running Espresso tests

Use Android Studio

First of all, you need to create configuration for instrumentation tests. You can choose "Run/Edit Configurations ..." for it. After it, you can choose "+" icon and click to "Android Tests".

Configure android tests

Then you need to set up module for Android tests. In my case I changed Name to "EspressoTests" and Module to "app".

Configure android tests

After these changes you can find updated run menu and run your tests or an application.

Run Espresso tests

Use gradle

If you want to run instrumentation test use gradle. You can use next command
./gradlew connectedAndroidTest or ./gradlew cAT.

When this command has been finished you can find a report with test results inside your build folder:

  • {PROJECT}/{APPLICATION_MODULE}/build/reports/androidTests/connected/index.html
Test results

Writing Espresso tests

Basic Espresso components

There are general Espresso components:

  • Espresso – entry point to the Espresso framework, the Espresso class allows to interact with View (via onView or onData);
  • ViewMatchers> – a collection of matches that matches Views. You can pass one or more matchers to onView method;
  • ViewActions – a collection of common ViewActions, like click, longClick, swipeLeft, etc;
  • ViewAssertions – a collection of common ViewAssertions that can be passed to check.
Espresso.onView(ViewMatchers.withId(R.id.coffee_increment))
    .check(ViewAssertions.matches(ViewMatchers.withText(R.string.increment_label)))
    .perform(ViewActions.click());

Basic test structure

Base idea of Espresso tests are interacting with different Views. I show how you can interact with different types of view. You can find a view using ID of the view for it or as example, when we talk about TextView, Button, etc.

Interacting with Views like Button, TextView, etc

If you want to find some view by ID, you can use the withId method onView(withId(R.id.pay)).

Or you can search view by the text onView(withText("Pay")) or onView(withText(R.string.pay)).

After it, you can do some action, as example you can click.

onView(withText(R.string.pay))
    .perform(click());

Interacting with ListView

ListView is an AdapterView. That means that ListView doesn’t allow load all items initially. Using the onView(...) method with ListView for verification is not the best idea. Fortunately, Espresso provides the onData(...) method. This method allows you load all items from AdapterView before interacting with ListView.

You can find the text and you can use next source code for it.

onData(allOf(is(instanceOf(String.class)), is("text")))
    .inAdapterView(withId(R.id.textItem))
    .perform(click());

Alternatively, you can interact with an item from the certain position of ListView.

onData(anything())
    .inAdapterView(withId(R.id.listView))
    .atPosition(3)
    .perform(click());

Interacting with Spinner

Spinner is also AdapterView like the ListView. You can use onView(...) method for verifying spinner text and for performing some actions.

However, if we talk about a selecting item from Spinner you can use onData(...) method.

onData(allOf(is(instanceOf(String.class)), is("text")))
    .perform(click());

Interactive with RecyclerView

RecyclerView is not an AdapterView, it means that you can’t use onData(...) method for RecyclerView. Fortunately, Espresso supports RecyclerView. You must add an additional dependency for it.

dependencies {
    ...

    androidTestCompile('com.android.support.test.espresso:espresso-contrib:2.2') {
        exclude group: 'com.android.support', module: 'appcompat'
        exclude group: 'com.android.support', module: 'support-v4'
        exclude group: 'com.android.support', module: 'support-annotations'
        exclude module: 'recyclerview-v7'
    }
}

Now you can use RecyclerViewActions for interaction with RecyclerView.

onView(withId(R.id.recView))
    .perform(RecyclerViewActions.actionOnItemAtPosition(2, click()));

Static imports

Espresso.onView(ViewMatchers.withId(R.id.coffee_increment))
    .check(ViewAssertions.matches(ViewMatchers.withText(R.string.increment_label)))
    .perform(ViewActions.click());

As you can see from an example above, the sentence of action is very long, however you can add some static imports and this sentence will look better.

import static android.support.test.espresso.Espresso.*;
import static android.support.test.espresso.action.ViewActions.*;
import static android.support.test.espresso.assertion.ViewAssertions.*;
import static android.support.test.espresso.matcher.ViewMatchers.*;

And to update source code

onView(withId(R.id.coffee_increment))           
    .check(matches(withText(R.string.increment_label)))         
    .perform(click());

As you can see after these changes source code is more compact.

Configure custom intents

Unfortunately, some activity must get additional parameter with startIntent. As example, if we have an activity that shows the detail from previous activity.

You can create configured ActivityTestRule, unfortunately, in this case you must use this rule for each test.

First of all you must add an additional dependency.

androidTestCompile('com.android.support.test.espresso:espresso-intents:2.2') {
     exclude group: 'com.android.support', module: 'support-annotations'
}

After it we can configure intent.

@Rule
public ActivityTestRule<PaymentActivity> mActivityRule =
    new ActivityTestRule<PaymentActivity>(PaymentActivity.class) {
        @Override
        protected Intent getActivityIntent() {
            Context targetContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
            Intent result = new Intent(targetContext, PaymentActivity.class);
            result.putExtra(PaymentActivity.TOTAL_PRICE, 5.0f);
            return result;
        }
};

@Test
public void test() {
    ...
}

You can use next definition of ActivityTestRule and configure Intent for each test.

@Rule
public ActivityTestRule<PaymentActivity> mActivityRule =
          new ActivityTestRule<>(PaymentActivity.class, true, false); 
@Test
public void test() {
    Context targetContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
    Intent intent = new Intent(targetContext, MainActivity.class);
    intent.putExtra(PaymentActivity.TOTAL_PRICE, 5.0f);
    mActivityRule.launchActivity(intent);

    ...

}

Testing asynchronous code

One of key feature of Espresso is the synchronization between the test and an application.

By default, Espresso synchronize all view operations with the UI thread as well as AsyncTask. Espresso also allows use custom resources in tests. However, if your application performs long-running operations Espresso doesn't know anything about it. It can be a problem for some tests, because you can get some data from the internet.

I would like to show you how to create custom Idling Resource and use it for your tests. Before implementing custom Idling Resource class you must know some major information.

public class ServiceIdlingResource implements IdlingResource {
    private final Context mContext;
    private ResourceCallback mCallback;

    public ServiceIdlingResource(Context context) {
        this.mContext = context;
    }

    @Override
    public String getName() {
        return ServiceIdlingResource.class.getSimpleName();
    }

    @Override
    public boolean isIdleNow() {
        boolean idle = !isIntentServiceRunning();
        if (idle &amp;&amp; mCallback != null) {
            mCallback.onTransitionToIdle();
        }
        return idle;
    }

    @Override
    public void registerIdleTransitionCallback(ResourceCallback callback) {
        this.mCallback = callback;
    }

    private boolean isIntentServiceRunning() {
        ActivityManager manager = (ActivityManager) mContext.getSystemService(Context.ACTIVITY_SERVICE);
        for (ActivityManager.RunningServiceInfo info : manager.getRunningServices(Integer.MAX_VALUE)) {
            if (CoffeeService.class.getName().equals(info.service.getClassName())) {
                return true;
            }
        }
        return false;
    }
}

The IdlingResource interface defines three methods:

  • public String getName()
  • public boolean isIdleNow()
  • public void registerIdleTransitionCallback(ResourceCallback callback)

The getName method returns string that identifies this Idling Resource. Of course, this string must be not a null.
The isIdleNow method returns the current state of the Idle Resource.
The registerIdleTransitionCallback method stores information about a callback that notifies changes of Idle Resource state.

In this small example, I use next simple service:

import android.app.IntentService;
import android.content.Intent;
import android.os.SystemClock;
import android.support.v4.content.LocalBroadcastManager;

import java.util.ArrayList;

public class CoffeeService extends IntentService {
    public final static String INTENT_GET_DATA = "com.alexzh.simplecoffeeorder.GET_DATA";
    public final static String INTENT_DATA = "data";

    public CoffeeService() {
        super("CoffeeService");
    }

    @Override
    protected void onHandleIntent(Intent intent) {
        SystemClock.sleep(3000);

        Intent replyIntent = new Intent(INTENT_GET_DATA);
        replyIntent.putExtra(INTENT_DATA, getCoffeeData());

        LocalBroadcastManager manager = LocalBroadcastManager.getInstance(this);
        manager.sendBroadcast(replyIntent);
    }

   

    public final static ArrayList&lt;Coffee&gt; getCoffeeData() {
        ArrayList&lt;Coffee&gt; list = new ArrayList&lt;&gt;();
        list.add(new Coffee("Espresso", 5.0f));
        list.add(new Coffee("Americano", 4.5f));
        list.add(new Coffee("Latte", 5.5f));
        list.add(new Coffee("Mocha", 7.5f));
        list.add(new Coffee("Cappuccino", 9.5f));
        list.add(new Coffee("Vacuum coffee", 12.0f));
        list.add(new Coffee("Café Cubano", 9.0f));
        list.add(new Coffee("Cafe Zorro", 6.0f));
        list.add(new Coffee("Guillermo", 3.0f));
        return list;
    }
}

The application shows the list of different coffees with prices.

Coffee order app

First of all we want to click to view inside the item, better way create custom action for it.

public static ViewAction clickByChildViewWithId(final int id) {
    return new ViewAction() {
        @Override
        public Matcher<View> getConstraints() {
            return null;
        }

        @Override
        public String getDescription() {
            return "Click on a child of a view with id.";
        }

        @Override
        public void perform(UiController uiController, View view) {
            View v = view.findViewById(id);
            if (v != null) {
                v.performClick();
            }
        }
    };
}

Secondly we want to check text on view inside the item, we can create additional action for it.

public static ViewAction checkTextViewByChildViewWithId(final int id, final String text) {
    return new ViewAction() {
        @Override
        public Matcher<View> getConstraints() {
            return null;
        }

        @Override
        public String getDescription() {
            return "Check a child of a view with id.";
        }

        @Override
        public void perform(UiController uiController, View view) {
            TextView v = (TextView) view.findViewById(id);
            if (v != null) {
                assertEquals(text, v.getText().toString());
           }
        }
    };
}

I also created two two simple methods for more convenient work with these actions.

clickToViewChildItem(int recyclerViewId, String item, int childId) {
    onView(withId(recyclerViewId))
        .perform(RecyclerViewActions.actionOnItem(
             hasDescendant(withText(item)),
             RecyclerChildViewActions.clickByChildViewWithId(childId)));
}

public static void checkTextViewCountForCoffee(int recyclerViewId, int childId, String coffee, String count) {
    onView(withId(recyclerViewId))
        .perform(RecyclerViewActions.actionOnItem(
             hasDescendant(withText(coffee)),
             RecyclerChildViewActions.checkTextViewByChildViewWithId(childId, count)));
}

Finally, I want to show simple espresso test for checking this activity

@RunWith(AndroidJUnit4.class)
public class MainActivityTest {
    private List<Coffee> mCoffeeList;
    private ServiceIdlingResource mServiceIdlingResource;

    @Rule
    public ActivityTestRule<MainActivity> mActivityRule =
            new ActivityTestRule<>(MainActivity.class);

    @Before
    public void setup() {
        mCoffeeList = CoffeeService.getCoffeeData();

        mServiceIdlingResource =
                new ServiceIdlingResource(mActivityRule.getActivity().getApplicationContext());
        registerIdlingResources(mServiceIdlingResource);
    }

    @Test
    public void shouldDisplayCoffeeOrderList() {
        onView(withId(R.id.recyclerView))
            .perform(RecyclerViewActions.scrollToPosition(mCoffeeList.size() - 1));

        for (Coffee coffee : mCoffeeList) {
            onView(withId(R.id.recyclerView))
                .perform(RecyclerViewActions.actionOnItem(
                    hasDescendant(withText(coffee.getName())),
                    checkTextViewByChildViewWithId(R.id.coffee_count, "0")
                ));
        }
    }

    @Test
    public void shouldOrderEspressoAndCafeZorro() {
        int coffeeCount = 0;
        float totalCoffeePrice = 0.0f;
        final String espresso = mCoffeeList.get(0).getName();
        final float espressoPrice = mCoffeeList.get(0).getPrice();

        final String coffeeZoro = mCoffeeList.get(7).getName();
        final float coffeeZoroPrice = mCoffeeList.get(7).getPrice();

        totalCoffeePrice += espressoPrice;
        changeToCoffeeOrderCountAndVerify(espresso, ++coffeeCount, totalCoffeePrice, R.id.coffee_increment);

        totalCoffeePrice += espressoPrice;
        changeToCoffeeOrderCountAndVerify(espresso, ++coffeeCount, totalCoffeePrice, R.id.coffee_increment);

        totalCoffeePrice -= espressoPrice;
        changeToCoffeeOrderCountAndVerify(espresso, --coffeeCount, totalCoffeePrice, R.id.coffee_decrement);

        totalCoffeePrice -= espressoPrice;
        changeToCoffeeOrderCountAndVerify(espresso, --coffeeCount, totalCoffeePrice, R.id.coffee_decrement);

        totalCoffeePrice += coffeeZoroPrice;
        changeToCoffeeOrderCountAndVerify(coffeeZoro, ++coffeeCount, totalCoffeePrice, R.id.coffee_increment);
    }

    @After
    public void tearDown() {
        unregisterIdlingResources(mServiceIdlingResource);
    }

    private void changeToCoffeeOrderCountAndVerify(String coffeeName, int coffeeCount, float totalCoffeePrice, int clickedViewId) {
        clickToViewChildItem(R.id.recyclerView, coffeeName, clickedViewId);
        checkTextViewCountForCoffee(R.id.recyclerView, R.id.coffee_count, coffeeName, String.valueOf(coffeeCount));
        onView(withId(R.id.total_price_toolbar)).check(matches(withText(getString(mActivityRule, R.string.price, totalCoffeePrice))));
    }
}

You can find more espresso tests here.

GitHub: Project with tests.

Resources:

Thank you for your time.
Have a nice day.


Mobile development with Alex

A blog about Android development & testing, Best Practices, Tips and Tricks

Share
More from Mobile development with Alex

Great! You’ve successfully signed up.

Welcome back! You've successfully signed in.

You've successfully subscribed to Mobile development with Alex.

Success! Check your email for magic link to sign-in.

Success! Your billing info has been updated.

Your billing was not updated.