测试导航

在发布之前,测试应用的导航逻辑非常重要,以验证您的应用是否按预期运行。

导航组件负责处理目标之间的导航管理、参数传递以及与 FragmentManager 的协作。这些功能已经过严格测试,因此无需在您的应用中再次测试它们。然而,重要的是测试您的 Fragment 中应用特定代码与其 NavController 的交互。本指南将介绍一些常见的导航场景以及如何测试它们。

测试 Fragment 导航

要单独测试 Fragment 与其 NavController 的交互,Navigation 2.3 及更高版本提供了 TestNavHostController,它提供了用于设置当前目标并在 NavController.navigate() 操作后验证返回堆栈的 API。

您可以通过在应用模块的 build.gradle 文件中添加以下依赖项,将 Navigation Testing 工件添加到您的项目

Groovy

dependencies {
  def nav_version = "2.9.0"

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

Kotlin

dependencies {
  val nav_version = "2.9.0"

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

假设您正在构建一个问答游戏。游戏从 title_screen 开始,当用户点击“玩”时导航到 in_game 屏幕。

代表 title_screen 的 Fragment 可能如下所示

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

要测试应用在用户点击“”时是否正确地将用户导航到 in_game 屏幕,您的测试需要验证此 Fragment 是否正确地将 NavController 移动到 R.id.in_game 屏幕。

结合使用 FragmentScenarioEspressoTestNavHostController,您可以重新创建测试此场景所需的条件,如以下示例所示

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

以上示例创建了一个 TestNavHostController 实例并将其分配给 Fragment。然后,它使用 Espresso 来驱动界面,并验证是否执行了适当的导航操作。

就像真正的 NavController 一样,您必须调用 setGraph 来初始化 TestNavHostController。在此示例中,被测试的 Fragment 是我们图表的起始目标。TestNavHostController 提供了一个 setCurrentDestination 方法,允许您设置当前目标(以及可选的该目标的参数),以便在测试开始之前 NavController 处于正确状态。

NavHostFragment 将使用的 NavHostController 实例不同,当您调用 navigate() 时,TestNavHostController 不会触发底层 navigate() 行为(例如 FragmentNavigator 执行的 FragmentTransaction) - 它只会更新 TestNavHostController 的状态。

使用 FragmentScenario 测试 NavigationUI

在前面的示例中,提供给 titleScenario.onFragment() 的回调在 Fragment 的生命周期转换到 RESUMED 状态后才会被调用。此时,Fragment 的视图已经创建并附加,因此在生命周期中可能为时已晚,无法正确测试。例如,当您在 Fragment 中使用 NavigationUI 和视图(例如由 Fragment 控制的 Toolbar)时,您可以在 Fragment 达到 RESUMED 状态之前,使用您的 NavController 调用设置方法。因此,您需要一种在生命周期早期设置 TestNavHostController 的方法。

拥有自己 Toolbar 的 Fragment 可以如下编写

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

这里我们需要在调用 onViewCreated() 时创建 NavController。使用之前 onFragment() 的方法,将会在生命周期中设置我们的 TestNavHostController 过晚,导致 findNavController() 调用失败。

FragmentScenario 提供了一个 FragmentFactory 接口,可用于注册生命周期事件的回调。这可以与 Fragment.getViewLifecycleOwnerLiveData() 结合使用,以接收紧随 onCreateView() 之后的回调,如以下示例所示

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

通过使用此技术,NavController 在调用 onViewCreated() 之前就可用,从而允许 Fragment 使用 NavigationUI 方法而不会崩溃。

测试与返回堆栈条目的交互

与返回堆栈条目交互时,TestNavHostController 允许您通过使用它从 NavHostController 继承的 API,将控制器连接到您自己的测试 LifecycleOwnerViewModelStoreOnBackPressedDispatcher

例如,在测试使用 导航作用域 ViewModel 的 Fragment 时,您必须在 TestNavHostController 上调用 setViewModelStore

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