测试导航

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

导航组件处理管理目标之间导航、传递参数以及与 FragmentManager 协作的所有工作。这些功能已经过严格测试,因此无需在应用中再次测试它们。但是,需要测试的是片段中应用特定代码与其 NavController 之间的交互。本指南介绍了一些常见的导航场景以及如何对其进行测试。

测试片段导航

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

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

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

假设您正在构建一个琐事游戏。游戏从一个 **title_screen** 开始,当用户点击播放时导航到一个 **in_game** 屏幕。

表示 **title_screen** 的片段可能如下所示

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**屏幕,您的测试需要验证此片段是否正确地将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的实例,并将其分配给片段。然后,它使用Espresso驱动UI并验证是否采取了适当的导航操作。

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

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

使用FragmentScenario测试NavigationUI

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

拥有自己的Toolbar的片段可以按如下方式编写。

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()之前可用,允许片段使用NavigationUI方法而不会崩溃。

测试与回退栈条目的交互

与回退栈条目交互时,TestNavHostController允许您将控制器连接到您自己的测试LifecycleOwnerViewModelStoreOnBackPressedDispatcher,方法是使用它从NavHostController继承的API。

例如,当测试使用导航作用域ViewModel的片段时,您必须在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())