Testing a sorted list with Espresso

Espresso is a pretty powerful tool when it comes to writing acceptance tests for Android. Acceptance tests are the ones that make sure your entire feature (or a certain aspect of the feature) is implemented correctly. The advantage of automating acceptance tests is the simplicity of catching regressions, which usually occur during active development or bug fixing. When you have automated tests, it all comes down to just running them to find out if you broke something by a recent change. Awesome!

In this post I'll show you how to setup Espresso in your Android project, and will write a simple acceptance test, that checks that a list of Premier League teams is sorted alphabetically. Brew yourself a cup of espresso and fasten your seatbelt!

Espresso setup

It's pretty easy to configure Espresso if you're using Android Studio and Gradle. Just open the build.gradle file inside the app module and add the following dependencies:

def APPCOMPAT_VERSION = "23.1.1"  
def ESPRESSO_RUNNER_VERSION = "0.4.1"

dependencies {  
    // dependencies with "compile" scope go here

    androidTestCompile "com.android.support:support-annotations:${APPCOMPAT_VERSION}"
    androidTestCompile 'com.android.support.test.espresso:espresso-core:2.2.1'
    androidTestCompile "com.android.support.test:runner:${ESPRESSO_RUNNER_VERSION}"
    androidTestCompile "com.android.support.test:rules:${ESPRESSO_RUNNER_VERSION}"
}

By the way, the complete source code for this project is available on GitHub, so feel free to checkout.

Sync your project with Gradle and sip some coffee while Gradle runs the build. One last detail to finalize the setup is to add the following line to build.gradle under defaultConfig closure:

defaultConfig {  
    // default setup here

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

And we're done! Let's continue with writing an acceptance test with Espresso.

Writing an acceptance test

You'll notice the androidTest directory under the src: this is where the Espresso tests usually live. Go ahead and create a class called TeamsActivityTest. Add a couple of annotations to make your class look like that:

@RunWith(AndroidJUnit4.class)
@LargeTest
public class TeamsActivityTest {  
}

We declare that we'll be using JUnit version 4 to write our tests. The @LargeTest annotation serves as a hint to the test runner regarding what kind of tests our class contains, and is usually used with Espresso tests. Next, we'll add the following field to the test class:

@Rule public ActivityTestRule<TeamsActivity> activityTestRule = 
        new ActivityTestRule<>(TeamsActivity.class);

Until JUnit 4 support was introduced, Android test classes were usually extending the ActivityInstrumentationTestCase2 class. With JUnit 4 it's enough to add a field of type ActivityTestRule, annotated with @Rule, to describe how the Activity under test should be started. Check the overloaded versions of ActivityTestRule constructor to see which tweaks can be made to the test startup.

With our test class set, let's proceed straight to implementing our test case:

@Test 
public void teamsListIsSortedAlphabetically() {  
    onView(withId(android.R.id.list)).check(matches(isSortedAlphabetically()));
}

onView(), withId() and matches() are all static methods inside the framework, so I prefer to use static imports to make the test definitions look clean and concise. Check the sample code on GitHub for the correct imports.

isSortedAlphabetically() though is a custom Hamcrest matcher, that describes the condition that we want to check on our View, namely, that the contents of the android.R.id.list are sorted in alphabetical order. Here's how we define the matcher:

private static Matcher<View> isSortedAlphabetically() {  
    return new TypeSafeMatcher<View>() {

        private final List<String> teamNames = new ArrayList<>();

        @Override
        protected boolean matchesSafely(View item) {
            RecyclerView recyclerView = (RecyclerView) item;
            TeamsAdapter teamsAdapter = (TeamsAdapter) recyclerView.getAdapter();
            teamNames.clear();
            teamNames.addAll(extractTeamNames(teamsAdapter.getTeams()));
            return Ordering.natural().isOrdered(teamNames);
        }

        private List<String> extractTeamNames(List<Team> teams) {
            List<String> teamNames = new ArrayList<>();
            for (Team team : teams) {
                teamNames.add(team.name);
            }
            return teamNames;
        }

        @Override
        public void describeTo(Description description) {
            description.appendText("has items sorted alphabetically: " + teamNames);
        }
    };
}

We know for sure that we're using a RecyclerView, so we can safely cast the parameter of matchesSafely() and extract the TeamsAdapter to get to the data. We'll extractNames() from the list of Team objects, and then use Guava's Ordering class to check that the list is properly sorted. When writing Hamcrest matchers, don't overlook the describeTo() method, as it can prove very useful when your test fails. In our describeTo() we'll shortly describe what the matcher is doing and will also print the data that we stored locally: now when our test fails we'll know exactly how the dataset looked and can draw conclusions on why the condition has failed.

Now you may wonder where do things like Team and TeamAdapter (or even RecyclerView that we haven't integrated yet) come from. Writing tests that don't even compile is actually pretty fine with Test Driven Development, that introduces the "red-green-refactor" cycle: write your tests, make them compile and pass, refactor to remove duplication. We're currently "red", so let's work our way to "green" by writing some code.

First, integrate the RecyclerView by adding the following dependency to the app/build.gradle file:

dependencies {  
    // other "compile" dependencies go here
    compile "com.android.support:recyclerview-v7:${APPCOMPAT_VERSION}"

    // "androidTest" dependencies are here
}

In case you already have a MainActivity in your project, rename it to TeamsActivity, otherwise create it from scratch. TeamsActivity will use the following layout. Team is our POJO and it looks like this:

public class Team {

    public final String name;
    public final @DrawableRes int logoRes;

    public Team(@NonNull String name, @DrawableRes int logoRes) {
        this.name = name;
        this.logoRes = logoRes;
    }

    public static final Comparator<Team> BY_NAME_ALPHABETICAL = new Comparator<Team>() {
        @Override public int compare(Team lhs, Team rhs) {
            return lhs.name.compareTo(rhs.name);
        }
    };
}

Notice the BY_NAME_ALPHABETICAL Comparator - that's the one we'll use to sort the teams as required.

Next is the TeamAdapter class, which is pretty straightforward:

public class TeamsAdapter extends RecyclerView.Adapter<TeamsAdapter.ViewHolder> {

    private final LayoutInflater layoutInflater;

    private final List<Team> teams;

    public TeamsAdapter(LayoutInflater layoutInflater) {
        this.layoutInflater = layoutInflater;
        this.teams = new ArrayList<>();
    }

    @Override
    public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
        return new ViewHolder(layoutInflater.inflate(R.layout.row_team, parent, false));
    }

    @Override
    public void onBindViewHolder(ViewHolder holder, int position) {
        Team team = teams.get(position);
        holder.teamLogo.setImageResource(team.logoRes);
        holder.teamName.setText(team.name);
    }

    @Override public int getItemCount() {
        return teams.size();
    }

    public void setTeams(List<Team> teams) {
        this.teams.clear();
        this.teams.addAll(teams);
        notifyItemRangeInserted(0, teams.size());
    }

    public List<Team> getTeams() {
        return Collections.unmodifiableList(teams);
    }

    static class ViewHolder extends RecyclerView.ViewHolder {

        ImageView teamLogo;
        TextView teamName;

        public ViewHolder(View itemView) {
            super(itemView);
            teamLogo = (ImageView) itemView.findViewById(R.id.team_logo);
            teamName = (TextView) itemView.findViewById(R.id.team_name);
        }
    }
}

The row_team layout can be found here. Now let's add the code, that will create Team objects and initialize our TeamAdapter, to TeamActivity:

@Override
protected void onCreate(Bundle savedInstanceState) {  
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);

    RecyclerView teamsRecyclerView = (RecyclerView) findViewById(android.R.id.list);
    teamsRecyclerView.setLayoutManager(new LinearLayoutManager(this));

    TeamsAdapter teamsAdapter = new TeamsAdapter(LayoutInflater.from(this));
    teamsAdapter.setTeams(createTeams());
    teamsRecyclerView.setAdapter(teamsAdapter);
}

private List<Team> createTeams() {  
    List<Team> teams = new ArrayList<>();
    String[] teamNames = getResources().getStringArray(R.array.team_names);
    TypedArray teamLogos = getResources().obtainTypedArray(R.array.team_logos);
    for (int i = 0; i < teamNames.length; i++) {
        Team team = new Team(teamNames[i], teamLogos.getResourceId(i, -1));
        teams.add(team);
    }
    teamLogos.recycle();
    Collections.sort(teams, Team.BY_NAME_ALPHABETICAL);
    return teams;
}

We're using the Team.BY_NAME_ALPHABETICAL here to properly sort our items before passing them to the adapter.

Please fill the gaps in the code by checking the sample on GitHub.

And we're done! Now you can run the test either by right-clicking the TeamsActivityTest class and selecting the "Run" command, or running the following from the command line:

./gradlew connectedAndroidTest

The test should run fine, but in case they fail - you'll usually have a pretty helpful output from Espresso to help you debug the issue.

Now, we have an acceptance test written in Espresso, that automatically checks that our feature has been implemented correctly. As already mentioned, the complete sample code for this project is available on GitHub.

Are you writing Espresso tests to validate your features? I'd love to hear from you! Got any feedback, or noticed any errors? Please drop a comment or contact me directly. Cheers!