How to add androidTest to a fragment using conditional navigation?

Viewed 398

I'm trying to add test navigation to the example present in the documentation of conditional navigation.

I created a demo following the example and published it on github.

The demo contains 3 fragments: HomeFragment, ProfileFragment and LoginFragment. In the ProfileFragment there is a condition checking if the user is connected or not. If the user is not connected he is sent to the LoginFragment.

ProfileFragment

@AndroidEntryPoint
class ProfileFragment : Fragment(R.layout.fragment_profile) {
    private val mainViewModel: MainViewModel by activityViewModels()
    private val navController by lazy { findNavController() }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val currentBackStackEntry = navController.currentBackStackEntry!!
        currentBackStackEntry.savedStateHandle.getLiveData<Boolean>(LoginFragment.LOGIN_SUCCESSFUL)
            .observe(currentBackStackEntry, { success ->
                if (!success) {
                    val startDestination = navController.graph.startDestination
                    val navOptions = NavOptions.Builder()
                        .setPopUpTo(startDestination, true)
                        .build()
                    navController.navigate(startDestination, null, navOptions)
                }
            })
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        mainViewModel.user.observe(viewLifecycleOwner, {
            if(!it){
                navController.navigate(R.id.loginFragment)
            }
        })
    }
}

As explained in the documentation, in the onCreate() method I'm observing the LOGIN_SUCCESSFUL value stored in the SavedStateHandle in order to redirect the user if he didn't log in.

LoginFragment

@AndroidEntryPoint
class LoginFragment : Fragment() {
    companion object {
        const val LOGIN_SUCCESSFUL: String = "LOGIN_SUCCESSFUL"
    }

    private val viewModel: LoginViewModel by viewModels()
    private val navController by lazy { findNavController() }

    private lateinit var savedStateHandle: SavedStateHandle
    private lateinit var binding: FragmentLoginBinding

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        binding = DataBindingUtil.inflate(inflater, R.layout.fragment_login, container, false)
        binding.viewModel = viewModel
        binding.lifecycleOwner = this
        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        savedStateHandle = navController.previousBackStackEntry!!.savedStateHandle
        savedStateHandle.set(LOGIN_SUCCESSFUL, false)

        viewModel.login.observe(viewLifecycleOwner, ResultObserver {
            if (it.status == Status.ERROR) {
                Snackbar.make(requireView(), it.message ?: "Error", Snackbar.LENGTH_SHORT).show()
            } else if (it.status == Status.SUCCESS) {
                savedStateHandle.set(LOGIN_SUCCESSFUL, true)
                navController.popBackStack()
            }
        })
    }
}

Testing

@Test
fun profileFragment_unauthenticated() {
    setAuthentication(false)

    val navController = TestNavHostController(ApplicationProvider.getApplicationContext())

    launchFragmentInHiltContainer<ProfileFragment> {
        navController.setViewModelStore(ViewModelStore())
        navController.setGraph(R.navigation.nav_main)
        navController.setCurrentDestination(R.id.profileFragment)
        Navigation.setViewNavController(requireView(), navController)
    }
    assertEquals(navController.currentDestination?.id, R.id.loginFragment)
}

Because I'm using Hilt for dependencies injection I need to attach the fragment to an activity annotated with @AndroidEntryPoint: launchFragmentInHiltContainer

During the test, I'm creating an instance of TestNavHostController and assigning it to the fragment.

The problem is that I am accessing the navController in the onCreate method while the navController is instantiated during the RESUMED state (see FragmentScenario). Causing a java.lang.IllegalStateException: Fragment ProfileFragment ... does not have a NavController set

I know it's possible to make the navController available sooner by adding an observer to the fragment's viewLifecycleOwnerLiveData. However, it is not soon enough.

So, how to implement test navigation in such a case?

Possible solution

After several days looking for a way to add test to my fragment I thought I found a solution. The idea was to attach the navController to the activity. However, I'm not totally satisfied with the result, that is why I'm not posting this solution as an answer. For anyone interested I added my solution to the github repository.

1 Answers

I know it's possible to make the navController available sooner by adding an observer to the fragment's viewLifecycleOwnerLiveData. However, it is not soon enough.

Actually you applied it in wrong way.

As google documentation mention here https://developer.android.com/guide/navigation/navigation-testing#test_navigationui_with_fragmentscenario

They used launchFragmentInContainer which has a FragmentFactory for control the instantiation of Fragment instance.

This is how you creating the fragment by using activity.supportFragmentManager.fragmentFactory

   val fragment: Fragment = activity.supportFragmentManager.fragmentFactory.instantiate(
        Preconditions.checkNotNull(T::class.java.classLoader),
        T::class.java.name
    )

Replace activity.supportFragmentManager.fragmentFactory with

 val fragmentFactory = object : FragmentFactory() {
        override fun instantiate(
            classLoader: ClassLoader,
            className: String
        ) = when (className) {
            T::class.java.name -> action()
            else -> super.instantiate(classLoader, className)
        }
    }

So you will have fragment creation like this

 val fragment: Fragment = fragmentFactory.instantiate(
        Preconditions.checkNotNull(T::class.java.classLoader),
        T::class.java.name
    )

Replace crossinline action: Activity.() -> Unit = {}

with crossinline action: () -> T

Remove activity.action() because its already used in FragmentFactory as you can see above

So

launchFragmentInHiltContainer()

will look like this

inline fun <reified T : Fragment> launchFragmentInHiltContainer(
fragmentArgs: Bundle? = null,
@StyleRes themeResId: Int = R.style.FragmentScenarioEmptyFragmentActivityTheme,
crossinline action: () -> T) {
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 fragmentFactory = object : FragmentFactory() {
        override fun instantiate(
            classLoader: ClassLoader,
            className: String
        ) = when (className) {
            T::class.java.name -> action()
            else -> super.instantiate(classLoader, className)
        }
    }

    val fragment: Fragment = 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()
} }

Now you can use same code that provided by google here with hilt injection as the following

       launchFragmentInHiltContainer {
       ProfileFragment().also { fragment ->
            fragment.viewLifecycleOwnerLiveData.observeForever { viewLifecycleOwner ->
                if (viewLifecycleOwner != null) {
                    navController.setViewModelStore(ViewModelStore())
                    navController.setGraph(R.navigation.nav_main)
                    navController.setCurrentDestination(R.id.profileFragment)
                    Navigation.setViewNavController(requireView(), navController)
                }
            }
        }
    }
Related