测试导航

请务必在发布应用之前测试其导航逻辑,以便验证应用能否按预期运行。

Navigation 组件可以处理以下所有工作:管理目的地之间的导航、传递参数和使用 FragmentManager。这些功能已经过严格测试,因此您无需在应用中重新测试它们。但是,务必需要测试的是 Fragment 中的应用专用代码与 Fragment 的 NavController 之间的互动情况。本指南详细介绍了一些常见导航情形及其测试方式。

测试 fragment 导航

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

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

Groovy

dependencies {
  def nav_version = "2.8.4"

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

Kotlin

dependencies {
  val nav_version = "2.8.4"

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

假设您需要构建一个知识问答游戏。游戏从 title_screen 开始,当用户点击“PLAY”时会转到 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);
        });
    }
}

如需测试应用能否在用户点击 Play 时正确地将其导航到 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。在本例中,被测 Fragement 便是图表的起始目的地。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;
    }
});

通过这种方法,NavControlleronViewCreated() 被调用之前就已可用,因而 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())