Навигация по тестовому фрагменту

Перед выпуском приложения важно протестировать логику навигации, чтобы убедиться, что оно работает так, как вы ожидаете.

Компонент Navigation берет на себя всю работу по управлению навигацией между пунктами назначения, передаче аргументов и работе с FragmentManager . Эти возможности уже тщательно протестированы, поэтому нет необходимости тестировать их повторно в вашем приложении. Однако важно протестировать взаимодействие между специфическим для приложения кодом в ваших фрагментах и ​​их NavController .

Тестирование в изоляции

Для изолированной проверки взаимодействия фрагментов с их NavController , в Navigation 2.3 и более поздних версиях предоставляется TestNavHostController , который обеспечивает API для установки текущего пункта назначения и проверки стека возврата после операций NavController.navigate() .

Добавить артефакт Navigation Testing в свой проект можно, добавив следующую зависимость в файл build.gradle вашего модуля приложения:

Классный

dependencies {
  def nav_version = "2.9.8"

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

Котлин

dependencies {
  val nav_version = "2.9.8"

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

Рассмотрим викторину. Игра начинается с титульного экрана и переходит к внутриигровому экрану, когда пользователь нажимает кнопку «Играть».

Навигация по игре-викторине
Рисунок 1. Схема навигации в викторине.

Фрагмент, представляющий заголовок экрана, может выглядеть примерно так:

Котлин

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

Чтобы проверить, правильно ли приложение перенаправляет пользователя на экран in_game при нажатии кнопки Play , ваш тест должен убедиться, что этот фрагмент корректно перемещает NavController на экран R.id.in_game .

Используя комбинацию FragmentScenario , Espresso и TestNavHostController , вы можете воссоздать условия, необходимые для тестирования этого сценария, как показано в следующем примере:

Котлин

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

В приведенном выше примере создается экземпляр TestNavHostController и присваивается фрагменту. Затем он использует Espresso для управления пользовательским интерфейсом и проверяет, было ли выполнено соответствующее действие навигации.

Как и в случае с настоящим NavController , для инициализации TestNavHostController необходимо вызвать setGraph . В этом примере тестируемый фрагмент являлся начальной точкой назначения нашего графа. TestNavHostController предоставляет метод setCurrentDestination , который позволяет установить текущую точку назначения (и, при необходимости, аргументы для этой точки назначения), чтобы NavController находился в правильном состоянии перед началом теста.

В отличие от экземпляра NavHostController , который используется в NavHostFragment , TestNavHostController не запускает базовое поведение navigate() (например, FragmentTransaction , как в FragmentNavigator ) при вызове navigate() — он только обновляет состояние TestNavHostController .

Протестируйте NavigationUI с помощью FragmentScenario.

В приведенном выше примере функция обратного вызова, передаваемая в titleScenario.onFragment() , вызывается после того, как фрагмент перешел из состояния RESUMED в состояние RESUMED . К этому моменту представление фрагмента уже создано и прикреплено, поэтому тестирование может быть проведено слишком поздно в жизненном цикле. Например, при использовании NavigationUI с представлениями во фрагменте, такими как Toolbar , управляемая фрагментом, вы можете вызывать методы настройки вашего NavController до того, как фрагмент достигнет состояния RESUMED . Таким образом, вам нужен способ установить ваш TestNavHostController раньше в жизненном цикле.

Фрагмент, имеющий собственную Toolbar можно записать следующим образом:

Котлин

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

Здесь нам нужен NavController созданный к моменту вызова onViewCreated() . Использование предыдущего подхода с onFragment() установило бы наш TestNavHostController слишком поздно в жизненном цикле, что привело бы к сбою вызова findNavController() .

FragmentScenario предоставляет интерфейс FragmentFactory , который можно использовать для регистрации коллбэков для событий жизненного цикла. Его можно комбинировать с Fragment.getViewLifecycleOwnerLiveData() для получения коллбэка, который следует непосредственно за onCreateView() , как показано в следующем примере:

Котлин

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

Благодаря этому методу NavController становится доступен до вызова onViewCreated() , что позволяет фрагменту использовать методы NavigationUI без сбоев.

Проверка взаимодействия с элементами стека возврата

При взаимодействии с элементами стека возврата TestNavHostController позволяет связать контроллер с собственным LifecycleOwner , ViewModelStore и OnBackPressedDispatcher вашего теста, используя API, унаследованные от NavHostController .

Например, при тестировании фрагмента, использующего ViewModel с областью видимости навигации , необходимо вызвать setViewModelStore в TestNavHostController :

Котлин

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())