Save the date! Android Dev Summit is coming to Sunnyvale, CA on Oct 23-24, 2019.

Test Navigation

It is important to test your app's navigation logic before you ship in order to verify that your application works as you expect.

The Navigation component handles all the work of managing navigation between destinations, passing arguments, and working with the FragmentManager. These capabilities are already rigorously tested, so there is no need to test them again in your app. What is important to test, however, are the interactions between the app specific code in your fragments and their NavController. This guide walks through a few common navigation scenarios and how to test them.

Test fragment Navigation

To test fragment interactions with their NavController in isolation, you can provide a mock NavController inside your test using Mockito. You can then use that mock implementation to verify interactions with it.

Let’s say you are building a trivia game. The game starts with a title_screen and navigates to an in_game screen when the user clicks play.

The fragment representing the title_screen might look something like this:

Kotlin

class TitleScreen : Fragment() {
    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ) = inflater.inflate(R.layout.fragment_title_screen, container, false)

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        view.findViewById<Button>(R.id.play_btn).setOnClickListener {
            view.findNavController().navigate(R.id.action_title_screen_to_in_game)
        }
    }
}

Java

public class TitleScreen extends Fragment {

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

    @Override
    public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
        view.findViewById(R.id.play_btn).setOnClickListener(v -> {
            Navigation.findNavController(view).navigate(R.id.action_title_screen_to_in_game);
        });
    }
}

To test that the app properly navigates the user to the in_game screen when the user clicks Play, your test needs to verify that this fragment invokes NavController.navigate() with the action R.id.action_title_screen_to_in_game.

Using a combination of FragmentScenario, Espresso, and Mockito, you can recreate the conditions necessary to test this scenario, as shown in the following example:

Kotlin

@RunWith(AndroidJUnit4::class)
class TitleScreenTest {

    @Test
    fun testNavigationToInGameScreen() {
        // Create a mock NavController
        val mockNavController = mock(NavController::class.java)

        // Create a graphical FragmentScenario for the TitleScreen
        val titleScenario = launchFragmentInContainer<TitleScreen>()

        // Set the NavController property on the fragment
        titleScenario.onFragment { fragment ->
            Navigation.setViewNavController(fragment.requireView(), mockNavController)
        }

        // Verify that performing a click prompts the correct Navigation action
        onView(ViewMatchers.withId(R.id.play_btn)).perform(ViewActions.click())
        verify(mockNavController).navigate(R.id.action_title_screen_to_in_game)
    }
}

Java

@RunWith(AndroidJUnit4.class)
public class TitleScreenTestJava {

    @Test
    public void testNavigationToInGameScreen() {

        // Create a mock NavController
        NavController mockNavController = mock(NavController.class);

        // Create a graphical FragmentScenario for the TitleScreen
        FragmentScenario<TitleScreen> titleScenario = FragmentScenario.launchInContainer(TitleScreen.class);

        // Set the NavController property on the fragment
        titleScenario.onFragment(fragment ->
                Navigation.setViewNavController(fragment.requireView(), mockNavController)
        );

        // Verify that performing a click prompts the correct Navigation action
        onView(ViewMatchers.withId(R.id.play_btn)).perform(ViewActions.click());
        verify(mockNavController).navigate(R.id.action_title_screen_to_in_game);
    }
}

The above example creates a mock instance of NavController and assigns it to the fragment. It then uses Espresso to drive the UI and verifies that the appropriate navigation action is taken.

Test NavigationUI with FragmentScenario

In the previous example, the callback provided to titleScenario.onFragment() is called after the fragment has moved through its lifecycle to the RESUMED state. By this time, the fragment’s view has already been created and attached, so it may be too late in the lifecycle to test properly. For example, when using NavigationUI with views in your fragment, such as with a Toolbar controlled by your fragment, you can call setup methods with your NavController before the fragment reaches the RESUMED state. Thus, you need a way to to set your mock NavController earlier in the lifecycle.

A fragment that owns its own Toolbar can be written as follows:

Kotlin

class TitleScreen : Fragment() {

    override fun onCreateView(
            inflater: LayoutInflater,
            container: ViewGroup?,
            savedInstanceState: Bundle?
    ) = return inflater.inflate(R.layout.fragment_title_screen, container, false)

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        val navController = view.findNavController()
        view.findViewById<Toolbar>(R.id.toolbar).setupWithNavController(navController)
    }
}

Java

public class TitleScreen extends Fragment {

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

    @Override
    public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
        NavController navController = Navigation.findNavController(view);
        view.findViewById(R.id.toolbar).setupWithNavController(navController);
    }
}

Here we need the NavController mocked by the time onViewCreated() is called. Using the previous approach of onFragment() would set our mock NavController too late in the lifecycle, causing the findNavController() call to fail.

FragmentScenario offers a FragmentFactory interface which can be used to register callbacks for lifecycle events. This can be combined with Fragment.getViewLifecycleOwnerLiveData() to receive a callback that immediately follows onCreateView(), as shown in the following example:

Kotlin

val scenario = launchFragmentInContainer {
    TitleScreen().also { fragment ->

        // In addition to returning a new instance of our Fragment,
        // get a callback whenever the fragment’s view is created
        // or destroyed so that we can set the mock NavController
        fragment.viewLifecycleOwnerLiveData.observeForever { viewLifecycleOwner ->
            if (viewLifecycleOwner != null) {
                // The fragment’s view has just been created
                Navigation.setViewNavController(fragment.requireView(), mockNavController)
            }
        }
    }
}

Java

FragmentScenario<TitleScreen> scenario =
FragmentScenario.launchInContainer(
       TitleScreen.class, null, new FragmentFactory() {
    @NonNull
    @Override
    public Fragment instantiate(@NonNull ClassLoader classLoader,
            @NonNull String className,
            @Nullable Bundle args) {
        TitleScreen titleScreen = new TitleScreen();

        // In addition to returning a new instance of our fragment,
        // get a callback whenever the fragment’s view is created
        // or destroyed so that we can set the mock NavController
        titleScreen.getViewLifecycleOwnerLiveData().observeForever(new Observer<LifecycleOwner>() {
            @Override
            public void onChanged(LifecycleOwner viewLifecycleOwner) {

                // The fragment’s view has just been created
                if (viewLifecycleOwner != null) {
                    Navigation.setViewNavController(titleScreen.requireView(), mockNavController);
                }

            }
        });
        return titleScreen;
    }
});

By using this technique, the NavController is available before onViewCreated() is called, allowing the fragment to use NavigationUI methods without crashing.