Cómo probar la navegación

Es importante que pruebes la lógica de navegación de tu app antes ponerla a disposición para verificar que tu aplicación funcione de la manera esperada.

El componente Navigation se ocupa de todo el trabajo de administrar la navegación entre los destinos, pasar los argumentos y trabajar con el objeto FragmentManager. Como estas capacidades ya se prueban con rigurosidad, no es necesario que vuelvas a probarlas en tu app. Sin embargo, es importante que pruebes las interacciones entre el código específico de la app en tus fragmentos y su NavController. En esta guía, se muestran algunos casos de navegación comunes y cómo probarlos.

Cómo probar Navigation de un fragmento

Para probar las interacciones de fragmentos con su NavController de manera aislada, Navigation 2.3 y las versiones posteriores proporcionan un TestNavHostController que brinda opciones de APIs para configurar el destino actual y verificar la pila de actividades después de las operaciones de NavController.navigate().

Puedes agregar el artefacto de prueba Navigation a tu proyecto si agregas la siguiente dependencia al archivo build.gradle del módulo de tu app:

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

Supongamos que compilas un juego de preguntas y respuestas. El juego comienza con title_screen y navega a una pantalla in_game cuando el usuario hace clic en Play.

El fragmento que representa title_screen podría verse de la siguiente manera:

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

Para probar que la app lleve al usuario sin inconvenientes a la pantalla in_game cuando hace clic en Play, tu prueba debe verificar que este fragmento mueva correctamente el NavController a la pantalla R.id.in_game.

Con una combinación del elemento FragmentScenario, Espresso y TestNavHostController, puedes recrear las condiciones necesarias para probar esta situación, como se muestra en el siguiente ejemplo:

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

En el ejemplo anterior, se crea una instancia de TestNavHostController y se la asigna al fragmento. Luego, se utiliza Espresso para controlar la IU y se verifica que se realice la acción de navegación adecuada.

Al igual que con un NavController real, debes invocar a setGraph para inicializar el TestNavHostController. En este ejemplo, el fragmento que se probaba era el destino de inicio de nuestro gráfico. El TestNavHostController proporciona un método de setCurrentDestination que te permite configurar el destino actual (y, opcionalmente, los argumentos para ese destino) de modo que el NavController esté en el estado correcto antes de que comience la prueba.

A diferencia la instancia de NavHostController que usaría un NavHostFragment, el TestNavHostController no activa el comportamiento subyacente de navigate() (como la FragmentTransaction que ejecuta FragmentNavigator) cuando invoques navigate(), solo se actualizará el estado del TestNavHostController.

Cómo probar NavigationUI con FragmentScenario

En el ejemplo anterior, la devolución de llamada proporcionada a titleScenario.onFragment() se llama una vez que el fragmento avanzó en su ciclo de vida al estado RESUMED. En ese momento, ya se creó y adjuntó la vista del fragmento, de manera que podría ser demasiado tarde en el ciclo de vida para realizar la prueba correctamente. Por ejemplo, cuando usas NavigationUI con vistas en tu fragmento, por ejemplo, con una Toolbar controlada por este fragmento, puedes evocar métodos de configuración con tu NavController antes de que el fragmento llegue al estado RESUMED. Por lo tanto, necesitas una manera de definir el TestNavHostController en un momento anterior del ciclo de vida.

Un fragmento que posee su propia Toolbar se puede escribir de la siguiente manera:

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

En este caso, necesitamos tener el NavController creado antes de llamar aonViewCreated(). Si usas el enfoque anterior de onFragment(), el elemento TestNavHostController se establecería demasiado tarde en el ciclo de vida, provocando que la llamada del findNavController() fallara.

El objeto FragmentScenario ofrece una interfaz de FragmentFactory, que se puede utilizar a fin de registrar devoluciones de llamada para eventos del ciclo de vida. Esto también se puede combinar con Fragment.getViewLifecycleOwnerLiveData() a fin de recibir una devolución de llamada que siga inmediatamente al elemento onCreateView(), como se muestra en el siguiente ejemplo:

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

Si usas esta técnica, el elemento NavController estará disponible antes de llamar a onViewCreated(), lo que permitirá que el fragmento utilice métodos de NavigationUI sin fallar.

Cómo probar interacciones con entradas de la pila de actividades

Cuando debas interactuar con las entradas de la pila de actividades, TestNavHostController te permitirá conectar el controlador a tus propios elementos LifecycleOwner, ViewModelStore y OnBackPressedDispatcher de prueba mediante el uso de las API que haya heredado de NavHostController.

Por ejemplo, cuando pruebes un fragmento que use un ViewModel con alcance de navegación, deberás llamar a setViewModelStore en el 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())