问题出现的场景
- 需求:运用
Robolectric
或Espresso
和mockk
对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