18、导航测试:测试导航动作、测试Safe Args生成的Direction、使用Navigation测试库、编写UI测试

导航组件用起来确实爽,但有个问题——你怎么保证它真的按你设计的路线走?

我见过太多项目,导航图画得漂漂亮亮,一跑起来就跳错页面。说白了,导航也需要测试,而且得系统性地测。

这一章,我们就聊聊怎么给导航写测试。包括测导航动作、测Safe Args生成的Direction、用Navigation测试库,以及写完整的UI测试。

为什么导航需要单独测试?

你可能会想:「我点个按钮跳转页面,手动测一下不就行了?」

嗯,小项目确实可以。但项目一大,页面跳转链路复杂起来,手动测试就变成噩梦了。我有个项目,导航图里嵌套了3层graph,光手动验证一遍就要20分钟。

更关键的是,导航测试能帮你捕获两类问题:

  • 路由错误:目标页面ID写错了,或者参数类型不匹配
  • 状态丢失:跳转过程中ViewModel的状态没正确传递

这两类问题,靠肉眼很难发现。但写个测试用例,几秒钟就能跑出来。

测试导航动作

导航动作,说白了就是你在NavGraph里定义的<action>标签。比如从A页面跳到B页面。

测试导航动作,核心思路是:模拟一个NavController,然后触发导航,验证是否跳到了正确的目的地。

核心思路:使用TestNavHostController替代真实的NavController,然后验证currentDestinationbackStack

来看一个例子。假设我们有一个HomeFragment,点击按钮后导航到DetailFragment

// 在测试中
@RunWith(AndroidJUnit4::class)
class NavigationTest {

    @get:Rule
    val navComposeTestRule = createComposeRule()

    private lateinit var navController: TestNavHostController

    @Before
    fun setup() {
        navController = TestNavHostController(
            ApplicationProvider.getApplicationContext()
        )
        navController.setGraph(R.navigation.nav_graph)
    }

    @Test
    fun testNavigateToDetail() {
        // 模拟点击导航按钮
        composeTestRule.onNodeWithText("查看详情").performClick()

        // 验证是否跳到了DetailFragment
        assertEquals(
            R.id.detailFragment,
            navController.currentDestination?.id
        )
    }
}

这里有个坑——TestNavHostController需要手动设置graph,否则它不知道你的导航图长什么样。我刚开始用的时候忘了这步,结果测试一直报空指针,排查了半天。

测试Safe Args生成的Direction

Safe Args是导航组件的精华。它帮你生成类型安全的Direction类,避免传参时拼错key或者类型不匹配。

但问题来了——你怎么知道生成的Direction是对的?

嗯,其实Safe Args生成的代码本身是可靠的,但你的使用方式可能有问题。比如:

  • 传了错误的参数值
  • 参数类型不匹配(比如传了String但期望Int)
  • 忘记传必填参数

测试Direction,我一般这样做:

@Test
fun testSafeArgsDirection() {
    // 构造一个Direction
    val direction = HomeFragmentDirections.actionHomeToDetail(
        itemId = 42,
        itemName = "测试商品"
    )

    // 验证参数是否正确
    assertEquals(42, direction.arguments?.getInt("itemId"))
    assertEquals("测试商品", direction.arguments?.getString("itemName"))
}

你可能会问:「这测了有啥用?不就是验证getter吗?」

有用。我遇到过一种情况:重构时改了参数名,但忘了更新调用方。Safe Args编译期会报错,但如果你用了反射或者动态传参,编译期就抓不到了。这时候单元测试能帮你兜底。

小技巧:如果你用navArgs()在Fragment里取参数,可以写一个测试验证参数解析是否正确。我曾经因为参数默认值没设置对,导致线上页面白屏,后来加了这层测试就再也没出过问题。

使用Navigation测试库

Google官方提供了一个navigation-testing库,里面有几个好东西:

  • TestNavHostController:模拟NavController,支持设置graph和验证导航
  • NavControllerSpy:可以监听导航事件,验证是否调用了navigate()
  • NavDeepLinkBuilder:用于测试Deep Link导航

引入依赖:

// build.gradle (app)
androidTestImplementation "androidx.navigation:navigation-testing:2.7.7"

使用NavControllerSpy的例子:

@Test
fun testNavigationWithSpy() {
    val spy = NavControllerSpy(
        ApplicationProvider.getApplicationContext()
    )
    spy.setGraph(R.navigation.nav_graph)

    // 执行导航
    spy.navigate(R.id.detailFragment)

    // 验证是否调用了navigate
    assertTrue(spy.navigateCallCount > 0)
    assertEquals(R.id.detailFragment, spy.lastNavigatedTo)
}

这个NavControllerSpy特别适合验证「导航是否被触发」,而不关心具体跳到了哪里。比如你想测试某个按钮是否绑定了正确的点击事件。

编写UI测试

UI测试是最后一道防线。它模拟用户操作,验证整个导航流程是否正常。

我一般用FragmentScenario或者ActivityScenario来启动Fragment,然后模拟点击、滑动等操作。

来看一个完整的UI测试例子:

@RunWith(AndroidJUnit4::class)
class NavigationUiTest {

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

    @Test
    fun testFullNavigationFlow() {
        // 启动Activity
        val scenario = ActivityScenario.launch(MainActivity::class.java)

        scenario.onActivity { activity ->
            // 获取NavController
            val navController = activity.findNavController(R.id.nav_host_fragment)

            // 验证初始页面
            assertEquals(R.id.homeFragment, navController.currentDestination?.id)

            // 模拟点击跳转
            onView(withId(R.id.btn_to_detail)).perform(click())

            // 验证跳转后的页面
            assertEquals(R.id.detailFragment, navController.currentDestination?.id)

            // 验证页面上的UI元素
            onView(withId(R.id.tv_title)).check(matches(withText("详情页面")))
        }
    }
}

这里有个关键点——不要直接依赖Fragment的类名。因为Fragment可能被替换或者重构,你应该依赖R.id来验证目的地。

注意:UI测试跑起来比较慢,建议只覆盖核心导航路径。我一般只测3-5条关键路径,比如「首页→详情→支付→成功」这种。细枝末节的跳转交给单元测试就好。

知识体系总览

下面这张图总结了导航测试的核心内容:

导航测试知识体系 导航动作测试 • 使用 TestNavHostController • 验证 currentDestination • 验证 backStack 状态 • 模拟用户点击触发导航 Safe Args Direction测试 • 验证参数类型和值 • 验证必填参数完整性 • 验证 navArgs() 解析 • 捕获重构时的参数变更 Navigation测试库 • TestNavHostController • NavControllerSpy • NavDeepLinkBuilder • 监听导航事件 UI测试 • FragmentScenario / ActivityScenario • 模拟用户操作 • 验证页面UI元素 • 覆盖核心导航路径 单元测试 + UI测试 = 导航可靠性保障

避坑指南

最后分享几个我踩过的坑:

  • 不要用真实NavController测试:真实NavController会尝试加载Fragment,导致测试变慢且容易出错。用TestNavHostController就好。
  • 记得设置graphTestNavHostController不会自动加载你的导航图,必须手动调用setGraph()
  • 参数验证要全面:Safe Args生成的Direction,不仅要验证参数值,还要验证参数类型。我遇到过String传成了Int,编译期没报错,运行时直接崩溃。
  • UI测试不要贪多:UI测试跑一次可能要几十秒,全量跑一遍能花半小时。挑核心路径测,其他交给单元测试。

导航测试这件事,说白了就是给你的导航图加个「安全带」。刚开始写可能觉得麻烦,但一旦出过线上事故,你就会发现——这时间花得值。

我的习惯:每次新增一个导航动作,顺手写一个测试。这样积少成多,测试覆盖率自然就上去了。别等到项目后期再补,那时候补测试比写新代码还痛苦。


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