Search code examples
androiddata-bindingandroid-espressomockkandroid-instrumentation

Proper Espresso test setup for Fragment with RecyclerView using Databinding


I've been facing an issue when trying to run instrumented tests for a fragment.

With the code below, the test starts but looks like it never launches the fragment, gets stuck on loading. I need to cancel it in order to stop running.

It appears to be something related to the Adapter, but I'm not sure.

class HomeFragment(
    private val viewModel: HomeViewModel
) : BaseFragment<FragmentHomeBinding>(R.layout.fragment_home) {

    override val shouldShowToolbar = false

    override val shouldShowBottomNavigationView = true


    private val authorAdapter = AuthorAdapter {
        findNavController().navigate(
            R.id.action_homeFragment_to_booksFragment,
            bundleOf(ARG_SEARCH_TERM to it)
        )
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        initAdapter()
        initObservers()
    
        viewModel.fetchGreatestAuthors()
    }

    private fun initAdapter() {
        
        with(binding.fragmentHomeAuthorsRecyclerView) {
            addItemDecoration(DividerItemDecoration(context, LinearLayout.VERTICAL))
            addItemDecoration(DividerItemDecoration(context, LinearLayout.HORIZONTAL))
            adapter = authorAdapter
        }
    }

    private fun initObservers() {
        
        viewModel.getGreatestAuthorsLiveData().observe(viewLifecycleOwner) {
            authorAdapter.setList(it)
        }
        viewModel.getLoadingLiveData().observe(viewLifecycleOwner) {
            binding.loadingProgressBar.isVisible = it
        }
    }
}

BaseFragment and the adapter:

override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        return DataBindingUtil.inflate<VB>(
            inflater,
            layoutRes,
            container,
            false
        ).run {
            binding = this
            root
        }
    }


class AuthorAdapter(
    private val onItemClick: (id: String) -> Unit
) : RecyclerView.Adapter<ViewHolder>() {

    private var list: List<Author> = emptyList()

    fun setList(list: List<Author>) {
        this.list = list
        notifyDataSetChanged()
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AuthorViewHolder {
        return DataBindingUtil.inflate<ItemAuthorBinding>(
            LayoutInflater.from(parent.context),
            R.layout.item_author,
            parent,
            false
        ).run {
            AuthorViewHolder(this)
        }
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        (holder as? AuthorViewHolder)?.bind(list[position])
    }

    override fun getItemCount() = list.size

    inner class AuthorViewHolder(private val binding: ItemAuthorBinding) :
        ViewHolder(binding.root) {

        fun bind(item: Author) {
            binding.author = item
            binding.root.setOnClickListener { onItemClick(item.fullName) }
        }
    }
}

The test class that is running into the issue:

@RunWith(AndroidJUnit4::class)
class HomeFragmentTest {

    private lateinit var viewModelMock: HomeViewModel

    @get:Rule
    val instantTestExecutorSchedule = InstantTaskExecutorRule()

    
    private val authorsLiveDataMock = MutableLiveData<List<Author>>()
    private val loadingLiveDataMock = MutableLiveData<Boolean>()

    @Before
    fun setup() {
        setupDataBindingMocks()

        viewModelMock = mockk<HomeViewModel> {
            every { getGreatestAuthorsLiveData() } returns authorsLiveDataMock
            every { fetchGreatestAuthors() } returns run { authorsLiveDataMock.postValue(listOf()) }
            every { getLoadingLiveData() } returns loadingLiveDataMock
        }
    }


    private fun setupDataBindingMocks() {
        mockkStatic(DataBindingUtil::class)

        every {
            DataBindingUtil.inflate<ItemAuthorBinding>(
                any(),
                any(),
                any(),
                any()
            )
        } returns mockk<ItemAuthorBinding>().apply {
            every { root } returns mockk()
        }
    }

    @Test
    fun whenViewCreated_shouldDisplayExpectedViews() {
        launchFragmentInContainer<HomeFragment>(
            factory = MainFragmentFactory(viewModelMock)
        )
        assertViewDisplayed(
            R.id.fragmentHomeAuthorsTitle,
        )
    }

    

If mockk() is replaced with spyk(), then it runs but throws the following error:

io.mockk.MockKException: Can't instantiate proxy via default constructor for class ItemAuthorBinding

Solution

  • It turns out the issue was a missing parameter (themeResId) in the launchFragmentInContainer method.

    Note: Your fragment might require a theme that the test activity doesn't use by default. You can provide your own theme as an additional argument to launch() and launchInContainer().

    Android documentation