Search code examples
androidkotlinandroid-fragmentsandroid-architecture-navigation

Testing an Android Fragment that uses Navigation


I am writing a test for a fragment that uses the Navigation API. Ideally I would like to test the fragment in isolation with something like this:

@RunWith(AndroidJUnit4::class)
class TestDetailFragment {
    private lateinit var dao: MyDao
    private lateinit var db: MyDatabase
    private lateinit var instrumentation: Instrumentation

    @Before
    fun createDb() {
        val context = ApplicationProvider.getApplicationContext<Context>()
        instrumentation = InstrumentationRegistry.getInstrumentation()
        db = Room.inMemoryDatabaseBuilder(
            context, MyDatabase::class.java).build()
        dao = db.getDao()
    }

    @After
    @Throws(IOException::class)
    fun closeDb() {
        db.close()
    }

    @Test
    fun testCreatePosition() {
        val input = "testing input"
        val entity = MyEntity(1, input)

        launchFragmentInContainer<DetailsFragment>()

        onView(withId(R.id.text_input))
            .perform(
                typeText(input),
                closeSoftKeyboard()
            )
        onView(withId(R.id.save)).perform(click())

        instrumentation.runOnMainSync {
            val liveEntities = dao.getEntities()
            liveEntities .observeForever { entities->
                assertThat(entities.get(0)).isEqualTo(entity)
            }
        }
    }
}

The problem is that the onClick listener for my save button uses the app's nav controller to return to the master list view:

class DetailsFragment: Fragment() {

    override fun onCreateView(
            inflater: LayoutInflater, container: ViewGroup?,
            savedInstanceState: Bundle?
    ): View? {
        // Inflate the layout for this fragment
        return inflater.inflate(R.layout.details, container, false)
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        view.findViewById<FloatingActionButton>(R.id.save).setOnClickListener {
            val db = activity?.let { MyDatabase.getDatabase(it) }!!
            val dao = db.getDao()

            val input = view.findViewById<EditText>(R.id.text_input).text.toString()
            val entity = MyEntity(null, input)
            db.queryExecutor.execute{
                dao.insert(entity )
            }

            findNavController().navigate(R.id.list)
        }
    }
}

Now when I run my test, I get the following error:

java.lang.IllegalStateException: View androidx.constraintlayout.widget.ConstraintLayout{42158d7 V.E...... ........ 0,0-1440,2562} does not have a NavController set

This makes sense since my test is loading the fragment in isolation and not using the activity from my app nor the navigation graph. After a little digging, I found TestNavHostController and modified my test as follows:

@RunWith(AndroidJUnit4::class)
class TestDetailFragment {
    private lateinit var dao: MyDao
    private lateinit var db: MyDatabase
    private lateinit var instrumentation: Instrumentation

    @Before
    fun createDb() {
        val context = ApplicationProvider.getApplicationContext<Context>()
        instrumentation = InstrumentationRegistry.getInstrumentation()
        db = Room.inMemoryDatabaseBuilder(
            context, MyDatabase::class.java).build()
        dao = db.getDao()
    }

    @After
    @Throws(IOException::class)
    fun closeDb() {
        db.close()
    }

    @Test
    fun testCreatePosition() {
        val input = "testing input"
        val entity = MyEntity(1, input)

        val detailsScenario = launchFragmentInContainer<DetailsFragment>()

        val navController = TestNavHostController(
            ApplicationProvider.getApplicationContext()
        )
        detailsScenario.onFragment { fragment ->
            navController.setGraph(R.navigation.nav_graph)
            Navigation.setViewNavController(fragment.requireView(), navController)
        }

        onView(withId(R.id.text_input))
            .perform(
                typeText(input),
                closeSoftKeyboard()
            )
        onView(withId(R.id.save)).perform(click())

        instrumentation.runOnMainSync {
            val liveEntities = dao.getEntities()
            liveEntities .observeForever { entities->
                assertThat(entities.get(0)).isEqualTo(entity)
            }
        }
    }
}

This solves the error about a missing NavController, but now I get

java.lang.IllegalArgumentException: Navigation action/destination com.example:id/list cannot be found from the current destination NavDestination(com.example:id/ListFragment) label=List Fragment

How do I proceed from here? Do I mock out the NavDestination for my test? This seems like a lot of effort for a test that is intended to assert that the object was created in the database. I plan to test navigation separately.

I realize I'm probably deep down the Y path of my XY problem here. Ultimately, my question is how do I test my fragment when the onClick handler requires a NavController. Is my approach along the right lines? Or is there a better way? If not, what do I do instead? Is there a good way to modify the onClick handler to not require the NavController directly? Or to inject it somehow?


Solution

  • The error message says you're on the listFragment destination, which is not the destination with your list action.

    TestNavHostController has a second method setCurrentDestination() for precisely this reason as explained in the Testing Navigation guide:

    TestNavHostController provides a setCurrentDestination method that allows you to set the current destination (and optionally, arguments for that destination) so that the NavController is in the correct state before your test begins.

    detailsScenario.onFragment { fragment ->
        navController.setGraph(R.navigation.nav_graph)
        // Use whatever destination ID is associated with your DeatilFragment
        navController.setCurrentDestination(R.id.detail)
        Navigation.setViewNavController(fragment.requireView(), navController)
    }