14、Navigation组件测试:测试导航图、测试NavHost、测试DeepLink、测试返回栈、测试导航参数传递

Navigation 组件,说白了就是帮我们管理 Fragment 跳转的。以前我们写 Intent、写 FragmentTransaction,代码又臭又长,还容易出 bug。Navigation 把这些都封装好了,但问题来了——你怎么保证你画的导航图是对的?DeepLink 能不能正常跳?返回栈会不会乱?

嗯,这就是今天我们要聊的。我会从最基础的导航图测试开始,一步步带你搞定 Navigation 组件的全套测试方案。

核心知识点一览:
  • 测试导航图的结构和合法性
  • 测试 NavHost 的 Fragment 加载
  • 测试 DeepLink 跳转
  • 测试返回栈行为
  • 测试导航参数传递
Navigation 组件测试体系 Navigation 测试 测试导航图 测试 NavHost 测试 DeepLink 测试返回栈 测试导航参数传递 五大测试维度,覆盖 Navigation 组件核心功能 每个维度都有对应的测试工具和断言方法

14.1 测试导航图:别让 XML 坑了你

导航图就是个 XML 文件,里面定义了 Fragment 之间的跳转关系。你想想看,如果 XML 写错了——比如目标 Fragment 不存在、action ID 重复了——那运行时直接崩。所以第一步,我们要验证导航图本身是合法的。

我个人习惯用 NavGraphTestRule 来测。这个规则会加载导航图,然后检查它的结构。

@RunWith(AndroidJUnit4::class)
class NavGraphTest {

    @get:Rule
    val navGraphRule = NavGraphTestRule(
        R.navigation.nav_graph_main
    )

    @Test
    fun testNavGraphHasCorrectStartDestination() {
        // 验证起始目的地
        val startDest = navGraphRule.navGraph.startDestinationId
        assertThat(startDest, `is`(R.id.homeFragment))
    }

    @Test
    fun testNavGraphContainsAllDestinations() {
        // 检查所有 Fragment 是否都在导航图中
        val destinations = navGraphRule.navGraph.nodes.keys
        assertThat(destinations, hasItem(R.id.homeFragment))
        assertThat(destinations, hasItem(R.id.detailFragment))
        assertThat(destinations, hasItem(R.id.settingsFragment))
    }

    @Test
    fun testActionIdsAreUnique() {
        // 检查 action ID 是否重复
        val actions = navGraphRule.navGraph.actions.keys
        val uniqueActions = actions.toSet()
        assertThat(actions.size, `is`(uniqueActions.size))
    }
}
小提示: 我在项目中遇到过导航图里写错了 Fragment 的类名,结果编译能过,运行时直接报 ClassNotFoundException。所以这个测试一定要加,尤其是多人协作的项目。

14.2 测试 NavHost:Fragment 真的加载了吗?

NavHost 是 Navigation 的容器,它负责把 Fragment 塞进去。测试 NavHost 说白了就是验证:当导航发生时,正确的 Fragment 被加载到了正确的位置。

这里我推荐用 NavHostTestRule 或者 FragmentScenario。我个人更倾向后者,因为它更灵活。

@RunWith(AndroidJUnit4::class)
class NavHostTest {

    @get:Rule
    val activityRule = ActivityScenarioRule(MainActivity::class.java)

    @Test
    fun testNavHostLoadsHomeFragment() {
        val scenario = activityRule.scenario
        scenario.onActivity { activity ->
            val navHostFragment = activity.supportFragmentManager
                .findFragmentById(R.id.nav_host_fragment) as NavHostFragment
            val navController = navHostFragment.navController

            // 验证当前 Fragment 是 HomeFragment
            val currentFragment = navHostFragment.childFragmentManager
                .fragments.firstOrNull()
            assertThat(currentFragment, instanceOf(HomeFragment::class.java))
        }
    }

    @Test
    fun testNavigateToDetailFragment() {
        val scenario = activityRule.scenario
        scenario.onActivity { activity ->
            val navController = activity.findNavController(R.id.nav_host_fragment)
            
            // 执行导航
            navController.navigate(R.id.action_home_to_detail)
            
            // 验证当前目的地
            assertThat(
                navController.currentDestination?.id,
                `is`(R.id.detailFragment)
            )
        }
    }
}
注意: 测试 NavHost 时,一定要确保 Activity 已经创建完成。我曾经在 onCreate 还没执行完就去拿 NavController,结果拿了个 null,排查了半天。用 onActivity 回调可以避免这个问题。

14.3 测试 DeepLink:外部跳转稳不稳?

DeepLink 是 Navigation 的一大亮点——你可以通过 URL 或者 Intent 直接跳转到某个 Fragment。但问题来了:DeepLink 的 URI 写对了没?参数传对了没?

测试 DeepLink 的核心思路是:构造一个 Intent,模拟外部应用发起的跳转,然后验证是否到达了正确的目的地。

@RunWith(AndroidJUnit4::class)
class DeepLinkTest {

    @get:Rule
    val activityRule = ActivityScenarioRule(MainActivity::class.java)

    @Test
    fun testDeepLinkOpensDetailFragment() {
        val scenario = activityRule.scenario
        scenario.onActivity { activity ->
            val navController = activity.findNavController(R.id.nav_host_fragment)
            
            // 构造 DeepLink Intent
            val deepLinkIntent = Intent(Intent.ACTION_VIEW)
            deepLinkIntent.data = Uri.parse("myapp://detail/123")
            
            // 处理 DeepLink
            val handled = navController.handleDeepLink(deepLinkIntent)
            
            // 验证 DeepLink 被处理
            assertThat(handled, `is`(true))
            
            // 验证跳转到了 DetailFragment
            assertThat(
                navController.currentDestination?.id,
                `is`(R.id.detailFragment)
            )
        }
    }

    @Test
    fun testDeepLinkWithInvalidUri() {
        val scenario = activityRule.scenario
        scenario.onActivity { activity ->
            val navController = activity.findNavController(R.id.nav_host_fragment)
            
            // 构造一个不存在的 DeepLink
            val invalidIntent = Intent(Intent.ACTION_VIEW)
            invalidIntent.data = Uri.parse("myapp://nonexistent")
            
            val handled = navController.handleDeepLink(invalidIntent)
            
            // 验证 DeepLink 没有被处理
            assertThat(handled, `is`(false))
        }
    }
}
避坑指南: 我曾经在 DeepLink 的 URI 里写错了参数名,结果跳转过去了,但 Fragment 拿到的参数是 null。所以测试 DeepLink 时,一定要同时验证参数是否正确传递。

14.4 测试返回栈:别让用户回不去

返回栈是 Navigation 最容易被忽视的部分。你想想看,用户从 A 跳到 B,再跳到 C,按返回键应该回到 B,而不是直接退出 App。如果返回栈乱了,用户体验直接崩。

测试返回栈,说白了就是验证 popBackStack() 的行为是否符合预期。

@RunWith(AndroidJUnit4::class)
class BackStackTest {

    @get:Rule
    val activityRule = ActivityScenarioRule(MainActivity::class.java)

    @Test
    fun testBackStackReturnsToPreviousFragment() {
        val scenario = activityRule.scenario
        scenario.onActivity { activity ->
            val navController = activity.findNavController(R.id.nav_host_fragment)
            
            // 从 Home 跳到 Detail
            navController.navigate(R.id.action_home_to_detail)
            assertThat(
                navController.currentDestination?.id,
                `is`(R.id.detailFragment)
            )
            
            // 按返回键
            navController.popBackStack()
            
            // 验证回到了 Home
            assertThat(
                navController.currentDestination?.id,
                `is`(R.id.homeFragment)
            )
        }
    }

    @Test
    fun testBackStackWithMultipleNavigations() {
        val scenario = activityRule.scenario
        scenario.onActivity { activity ->
            val navController = activity.findNavController(R.id.nav_host_fragment)
            
            // A -> B -> C
            navController.navigate(R.id.action_home_to_detail)
            navController.navigate(R.id.action_detail_to_settings)
            
            // 验证当前在 C
            assertThat(
                navController.currentDestination?.id,
                `is`(R.id.settingsFragment)
            )
            
            // 返回两次,应该回到 A
            navController.popBackStack()
            navController.popBackStack()
            
            assertThat(
                navController.currentDestination?.id,
                `is`(R.id.homeFragment)
            )
        }
    }

    @Test
    fun testPopUpToClearsBackStack() {
        val scenario = activityRule.scenario
        scenario.onActivity { activity ->
            val navController = activity.findNavController(R.id.nav_host_fragment)
            
            // A -> B -> C
            navController.navigate(R.id.action_home_to_detail)
            navController.navigate(R.id.action_detail_to_settings)
            
            // 使用 popUpTo 回到 A,并清除中间页面
            val navOptions = NavOptions.Builder()
                .setPopUpTo(R.id.homeFragment, inclusive = false)
                .build()
            navController.navigate(R.id.action_settings_to_home, null, navOptions)
            
            // 验证返回栈中只有 A
            val backStack = navController.backStack
            assertThat(backStack.size, `is`(1))
            assertThat(
                backStack.first().destination.id,
                `is`(R.id.homeFragment)
            )
        }
    }
}
注意: popBackStack() 返回的是 Boolean,表示是否成功弹出。如果返回 false,说明已经到栈底了。我在项目中遇到过连续调用 popBackStack() 导致 Fragment 重叠的问题,所以一定要检查返回值。

14.5 测试导航参数传递:数据丢了怎么办?

导航参数传递是 Navigation 的核心功能之一。你从 A Fragment 跳到 B Fragment,总得带点数据过去吧?比如用户 ID、商品详情等等。如果参数传丢了,或者类型不对,那 B Fragment 拿到的就是 null,直接崩。

测试参数传递,核心是验证 NavArgs 是否正确解析。

@RunWith(AndroidJUnit4::class)
class NavigationArgsTest {

    @get:Rule
    val activityRule = ActivityScenarioRule(MainActivity::class.java)

    @Test
    fun testNavigateWithStringArg() {
        val scenario = activityRule.scenario
        scenario.onActivity { activity ->
            val navController = activity.findNavController(R.id.nav_host_fragment)
            
            // 构造参数
            val args = Bundle().apply {
                putString("userId", "12345")
            }
            
            // 导航并传递参数
            navController.navigate(R.id.action_home_to_detail, args)
            
            // 验证参数传递成功
            val currentDestination = navController.currentDestination
            val arguments = currentDestination?.arguments ?: return@onActivity
            
            val userId = arguments.getString("userId")
            assertThat(userId, `is`("12345"))
        }
    }

    @Test
    fun testNavigateWithSafeArgs() {
        val scenario = activityRule.scenario
        scenario.onActivity { activity ->
            val navController = activity.findNavController(R.id.nav_host_fragment)
            
            // 使用 Safe Args 传递参数
            val action = HomeFragmentDirections.actionHomeToDetail("12345")
            navController.navigate(action)
            
            // 验证参数
            val currentDestination = navController.currentDestination
            val arguments = currentDestination?.arguments ?: return@onActivity
            
            val userId = arguments.getString("userId")
            assertThat(userId, `is`("12345"))
        }
    }

    @Test
    fun testNavigateWithMissingArg() {
        val scenario = activityRule.scenario
        scenario.onActivity { activity ->
            val navController = activity.findNavController(R.id.nav_host_fragment)
            
            // 不传参数直接导航
            try {
                navController.navigate(R.id.action_home_to_detail)
                // 如果没抛异常,说明参数是可选的
                // 这里可以根据实际情况断言
            } catch (e: IllegalArgumentException) {
                // 如果参数是必填的,会抛异常
                assertThat(e.message, containsString("userId"))
            }
        }
    }
}
个人经验: 我强烈建议使用 Safe Args 插件来传递参数。它会在编译期生成类型安全的代码,避免手写 Bundle 时拼错 key 或者类型不匹配。我在项目中从手写 Bundle 切换到 Safe Args 后,参数相关的 bug 直接降为零。

14.6 综合测试:模拟完整导航流程

上面我们拆开测了每个环节,但实际项目中,导航是一个完整的流程。我习惯写一个综合测试,模拟用户从 A 到 B 再到 C,然后返回,同时验证参数和返回栈。

@RunWith(AndroidJUnit4::class)
class NavigationIntegrationTest {

    @get:Rule
    val activityRule = ActivityScenarioRule(MainActivity::class.java)

    @Test
    fun testFullNavigationFlow() {
        val scenario = activityRule.scenario
        scenario.onActivity { activity ->
            val navController = activity.findNavController(R.id.nav_host_fragment)
            
            // 1. 从 Home 跳到 Detail,带参数
            val detailArgs = Bundle().apply {
                putString("itemId", "item_001")
            }
            navController.navigate(R.id.action_home_to_detail, detailArgs)
            
            // 验证在 Detail 页面,参数正确
            assertThat(navController.currentDestination?.id, `is`(R.id.detailFragment))
            val detailDest = navController.currentDestination
            assertThat(detailDest?.arguments?.getString("itemId"), `is`("item_001"))
            
            // 2. 从 Detail 跳到 Settings
            navController.navigate(R.id.action_detail_to_settings)
            assertThat(navController.currentDestination?.id, `is`(R.id.settingsFragment))
            
            // 3. 返回两次,回到 Home
            navController.popBackStack() // 回到 Detail
            navController.popBackStack() // 回到 Home
            assertThat(navController.currentDestination?.id, `is`(R.id.homeFragment))
            
            // 4. 验证返回栈只有 Home
            assertThat(navController.backStack.size, `is`(1))
        }
    }
}
总结一下:
  • 导航图测试:验证 XML 结构合法,action ID 不重复
  • NavHost 测试:验证 Fragment 被正确加载
  • DeepLink 测试:验证外部跳转能到达正确目的地
  • 返回栈测试:验证 popBackStack() 行为符合预期
  • 参数传递测试:验证 NavArgs 正确解析

把这些测试写全了,Navigation 组件基本就不会出大问题。嗯,剩下的就是业务逻辑的测试了,那是另一回事。


公众号:蓝海资料掘金营,微信deep3321