Question

I am trying to set up a basic test for a ListFragment and am running into serious issues. I'd been getting strange behavior out of Robolectric ListFragments, so I decided to run them on the device. They appear to be working precisely as I intended on the actual device, but not so with Robolectric. I'm using 2.3 snapshot 20140425.145412-162-jar-with-dependencies, because we need to use non-support Fragments.

When I run the ListFragment on the device, everything is dandy. When I run it on Robolectric, I get a null pointer exception at ListFragment$1.run(ListFragment.java:153). I've tried adding my own startFragment method and using the one provided in FragmentTestUtil.

This appears to be a bug, because even if I was doing something wrong I would expect behavior to be identical on the device.

Here is my ListFragment class:

public class TableManagerFragment extends ListFragment {

  private static final String TAG = TableManagerFragment.class.getSimpleName();

  /** All the TableProperties that should be visible to the user. */
  private List<TableProperties> mTableList;

  private TablePropertiesAdapter mTpAdapter;

  public TableManagerFragment() {
    // empty constructor required for fragments.
  }

  @Override
  public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    Log.d(TAG, "[onCreate]");
    this.mTableList = new ArrayList<TableProperties>();
    this.setHasOptionsMenu(true);
    this.setMenuVisibility(true);
  }

  @Override
  public boolean onOptionsItemSelected(MenuItem item) {
    Log.d(TAG, "[onOptionsItemSelected] selecting an item");
    return super.onOptionsItemSelected(item);
  }

  @Override
  public View onCreateView(
      LayoutInflater inflater,
      ViewGroup container,
      Bundle savedInstanceState) {
    Log.d(TAG, "[onCreateView]");
    View view = inflater.inflate(
        R.layout.fragment_table_list,
        container,
        false);
    return view;
  }

  @Override
  public void onActivityCreated(Bundle savedInstanceState) {
    super.onActivityCreated(savedInstanceState);
    // call this here because we need a context.
    List<TableProperties> newProperties = this.retrieveContentsToDisplay();
    Log.e(TAG, "got newProperties list of size: " + newProperties.size());
    this.setPropertiesList(newProperties);
    this.mTpAdapter = new TablePropertiesAdapter(this.getPropertiesList());
    this.setListAdapter(this.mTpAdapter);
    this.mTpAdapter.notifyDataSetChanged();    
  }

  @Override
  public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
    inflater.inflate(R.menu.table_manager, menu);
    super.onCreateOptionsMenu(menu, inflater);
  }

  /**
   * Retrieve the contents that will be displayed in the list. This should be
   * used to populate the list.
   * @return
   */
  List<TableProperties> retrieveContentsToDisplay() {
    TableProperties[] tpArray = TableProperties.getTablePropertiesForAll(
        getActivity(),
        TableFileUtils.getDefaultAppName());
    List<TableProperties> tpList = Arrays.asList(tpArray);
    return tpList;
  }

  /**
   * Get the list currently displayed by the fragment.
   * @return
   */
  List<TableProperties> getPropertiesList() {
    return this.mTableList;
  }

  /**
   * Update the contents of the list with the this new list.
   * @param list
   */
  void setPropertiesList(List<TableProperties> list) {
    // We can't change the reference, which is held by the adapter.
    this.getPropertiesList().clear();
    for (TableProperties tp : list) {
      this.getPropertiesList().add(tp);
    }
  }

}

And fragment_table_list.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" >
    <ListView
        android:id="@android:id/list"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:divider="#C0C0C0"
        android:dividerHeight="1dp"
        android:layout_weight="2"
        android:drawSelectorOnTop="false" />
    <TextView
        android:id="@android:id/empty"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:text="@string/no_data" />
</LinearLayout>

My Robolectric tests:

@RunWith(RobolectricTestRunner.class)
public class TableManagerFragmentTest {

  private TableManagerFragment fragment;
  private Activity parentActivity;

  public void setupFragmentWithNoItems() {
    this.fragment = getSpy(new ArrayList<TableProperties>());
    doGlobalSetup();
  }

  public void setupFragmentWithTwoItems() {
    TableProperties tp1 = mock(TableProperties.class);
    TableProperties tp2 = mock(TableProperties.class);
    when(tp1.getDisplayName()).thenReturn("alpha");
    when(tp2.getDisplayName()).thenReturn("beta");
    List<TableProperties> listOfMocks = new ArrayList<TableProperties>();
    listOfMocks.add(tp1);
    listOfMocks.add(tp2);
    this.fragment = getSpy(listOfMocks);
    doGlobalSetup();
  }

  /**
   * Does the setup required regardless of what the fragment is returning.
   */
  public void doGlobalSetup() {
    ShadowLog.stream = System.out;
    // We need external storage available for accessing the database.
    TestCaseUtils.setExternalStorageMounted();
    startFragment(this.fragment);
    this.parentActivity = this.fragment.getActivity();
    // Have to call visible to get the fragment to think its been attached to
    // a window.
    ActivityController.of(this.parentActivity).visible();
  }

  /**
   * Get a mocked TableManagerFragment that will return toDisplay when asked to
   * retrieve TableProperties.
   * @param toDisplay
   * @return
   */
  private TableManagerFragment getSpy(List<TableProperties> toDisplay) {
     TableManagerFragment spy = spy(new TableManagerFragment());
     doReturn(toDisplay).when(spy).retrieveContentsToDisplay();
     return spy;
  }

  @Test
  public void emptyViewIsVisibleWithoutContent() {
    setupFragmentWithNoItems();
    // We aren't retrieving any TableProperties, so it is empty.
    // Weirdly, the List is also visible. Perhaps this is because the list view
    // is always visible, just not taking up any screen real estate if there
    // are no elements? Should investigate this when we have known elements.
    View emptyView = this.fragment.getView().findViewById(android.R.id.empty);
    assertThat(emptyView).isVisible();
  }

  @Test
  public void listViewIsGoneWithoutContent() {
    setupFragmentWithNoItems();
    View listView = this.fragment.getView().findViewById(android.R.id.list);
    assertThat(listView).isGone();
  }

  @Test
  public void emptyViewIsGoneWithContent() {
    setupFragmentWithTwoItems();
    View emptyView = this.fragment.getView().findViewById(android.R.id.empty);
    assertThat(emptyView).isGone();
  }

  @Test
  public void listViewIsVisibleWithContent() {
    setupFragmentWithTwoItems();
    View listView = this.fragment.getView().findViewById(android.R.id.list);
    assertThat(listView).isVisible();
  }

  @Test
  public void hasCorrectMenuItems() {
    setupFragmentWithNoItems();
    ShadowActivity shadowActivity = shadowOf(parentActivity);
    Menu menu = shadowActivity.getOptionsMenu();
    assertThat(menu)
      .hasSize(4)
      .hasItem(R.id.menu_table_manager_export)
      .hasItem(R.id.menu_table_manager_import)
      .hasItem(R.id.menu_table_manager_sync)
      .hasItem(R.id.menu_table_manager_preferences);
  }


}

And my failure trace:

java.lang.NullPointerException
    at android.app.ListFragment$1.run(ListFragment.java:153)
    at org.robolectric.util.Scheduler.postDelayed(Scheduler.java:37)
    at org.robolectric.shadows.ShadowLooper.post(ShadowLooper.java:207)
    at org.robolectric.shadows.ShadowHandler.postDelayed(ShadowHandler.java:56)
    at org.robolectric.shadows.ShadowHandler.post(ShadowHandler.java:51)
    at android.os.Handler.post(Handler.java)
    at android.app.ListFragment.ensureList(ListFragment.java:432)
    at android.app.ListFragment.onViewCreated(ListFragment.java:203)
    at android.app.FragmentManagerImpl.moveToState(FragmentManager.java:843)
    at android.app.FragmentManagerImpl.moveToState(FragmentManager.java:1035)
    at android.app.BackStackRecord.run(BackStackRecord.java:635)
    at android.app.FragmentManagerImpl.execPendingActions(FragmentManager.java:1397)
    at android.app.FragmentManagerImpl$1.run(FragmentManager.java:426)
    at org.robolectric.util.Scheduler.postDelayed(Scheduler.java:37)
    at org.robolectric.shadows.ShadowLooper.post(ShadowLooper.java:207)
    at org.robolectric.shadows.ShadowHandler.postDelayed(ShadowHandler.java:56)
    at org.robolectric.shadows.ShadowHandler.post(ShadowHandler.java:51)
    at android.os.Handler.post(Handler.java)
    at android.app.FragmentManagerImpl.enqueueAction(FragmentManager.java:1303)
    at android.app.BackStackRecord.commitInternal(BackStackRecord.java:548)
    at android.app.BackStackRecord.commit(BackStackRecord.java:532)
    at org.robolectric.util.FragmentTestUtil.startFragment(FragmentTestUtil.java:14)
    at org.opendatakit.tables.fragments.TableManagerFragmentTest.doGlobalSetup(TableManagerFragmentTest.java:60)
    at org.opendatakit.tables.fragments.TableManagerFragmentTest.setupFragmentWithNoItems(TableManagerFragmentTest.java:38)
    at org.opendatakit.tables.fragments.TableManagerFragmentTest.emptyViewIsVisibleWithoutContent(TableManagerFragmentTest.java:81)
    at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:45)
    at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:15)
    at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:42)
    at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:20)
    at org.robolectric.RobolectricTestRunner$2.evaluate(RobolectricTestRunner.java:250)
    at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:263)
    at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:68)
    at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:47)
    at org.junit.runners.ParentRunner$3.run(ParentRunner.java:231)
    at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:60)
    at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:229)
    at org.junit.runners.ParentRunner.access$000(ParentRunner.java:50)
    at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:222)
    at org.robolectric.RobolectricTestRunner$1.evaluate(RobolectricTestRunner.java:177)
    at org.junit.runners.ParentRunner.run(ParentRunner.java:300)
    at org.eclipse.jdt.internal.junit4.runner.JUnit4TestReference.run(JUnit4TestReference.java:50)
    at org.eclipse.jdt.internal.junit.runner.TestExecution.run(TestExecution.java:38)
    at org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.runTests(RemoteTestRunner.java:467)
    at org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.runTests(RemoteTestRunner.java:683)
    at org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.run(RemoteTestRunner.java:390)
    at org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.main(RemoteTestRunner.java:197)
Was it helpful?

Solution

Turns out this is not robolectric's fault at all, but my own failure to understand mockito.

I was able to fix this. It isn't robolectric's problem, but my own not understanding of the mockito framework. I think somehow it boils down to problem 2 in the Spy javadoc:

Mockito does not delegate calls to the passed real instance, instead it actually creates a copy of it. So if you keep the real instance and interact with it, don't expect the spied to be aware of those interaction and their effect on real instance state. The corollary is that when an unstubbed method is called on the spy but not on the real instance, you won't see any effects on the real instance.

I'm not holding on to the real instance, but it seemed to not be doing something correctly. I ended up subclassing TableManagerFragment directly to make my own stub. It's unfortunate that I can't use Mockito to do this, although I'm not completely sure why it doesn't work. Ultimately, I don't think you can use Mockito's spy to work with Fragments. If someone can and has information to the contrary, I'd love to hear it! My life would be easier.

Licensed under: CC-BY-SA with attribution
Not affiliated with StackOverflow
scroll top