Testar navegação

É importante testar a lógica de navegação do seu app antes de enviá-lo para verificar se ele funciona conforme o esperado.

O componente Navigation lida com todo o trabalho de gerenciamento de navegação entre destinos, transmissão de argumentos e trabalhos com FragmentManager. Como esses recursos já foram rigorosamente testados, não é necessário testá-los novamente no app. No entanto, o importante é testar as interações entre o código específico do app nos fragmentos e o respectivo NavController. Este guia apresenta alguns cenários de navegação comuns e como testá-los.

Testar a navegação de fragmentos

Para testar interações de fragmento com o NavController isoladamente, o Navigation 2.3 e versões mais recentes fornecem um TestNavHostController que oferece APIs para definir o destino atual e verificar a backstack depois das operações NavController.navigate().

É possível adicionar o artefato Navigation Testing ao projeto adicionando a seguinte dependência ao arquivo build.gradle do módulo do app:

Groovy

dependencies {
  def nav_version = "2.8.0"

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

Kotlin

dependencies {
  val nav_version = "2.8.0"

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

Digamos que você esteja criando um jogo de curiosidades. O jogo começa com uma title_screen e navega para uma tela in_game quando o usuário clica em "Play".

O fragmento que representa a title_screen pode ter esta aparência:

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 testar se o app direciona o usuário para a tela in_game corretamente quando ele clica em Play, seu teste precisa verificar se esse fragmento move corretamente o NavController para a tela R.id.in_game.

Usando uma combinação de FragmentScenario, Espresso e TestNavHostController, é possível recriar as condições necessárias para testar esse cenário, como mostrado no exemplo a seguir:

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

O exemplo acima cria uma instância de TestNavHostController e a atribui ao fragmento. Em seguida, ele usa o Espresso para direcionar a IU e verificar se a ação de navegação adequada foi realizada.

Assim como um NavController real, você precisa chamar o setGraph para inicializar o TestNavHostController. Nesse exemplo, o fragmento que está sendo testado era o destino inicial do nosso gráfico. TestNavHostController fornece uma setCurrentDestination que permite definir o destino atual (e, opcionalmente, argumentos para esse destino) de modo que o NavController esteja no estado correto antes do início do teste.

Diferentemente de uma instância NavHostController que um NavHostFragment usaria, TestNavHostController não aciona o comportamento subjacente navigate() (como a FragmentTransaction que o FragmentNavigator aciona) quando você chama navigate(), ele apenas atualiza o estado do TestNavHostController.

Testar NavigationUI com FragmentScenario

No exemplo anterior, o callback fornecido para titleScenario.onFragment() é chamado depois que o fragmento passou pelo ciclo de vida para o estado RESUMED. Nesse ponto, a visualização do fragmento já foi criada e anexada. Por isso, pode ser muito tarde no ciclo de vida para ser testada corretamente. Por exemplo, ao usar NavigationUI com visualizações no fragmento, como com um Toolbar controlado pelo fragmento, você pode chamar métodos de configuração com NavController antes que o fragmento alcance o estado RESUMED. Assim, você precisa definir seu TestNavHostController no início do ciclo de vida.

Um fragmento que possui o próprio Toolbar pode ser escrito da seguinte forma:

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

Aqui, precisamos do NavController criado no momento em que o onViewCreated() é chamado. O uso da abordagem anterior de onFragment() definiria nosso TestNavHostController muito tarde no ciclo de vida, causando a falha da chamada de findNavController().

O FragmentScenario oferece uma interface FragmentFactory que pode ser usada para registrar callbacks para eventos de ciclo de vida. Isso pode ser combinado com Fragment.getViewLifecycleOwnerLiveData() para receber um callback imediatamente após onCreateView(), conforme mostrado neste exemplo:

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

Usando essa técnica, o NavController fica disponível antes de onViewCreated() ser chamado, permitindo que o fragmento use métodos NavigationUI sem falhas.

Testar interações com entradas da pilha de retorno

Ao interagir com as entradas da pilha de retorno, o TestNavHostController permite que você conecte o controlador a seus próprios LifecycleOwner, ViewModelStore e OnBackPressedDispatcher de teste usando as APIs herdadas de NavHostController.

Por exemplo, ao testar um fragmento que usa um ViewModel com escopo de navegação, é necessário chamar setViewModelStore no 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())