问题出现的场景

  • 需求:运用RobolectricEspressomockk 对Fragment进行单元测试
  • 技术栈:MVVM、Fragment中Hilt依靠注入和Jetpack Navigation页面跳转
  • 差异点:ViewModel运用 provideGraphViewModel 绑定一个graph id懒加载
  • 原因: 在Fragment的onCreate()办法中运用ViewModel形成ViewModel在Fragment的view创建之前就调用了findNavController()

FragmentTest代码

@HiltAndroidTest
@RunWith(RobolectricTestRunner::class)
@Config(
    sdk = [Build.VERSION_CODES.O_MR1],
    application = HiltTestApplication::class
)
class XXXFragmentTest {
    @get:Rule
    var hiltRule = HiltAndroidRule(this)
    private val navController = mockk<NavController>(relaxed = true)
    @Before
    fun setUp() {
        hiltRule.inject()
    }
    @Test
    fun testSomeButtonClick() {
        launchFragmentInHiltContainer<XXXFragment>(fragmentArgs = bundle) {
            ShadowLooper.runUiThreadTasks()
            // The fragment’s view has just been created
            Navigation.setViewNavController(requireView(), navController) // 向Fragment中注入Mock方针
            view?.findViewById<ImageButton>(R.id.ivActionBarBack)?.performClick()
            verify { findNavController().navigateUp() } // 获取Fragment#NavController并校验`navigateUp()`办法的调用
        }
    }
}

launchFragmentInHiltContainer的用法详细参考Hilt 测试指南(需求科学上网)

代码如下:

inline fun <reified T : Fragment> launchFragmentInHiltContainer(
    fragmentArgs: Bundle? = null,
    @StyleRes themeResId: Int = R.style.FragmentScenarioEmptyFragmentActivityTheme,
    crossinline action: Fragment.() -> Unit = {}
) {
    val startActivityIntent = Intent.makeMainActivity(ComponentName(ApplicationProvider.getApplicationContext(), HiltTestActivity::class.java))
        .putExtra("androidx.fragment.app.testing.FragmentScenario.EmptyFragmentActivity.THEME_EXTRAS_BUNDLE_KEY", themeResId)
    ActivityScenario.launch<HiltTestActivity>(startActivityIntent).onActivity { activity ->
        val fragment: Fragment = activity.supportFragmentManager.fragmentFactory.instantiate(Preconditions.checkNotNull(T::class.java.classLoader), T::class.java.name)
        fragment.arguments = fragmentArgs
        activity.supportFragmentManager
            .beginTransaction()
            .add(android.R.id.content, fragment, "")
            .commitNow()
        fragment.action()
    }
}

该办法详细完成便是运用一个HiltActivity加载方针Fragment,并在action()回调里执行test代码

调试过程

Fragment XXXFragment does not have a NavController set
at androidx.navigation.fragment.NavHostFragment$Companion.findNavController(NavHostFragment.kt:394)
at androidx.navigation.fragment.FragmentKt.findNavController(Fragment.kt:29)
at com.income.travel.declaration.XXXFragment$viewModel$2$invoke$$inlined$provideGraphViewModel$1.invoke(HiltNavGraphViewModelLazy.kt:49)

源码定位:

@MainThread
public inline fun <reified VM : ViewModel> Fragment.hiltNavGraphViewModels(@IdRes navGraphId: Int): Lazy<VM> {
    val backStackEntry by lazy {
        findNavController().getBackStackEntry(navGraphId) // 原因便是在这里调用了findNavController()
    }
    val storeProducer: () -> ViewModelStore = {
        backStackEntry.viewModelStore
    }
    return createViewModelLazy(VM::class, storeProducer) {
        HiltViewModelFactory(requireActivity(), backStackEntry)
    }
}

在Fragment#onCreate() 办法中调用了ViewModel里的办法,从而形成ViewModel初始化的过程中调用了findNavController()办法
更加深入我们会发现详细的报错方位:

@JvmStatic
public fun findNavController(fragment: Fragment): NavController {
    var findFragment: Fragment? = fragment
    while (findFragment != null) {
        if (findFragment is NavHostFragment) {
            return findFragment.navHostController as NavController
        }
        val primaryNavFragment = findFragment.parentFragmentManager
            .primaryNavigationFragment
        if (primaryNavFragment is NavHostFragment) {
            return primaryNavFragment.navHostController as NavController
        }
        findFragment = findFragment.parentFragment
    }
    // Try looking for one associated with the view instead, if applicable
    val view = fragment.view
    if (view != null) {
        return Navigation.findNavController(view)
    }
    // For DialogFragments, look at the dialog's decor view
    val dialogDecorView = (fragment as? DialogFragment)?.dialog?.window?.decorView
    if (dialogDecorView != null) {
        return Navigation.findNavController(dialogDecorView)
    }
    throw IllegalStateException("Fragment $fragment does not have a NavController set") // 详细报错方位
}

由于是在HiltActivity里直接用FragmentManager加载方针Fragment,所以在Fragment#view创建之前调用findNavController()的话就会报错

解决方案

办法一:把ViewModel的办法调用放到onCreateView()之后调用并提早注入NavController的Test或Mock方针

inline fun <reified T : Fragment> launchFragmentInHiltContainer(
    fragmentArgs: Bundle? = null,
    themeResId: Int = R.style.FragmentScenarioEmptyFragmentActivityTheme,
    fragmentFactory: FragmentFactory? = null,
    navController: NavController? = null,
    crossinline action: T.() -> Unit = {}
) {
    val mainActivityIntent = Intent.makeMainActivity(ComponentName(ApplicationProvider.getApplicationContext(), HiltTestActivity::class.java))
        .putExtra(THEME_EXTRAS_BUNDLE_KEY, themeResId)
    ActivityScenario.launch<HiltTestActivity>(mainActivityIntent).onActivity { activity ->
        fragmentFactory?.let {
            activity.supportFragmentManager.fragmentFactory = it
        }
        val fragment = activity.supportFragmentManager.fragmentFactory.instantiate(Preconditions.checkNotNull(T::class.java.classLoader), T::class.java.name)
        fragment.arguments = fragmentArgs
        fragment.viewLifecycleOwnerLiveData.observeForever { viewLifecycleOwner ->
            if (viewLifecycleOwner != null) {
                navController?.let { Navigation.setViewNavController(fragment.requireView(), it) }
            }
        }
        activity.supportFragmentManager.beginTransaction()
            .add(android.R.id.content, fragment, "")
            .commitNow()
        (fragment as T).action()
    }
}

办法二:参考Navigation的完成,HiltActivity和Fragment之间加一层NavHostFragment的container

详细改造代码如下:

inline fun <reified T : Fragment> launchFragmentInHiltHostContainer(
    fragmentArgs: Bundle? = null,
    graphId: Int,
    destinationId: Int,
    themeResId: Int = R.style.FragmentScenarioEmptyFragmentActivityTheme,
    crossinline action: T.() -> Unit = {}
) {
    val mainActivityIntent = Intent.makeMainActivity(ComponentName(ApplicationProvider.getApplicationContext(), HiltTestActivity::class.java))
        .putExtra(THEME_EXTRAS_BUNDLE_KEY, themeResId)
    ActivityScenario.launch<HiltTestActivity>(mainActivityIntent).onActivity { activity ->
        val hostFragment: NavHostFragment = activity.supportFragmentManager
            .fragmentFactory.instantiate(Preconditions.checkNotNull(NavHostFragment::class.java.classLoader), NavHostFragment::class.java.name) as NavHostFragment
        hostFragment.viewLifecycleOwnerLiveData.observeForever {
            when (it.lifecycle.currentState) {
                Lifecycle.State.RESUMED -> {
                    val graphInflater = hostFragment.navController.navInflater
                    val navGraph: NavGraph = graphInflater.inflate(graphId)
                    navGraph.addInDefaultArgs(fragmentArgs)
                    val navController = hostFragment.navController
                    navGraph.setStartDestination(destinationId)
                    navController.graph = navGraph
                    navController.addOnDestinationChangedListener { _, destination, _ ->
                        if(destination.id == destinationId){
                           val fragment = hostFragment.childFragmentManager.fragments[0]
                            (fragment as T).action()
                        }
                    }
                }
                else -> Unit
            }
        }
        activity.supportFragmentManager.beginTransaction()
            .add(android.R.id.content, hostFragment, "")
            .commitNow()
    }
}

其他可参考 issue android/architecture-samples#752