Test-Driven Android: RecyclerViews

Welcome! You’ve landed on the second post in a series of posts designed to be companion reading for Android Programming: The Big Nerd Ranch Guide, 2nd ed.

Here is the first of those posts. 

RecyclerView

BNR goes step-by-step through how to make a RecyclerView and all of the component objects that come together to make it work. The RecyclerView differs from the ListView, which is the list creation method that I have used the most in Android, so I was excited to see the RecyclerView in action. That having been said, the explanation in the book spreads out over several pages; I wanted to find something more concise to help me achieve a high-level picture of how everything talks to each other and how I might implement a RecyclerView on my own rather than copying the code verbatim in the book. I found this blog post by Ashraff Hathibelagal, which helped me sketch out a responsibility diagram for RecyclerViews to contrast with a responsibility diagram of its predecessor, the ListView/GridView:

Doc - Sep 27, 2015, 2-53 PM

I also wanted to find a way to test-drive an implementation of the RecyclerView, so that the design and function of my code would be guided and validated by a set of automated tests.*

*Warning: this post is not an introduction to TDD. This material will probably be most helpful if you already understand the concepts of test-driven developmentwhy a developer would write automated unit tests, dependency injection, and mocking.

Below is a file-by-file code example of how I did this. The example comes from an app that displays a list of candies:

Because who doesn't love candies? Image courtesy of allbackgrounds.com.
Because who doesn’t love candies? Image courtesy of allbackgrounds.com.

In between the code, I’ll include some annotations explaining why certain things were done.

The first step is to include all the dependencies to test-drive and implement the Recycler View.

app build.gradle

...
 dependencies {
     compile fileTree(dir: 'libs', include: ['*.jar'])
     compile 'com.android.support:recyclerview-v7:22.2.1'
     compile 'com.squareup.dagger:dagger:1.2.2'
     provided 'com.squareup.dagger:dagger-compiler:1.2.2'
     testCompile 'com.squareup.assertj:assertj-android:1.1.0'
     testCompile 'org.robolectric:robolectric:3.0'
     testCompile('org.robolectric:shadows-support-v4:3.0') {
         exclude module: 'support-v4'
     }
     testCompile 'org.mockito:mockito-all:1.10.19'
     testCompile 'net.javacrumbs.json-unit:json-unit-fluent:1.5.6'
     testCompile 'com.squareup.assertj:assertj-android-recyclerview-v7:1.1.0@aar'
 }
...

You see quite a few dependencies there. The recyclerview dependency at the top will give us our recyclerview implementation from the Android support library, so it’ll be backward compatible for Android devices on API level 20 and below.

Then we have dagger for dependency injection, assertj-android for general automated testing (it’s the current, supported descendant of fest), robolectric (for testing where we need access to an application context), shadows (for testing activities and dialogs), mockito (for mocking objects in our tests), json unit testing, and assertj-android-recyclerview for specifically testing the recycler view itself. We have to include @aar on the end of this dependency for now because of an open issue with the repo.

Now let’s take a look at the layout for the fragment where we will stick our recycler view:

fragment_candies_recycler_view.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
                  android:orientation="vertical"
                  android:layout_width="match_parent"
                  android:layout_height="match_parent">
  <android.support.v7.widget.RecyclerView
      android:layout_width="match_parent"
      android:layout_height="match_parent"
      android:id="@+id/candies_list" />
</LinearLayout>

So we have our recycler view in there. We will get ahold of that recycler view inside of its fragment by using its layout id. Then we will set a layout manager and an adapter on it.

CandiesFragmentTest.java

@RunWith(RobolectricGradleTestRunner.class)
@Config(constants = BuildConfig.class, sdk = 18)
// those annotations make it possible to use an application, cast to a TestCandiesApplication, for dependency injection.
public class CandiesFragmentTest {
    private TestableCandiesFragment fragment;

    // We could not inject a layoutManager as a dependency because it is an Android class and it has no public, zero-argument constructor.
    // The solution here was to new one up in the real fragment in a method called getLayoutManager(), then subclass that fragment specifically for testing purposes
    // and override getLayoutManager() to return a layout manager from a field on the object.
    // we set that field to the mockLayoutManager seen below.
    private LinearLayoutManager mockLayoutManager;
    
    @Inject
    CandiesBroadcastReceiver mockBroadcastReceiver;
    
    @Inject
    CandiesListAdapter mockAdapter;

    @Before
    public void setUp() {
        ((TestCandiesApplication) RuntimeEnvironment.application).inject(this);

        //making a mock of the layout manager...
        mockLayoutManager = Mockito.mock(LinearLayoutManager.class);
        fragment = new TestableCandiesFragment();

        //setting it on our testable subclass...
        fragment.setLayoutManager(mockLayoutManager);

        //Start the fragment!
        SupportFragmentTestUtil.startFragment(fragment);
    }

    @Test
    public void defaultDisplay() {
        RecyclerView recyclerView = (RecyclerView) fragment.getView().findViewById(R.id.candies_list);
        RecyclerView.LayoutManager layoutManager = recyclerView.getLayoutManager();

        //assertThat(LayoutManager layoutManager) is provided to us by the assertj-android-recyclerview library.
        assertThat(layoutManager).isEqualTo(mockLayoutManager);
    }

    @Test
    public void onViewCreated_setsAdapterAndAttributes() {

        //because we inject our mockAdapter into our fragment, we use a mock here to make sure that we give it
        //everything it needs to function.
        //it needs the list of candies in order to present them,
        //and it needs a context so that, when we click on a candy, the adapter can use the context
        //to launch a new activity that displays details about the candy.
        verify(mockAdapter).setCandies(Matchers.<List>any());
        verify(mockAdapter).setContext(fragment);
    }

    //Now we make sure that the adapter is showing us our candies in the list.
    @Test
    public void onSuccess_populatesViewWithListOfCandies() {
        Candy candy = new Candy();
        candy.setName("Chocolate Frogs");
        candy.setDescription("Eat this delicious magical treat before it hops away!");
        fragment.onSuccess(CandyBroadcastReceiver.Actions.GET_CANDIES, asList(candy));
        CandiesListAdapter adapter = (CandiesListAdapter) fragment.getRecyclerView().getAdapter();
        Assertions.assertThat(adapter.getItemCount()).isEqualTo(1);
        Assertions.assertThat(adapter.getItemAtPosition(0)).isSameAs(candy);

        Candy otherCandy = new Candy();
        fragment.onSuccess(CandyBroadcastReceiver.Actions.GET_CANDIES, asList(otherCandy));
        Assertions.assertThat(adapter.getItemCount()).isEqualTo(1);
        Assertions.assertThat(adapter.getItemAtPosition(0)).isSameAs(otherCandy);

    }

    //Here is the subclass of CandiesFragment that we use for testing. //It overrides getLayoutManager to return a mock so we can assert on the mock. 

    public static class TestableCandiesFragment extends CandiesFragment {
        private LinearLayoutManager mockLayoutManager;

        public void setLayoutManager(LinearLayoutManager mockLayoutManager) {
            this.mockLayoutManager = mockLayoutManager;
        }

        @Override
        public LinearLayoutManager getLayoutManager() {
            return mockLayoutManager;
        }
    }
}

So that is the test file that we use to make sure that everything happened correctly in our CandiesFragment. Now here is the implementation of CandiesFragment:

CandiesFragment.java

public class CandiesFragment extends Fragment {
    @Inject
    CandiesBroadcastReceiver receiver;

    @Inject
    CandiesListAdapter candiesListAdapter;

    private ArrayList listOfCandies = new ArrayList();

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        ((CandiesApplication) getActivity().getApplication()).inject(this);

        receiver.register(this);
        getActivity().registerReceiver(receiver, new IntentFilter(CandiesBroadcastReceiver.Actions.GET_CANDIES));

        getActivity().startService(new Intent(getActivity().getApplicationContext(), CandiesService.class));
    }

    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
        return inflater.inflate(R.layout.fragment_candiesrecycler_view, container, false);
    }

    @Override
    public void onViewCreated(View view, Bundle savedInstanceState) {
        super.onViewCreated(view, savedInstanceState);

        //Here we are setting our layout manager on our recycler view!
        getRecyclerView().setLayoutManager(getLayoutManager());
        //Now we pass the list adapter everything it needs...
        candiesListAdapter.setContext(this);
        candiesListAdapter.setCandies(listOfCandies);
        //and we set it on the recycler view, too.
        getRecyclerView().setAdapter(candiesListAdapter);
    }

    // Here is the method we extract to override in our testable subclass
    public LinearLayoutManager getLayoutManager() {
        return new LinearLayoutManager(getActivity());
    }

    // I make a habit of extracting methods to encapsulate android classes reaching into their
    // layouts to get elements. Often these elements need to be mocked in testing.
    public RecyclerView getRecyclerView() {
        return (RecyclerView) getView().findViewById(R.id.candies_list);
    }

    @Override
    public void onSuccess(String action, Object data) {
        listOfCandies.clear();
        List candies = (List)data;

        listOfCandies.addAll(candies);
        candiesListAdapter.setCandies(listOfCandies);
        getRecyclerView().getAdapter().notifyDataSetChanged();
    }

    @Override
    public void onFailure(CandiesApiError error) {
    }

    @Override
    public void onDestroy() {
        super.onDestroy();

        getActivity().unregisterReceiver(receiver);
    }

    public ArrayList getListOfCandies() {
        return listOfCandies;
    }
}

Now we have set our layoutManager and our adapter on our fragment. What does the listAdapter look like, and how do we test it?

Here is how we tested it, again subclassing and overriding the test subject to get a mock where we are unable to use dependency injection.

ExampleListAdapterTest.java

@RunWith(RobolectricGradleTestRunner.class)
@Config(constants = BuildConfig.class, sdk = 18)
public class ExampleListAdapterTest {
    private CandiesListAdapter adapter;
    private CandiesListAdapter.CandyViewHolder holder;
    private View mockView;
    private Fragment mockFragment;

    @Before
    public void setUp() throws Exception {
        ((TestApplication) RuntimeEnvironment.application).inject(this);
        adapter = new CandiesListAdapter();
        mockView = mock(View.class);
        mockFragment = mock(Fragment.class);

        stub(mockFragment.getString(anyInt())).toReturn("Candy");
    }

    @Test
    public void itemCount() {
        Candy candy = new Candy();
        adapter.setCandies(asList(candy, candy, candy));
        assertThat(adapter.getItemCount()).isEqualTo(3);
    }

    @Test
    public void getItemAtPosition() {
        Candy firstCandy = new Candy();
        Candy secondCandy = new Candy();
        adapter.setCandies(asList(firstCandy, secondCandy));
        assertThat(adapter.getItemAtPosition(0)).isEqualTo(firstCandy);
        assertThat(adapter.getItemAtPosition(1)).isEqualTo(secondCandy);
    }

    @Test
    public void onBindViewHolder_setsTextAndClickEventForCandyView() {
        Candy candy = new Candy();
        candy.setName(asList("Gumdrops"));
        candy.setDescription("Don't leave these sticky treats in a car during the summer.");

        adapter.setCandies(asList(candy));
        adapter.setContext(mockFragment);
        LayoutInflater inflater = (LayoutInflater) RuntimeEnvironment.application.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
        //We have a layout especially for the items in our recycler view. We will see it in a moment. 
        View listItemView = inflater.inflate(R.layout.list_adapter_candies_item, null, false);
        holder = new CandiesListAdapter.CandyViewHolder(listItemView);
        adapter.onBindViewHolder(holder, 0);
        assertThat(holder.nameView.getText().toString()).isEqualTo("Gumdrops");
        assertThat(holder.descriptionView.getText().toString()).isEqualTo("Don't leave these sticky treats in a car during the summer.");
        holder.itemView.performClick();
        Intent intent = new Intent(mockFragment.getActivity(), CandyDetailActivity.class);
        intent.putExtra("candy", candy);
        verify(mockFragment).startActivity(intent);
    }

    @Test
    public void onCreateViewHolder_returnsNewCandyViewHolderOfCorrectLayout() {
        TestableCandiesListAdapter testableAdapter = new TestableCandiesListAdapter();
        testableAdapter.setMockView(mockView);
        CandiesListAdapter.CandyViewHolder candyViewHolder = testableAdapter.onCreateViewHolder(new FrameLayout(RuntimeEnvironment.application), 0);
        assertThat(candyViewHolder.itemView).isSameAs(mockView);
    }

    //Here we subclass and override the test subject again so we can use a mock view for testing, instead of the real one.
    static class TestableCandiesListAdapter extends CandiesListAdapter {
        public View mockView;

        public void setMockView(View mockView) {
            this.mockView = mockView;
        }

        @Override
        public View getLayout(ViewGroup parent) {
            return mockView;
        }
    }
}

Here is the implementation that makes those tests pass:

CandiesListAdapter.java

public class CandiesListAdapter extends RecyclerView.Adapter {
    private List candies;
    private Fragment context;

    public Fragment getContext() {
        return context;
    }

    public void setContext(Fragment context) {
        this.context = context;
    }

    @Override
    public CandyViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
        View view = getLayout(parent);

        return new CandyViewHolder(view);
    }

    @Override
    public void onBindViewHolder(CandyViewHolder holder, int position) {
        final Candy candy = getItemAtPosition(position);

        //This seemed the most sensible place to put an item-specific onClickListener, since this is where all of the item-specific settings are handled.
        holder.itemView.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                Intent intent = new Intent(getContext().getActivity(), CandyDetailActivity.class));
                intent.putExtra("candy", candy);
                context.startActivity(intent);
            }
        });
        holder.nameView.setText(candy.getName());
        holder.descriptionView.setText(candy.getDescription());
    }

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

    public View getLayout(ViewGroup parent) {
        return LayoutInflater.from(parent.getContext()).inflate(R.layout.list_adapter_candy_item, null);
    }

    public Candy getItemAtPosition(int position) {
        return candies.get(position);
    }

    public void setCandies(List candies) {
        this.candies = candies;
    }

    // This view holder allows the Adapter to hang onto the instances of an individual item view
    // and reuse them when they go offscreen for the new views that have to come onscreen.
    public static class CandyViewHolder extends RecyclerView.ViewHolder {
        public TextView nameView;
        public TextView descriptionView;
        public View itemView;

        public CandyViewHolder(View itemView) {
            super(itemView);

            this.itemView = itemView;
            nameView = (TextView) itemView.findViewById(R.id.candy_name);
            descriptionView = (TextView) itemView.findViewById(R.id.candy_description);
        }
    }
}

In the adapter, you see a reference to the layout in which it is supposed to set the fields: the list_adapter_candies_item xml.
Here is what that looks like:

list_adapter_candies_item.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
 xmlns:android="http://schemas.android.com/apk/res/android"
 xmlns:tools="http://schemas.android.com/tools"
 android:layout_width="match_parent"
 android:layout_height="match_parent"
 android:padding="@dimen/gutter"
 android:orientation="vertical"
 >
 <TextView
 style="@style/FontSizeS"
 android:id="@+id/candy_name"
 android:layout_width="wrap_content"
 android:layout_height="wrap_content"
 tools:text="Rainbow Jawbreakers"
 />
 <TextView
 style="@style/FontSizeXS"
 android:id="@+id/candy_description"
 android:layout_width="wrap_content"
 android:layout_height="wrap_content"
 android:textColor="@color/medium_gray"
 android:singleLine="true"
 tools:text="Rainbow jawbreakers are large sugary spheres that look fun and innocent...but don't bite them!"
 />
</LinearLayout>

A final change had to be made to tie all of this together. We added our adapter as a dependency to the CandiesFragment, which we will expect dagger to inject for us. In order for dagger to know how to do that, we must add an adapter to our CandiesModule, and a mock adapter to our TestCandiesModule so that dagger injects a mock for us when we need to test.

Here is the test module:

TestCandiesModule.java

@Module(
        injects = {
        ...
        CandiesFragmentTest.class,
        //Because the TestableCandiesFragment subclasses CandiesFragment, it needs to be on the list
        //to receive any injections that its superclass needs. So here it is.
        //We only need it in the TestCandiesModule, not the CandiesModule, because we do not
        //instantiate one anywhere outside of the tests.
        CandiesFragmentTest.TestableCandiesFragment.class,
        CandiesListAdapterTest.class,
        },
        complete = false,
        overrides = true,
        library = true
)
public class TestCandiesModule {
    @Mock
    CandiesService mockCandiesService;
    
    @Mock
    CandiesListAdapter mockAdapter;

    public TestApplicationModule() {
        initMocks(this);
    }

    @Provides
    CandiesListAdapter provideMockAdapter() {
        return mockAdapter;
    }

    @Provides
    CandiesService provideCandiesService() {
        return mockCandiesService;
    }
}

…and the non-test module:

CandiesModule.java

@Module(
        injects = {
                CandiesFragment.class,
                CandiesListAdapter.class,
        },
        complete = false
)
public class CandiesModule {
    @Provides
    CandiesService provideCandiesService() {
        return new CandiesService();
    }

    @Provides
    CandiesListAdapter provideCandiesAdapter() {
        return new CandiesListAdapter();
    }
}

Conclusions

The approach in the example successfully produces a recycler view that displays a list of candies and visits a CandyDetailActivity screen when I click on any of the candies.

It also successfully tests the implementation such that commenting out any line of the implementation causes at least one test to fail, and changing the behavior of the recyclerview causes at least one test to fail.

However, there may even be improved approaches to the one described above:

  • It might make more sense to use spies, rather than subclassed versions of the test subjects, to check on fields that we could not inject. That approach might be clearer for other programmers to read.
  • It feels odd to pass a context into the adapter. Should an adapter know how to start a new activity, or should it instead call back to its fragment? The approach shown here seemed like a good option because it kept all the setting stuff on the item view in the same place. I wonder whether there are any runtime risks of doing it this way.

I will look into these things.

In the meantime, though the RecyclerView is assembled a bit differently from the ListView or GridView, it has proven to be just as testable as they are.

In implementing this, I also found the separation of responsibilities in the RecyclerView both more flexible and more intuitive than its predecessors. If you have additional thoughts or feedback, I would love to hear them.

Advertisements

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s