Testare la navigazione dei frammenti

È importante testare la logica di navigazione dell'app prima della spedizione per verificare che l'applicazione funzioni come previsto.

Il componente Navigation gestisce tutto il lavoro di gestione della navigazione tra le destinazioni, il passaggio di argomenti e l'utilizzo di FragmentManager. Queste funzionalità sono già testate rigorosamente, quindi non è necessario testarle di nuovo nella tua app. Ciò che è importante testare, tuttavia, sono le interazioni tra il codice specifico dell'app nei tuoi fragment e il relativo NavController.

Test in isolamento

Per testare le interazioni dei fragment con il relativo NavController in isolamento, Navigation 2.3 e versioni successive forniscono un TestNavHostController che offre API per impostare la destinazione corrente e verificare il back stack dopo le operazioni NavController.navigate().

Puoi aggiungere l'artefatto di test di navigazione al tuo progetto aggiungendo la seguente dipendenza nel file build.gradle del modulo dell'app:

Groovy

dependencies {
  def nav_version = "2.9.8"

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

Kotlin

dependencies {
  val nav_version = "2.9.8"

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

Prendi in considerazione un gioco a quiz. Il gioco inizia con una schermata del titolo e passa a una schermata in-game quando l'utente fa clic su Riproduci.

Flusso di navigazione del gioco a quiz
Figura 1. Flusso di navigazione del gioco a quiz.

Il frammento che rappresenta title_screen potrebbe avere un aspetto simile a questo:

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

Per verificare che l'app indirizzi correttamente l'utente alla schermata in_game quando l'utente fa clic su Gioca, il test deve verificare che questo frammento sposti correttamente NavController alla schermata R.id.in_game.

Utilizzando una combinazione di FragmentScenario, Espresso e TestNavHostController, puoi ricreare le condizioni necessarie per testare questo scenario, come mostrato nell'esempio seguente:

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 using 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
    fun 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 using 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'esempio precedente crea un'istanza di TestNavHostController e la assegna al fragmento. Poi utilizza Espresso per controllare la UI e verifica che venga eseguita l'azione di navigazione appropriata.

Proprio come un vero NavController, devi chiamare setGraph per inizializzare TestNavHostController. In questo esempio, il frammento in fase di test era la destinazione iniziale del nostro grafico. TestNavHostController fornisce un metodo setCurrentDestination che consente di impostare la destinazione corrente (e, facoltativamente, gli argomenti per quella destinazione) in modo che NavController sia nello stato corretto prima dell'inizio del test.

A differenza di un'istanza NavHostController che un NavHostFragment utilizzerebbe, TestNavHostController non attiva il comportamento navigate() sottostante (come FragmentTransaction che FragmentNavigator fa) quando chiami navigate(). Aggiorna solo lo stato di TestNavHostController.

Testare NavigationUI con FragmentScenario

Nell'esempio precedente, il callback fornito a titleScenario.onFragment() viene chiamato dopo che il fragment è passato attraverso il suo ciclo di vita allo stato RESUMED. A questo punto, la visualizzazione del frammento è già stata creata e allegata, quindi potrebbe essere troppo tardi nel ciclo di vita per eseguire il test correttamente. Ad esempio, quando utilizzi NavigationUI con le visualizzazioni nel fragment, ad esempio con un Toolbar controllato dal fragment, puoi chiamare i metodi di configurazione con NavController prima che il fragment raggiunga lo stato RESUMED. Pertanto, devi trovare un modo per impostare TestNavHostController in una fase precedente del ciclo di vita.

Un frammento che possiede il proprio Toolbar può essere scritto come segue:

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

Qui abbiamo bisogno di NavController creato quando viene chiamato onViewCreated(). L'utilizzo dell'approccio precedente di onFragment() imposterebbe il nostro TestNavHostController troppo tardi nel ciclo di vita, causando l'errore della chiamata findNavController().

FragmentScenario offre un'interfaccia FragmentFactory che può essere utilizzata per registrare i callback per gli eventi del ciclo di vita. Questo può essere combinato con Fragment.getViewLifecycleOwnerLiveData() per ricevere un callback che segue immediatamente onCreateView(), come mostrato nel seguente esempio:

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

Utilizzando questa tecnica, NavController è disponibile prima che venga chiamato onViewCreated(), consentendo al fragment di utilizzare i metodi NavigationUI senza arresti anomali.

Testare le interazioni con le voci del back stack

Quando interagisci con le voci del back stack, il TestNavHostController ti consente di collegare il controller ai tuoi test LifecycleOwner, ViewModelStore e OnBackPressedDispatcher utilizzando le API ereditate da NavHostController.

Ad esempio, quando testi un fragment che utilizza un ViewModel con ambito di navigazione, devi chiamare setViewModelStore su 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())