Tester la navigation

Il est important de tester la logique de navigation de votre application avant de la distribuer afin de vérifier qu'elle fonctionne comme prévu.

Le composant Navigation gère l'ensemble des tâches de gestion de la navigation entre les destinations, de transmission d'arguments et d'utilisation de FragmentManager. Ces fonctionnalités sont déjà soumises à des tests rigoureux. Vous n'avez donc pas besoin de les tester à nouveau dans votre application. Toutefois, il est important de tester les interactions entre le code spécifique de l'application dans vos fragments et leur NavController. Ce guide décrit quelques scénarios de navigation courants et explique comment les tester.

Tester la navigation par fragment

Pour tester de façon isolée les interactions des fragments avec leur NavController, la version 2.3 du composant Navigation (et les versions ultérieures) propose un TestNavHostController qui fournit des API permettant de définir la destination actuelle et de vérifier la pile "Retour" après les opérations NavController.navigate().

Vous pouvez ajouter l'artefact de test de navigation à votre projet en ajoutant la dépendance suivante dans le fichier build.gradle de votre module d'application :

Groovy

dependencies {
  def nav_version = "2.7.7"

  androidTestImplementation "androidx.navigation:navigation-testing:$nav_version"
}

Kotlin

dependencies {
  val nav_version = "2.7.7"

  androidTestImplementation("androidx.navigation:navigation-testing:$nav_version")
}

Imaginons que vous créiez un jeu de culture générale. Le jeu commence par un écran title_screen, puis passe à un écran in_game lorsque l'utilisateur clique sur le bouton "Play" (Jouer).

Le fragment représentant l'écran title_screen peut se présenter comme suit :

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);
        });
    }
}

Pour vérifier que l'application dirige correctement l'utilisateur vers l'écran in_game lorsqu'il clique sur Play (Jouer), votre test doit vérifier que ce fragment déplace correctement le NavController sur l'écran R.id.in_game.

En combinant FragmentScenario, Espresso et TestNavHostController, vous pouvez recréer les conditions nécessaires pour tester ce scénario, comme illustré dans l'exemple suivant :

Kotlin

@RunWith(AndroidJUnit4::class)
class TitleScreenTest {

    @Test
    fun testNavigationToInGameScreen() {
        // Create a TestNavHostController
        val navController = TestNavHostController(
            ApplicationProvider.getApplicationContext())

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

        titleScenario.onFragment { fragment ->
            // Set the graph on the TestNavHostController
            navController.setGraph(R.navigation.trivia)

            // Make the NavController available via the findNavController() APIs
            Navigation.setViewNavController(fragment.requireView(), navController)
        }

        // Verify that performing a click changes the NavController’s state
        onView(ViewMatchers.withId(R.id.play_btn)).perform(ViewActions.click())
        assertThat(navController.currentDestination?.id).isEqualTo(R.id.in_game)
    }
}

Java

@RunWith(AndroidJUnit4.class)
public class TitleScreenTestJava {

    @Test
    public void testNavigationToInGameScreen() {

        // Create a TestNavHostController
        TestNavHostController navController = new TestNavHostController(
            ApplicationProvider.getApplicationContext());

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

        titleScenario.onFragment(fragment ->
                // Set the graph on the TestNavHostController
                navController.setGraph(R.navigation.trivia);

                // Make the NavController available via the findNavController() APIs
                Navigation.setViewNavController(fragment.requireView(), navController)
        );

        // Verify that performing a click changes the NavController’s state
        onView(ViewMatchers.withId(R.id.play_btn)).perform(ViewActions.click());
        assertThat(navController.currentDestination.id).isEqualTo(R.id.in_game);
    }
}

L'exemple ci-dessus crée une instance de TestNavHostController et l'attribue au fragment. Il utilise ensuite Espresso pour piloter l'interface utilisateur et vérifie que l'action de navigation appropriée est effectuée.

Comme pour un vrai NavController, vous devez appeler setGraph pour initialiser le TestNavHostController. Dans cet exemple, le fragment testé était la destination de départ de notre graphique. TestNavHostController fournit une méthode setCurrentDestination qui vous permet de définir la destination actuelle (et éventuellement des arguments pour cette destination) afin que l'état du NavController soit correct avant le début du test.

Contrairement à une instance NavHostController utilisée par un NavHostFragment, TestNavHostController ne peut pas déclencher le comportement de navigate() qui en découle (contrairement au FragmentNavigator qui le peut pour le FragmentTransaction) lorsque vous appelez navigate(). Il ne peut que mettre à jour l'état du TestNavHostController.

Tester NavigationUI avec FragmentScenario

Dans l'exemple précédent, le rappel fourni à titleScenario.onFragment() est appelé une fois que le fragment a progressé dans son cycle de vie jusqu'à l'état RESUMED. À ce stade, la vue du fragment a déjà été créée et associée. Il est donc peut-être trop tard dans le cycle de vie pour effectuer un test correct. Par exemple, lorsque vous utilisez NavigationUI avec des vues dans votre fragment, par exemple avec un Toolbar contrôlé par votre fragment, vous pouvez appeler des méthodes de configuration avec votre NavController avant que le fragment atteigne l'état RESUMED. Vous avez donc besoin d'un moyen de définir votre TestNavHostController plus tôt dans le cycle de vie.

Un fragment possédant son propre Toolbar peut être écrit comme suit :

Kotlin

class TitleScreen : Fragment(R.layout.fragment_title_screen) {
    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 {
    public TitleScreen() {
        super(R.layout.fragment_title_screen);
    }

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

Nous avons besoin ici du NavController créé au moment où onViewCreated() est appelé. L'utilisation de l'approche précédente basée sur onFragment() définirait notre TestNavHostController trop tardivement dans le cycle de vie, ce qui entraînerait l'échec de l'appel de findNavController().

FragmentScenario propose une interface FragmentFactory permettant d'enregistrer des rappels pour les événements de cycle de vie. Elle peut être combinée à Fragment.getViewLifecycleOwnerLiveData() pour recevoir un rappel qui suit immédiatement onCreateView(), comme illustré dans l'exemple suivant :

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 NavController
        fragment.viewLifecycleOwnerLiveData.observeForever { viewLifecycleOwner ->
            if (viewLifecycleOwner != null) {
                // The fragment’s view has just been created
                navController.setGraph(R.navigation.trivia)
                Navigation.setViewNavController(fragment.requireView(), navController)
            }
        }
    }
}

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 NavController
        titleScreen.getViewLifecycleOwnerLiveData().observeForever(new Observer<LifecycleOwner>() {
            @Override
            public void onChanged(LifecycleOwner viewLifecycleOwner) {

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

            }
        });
        return titleScreen;
    }
});

En utilisant cette technique, le NavController est disponible avant l'appel de onViewCreated(), ce qui permet au fragment d'utiliser les méthodes NavigationUI sans plantage.

Tester les interactions avec les entrées de la pile "Retour"

En cas d'interactions avec les entrées de la pile "Retour", TestNavHostController vous permet de connecter le contrôleur à votre propre test LifecycleOwner, ViewModelStore et OnBackPressedDispatcher en utilisant les API dont il hérite de NavHostController.

Par exemple, lorsque vous testez un fragment qui utilise un ViewModel dont la portée est limitée à la navigation, vous devez appeler setViewModelStore sur le TestNavHostController :

Kotlin

val navController = TestNavHostController(ApplicationProvider.getApplicationContext())

// This allows fragments to use by navGraphViewModels()
navController.setViewModelStore(ViewModelStore())

Java

TestNavHostController navController = new TestNavHostController(ApplicationProvider.getApplicationContext());

// This allows fragments to use new ViewModelProvider() with a NavBackStackEntry
navController.setViewModelStore(new ViewModelStore())