Android UI testing: Why LiveData's observers are not being called?

柔情痞子 提交于 2021-01-29 16:17:35

问题


I have been trying, without success, to do some UI tests on Android. My app follows the MVVM architecture and uses Koin for DI.

I followed this tutorial to properly set up a UI test for a Fragment with Koin, MockK and Kakao.

I created the custom rule for injecting mocks, setup the ViewModel, and on the @Before call, run the expected answers and returns with MockK. The problem is that, even when the fragment's viewmodel's LiveData object is the same as the testing class's LiveData object, the Observer's onChange is never triggered on the Fragment.

I run the test with the debugger and it seems the LiveData functions and MockK's answers are properly called. The logs show that the value hold by the LiveData objects is the same. The lifecycle of the Fragment when the test is running is Lifecycle.RESUMED. So why is the Observer's onChange(T) not being triggered?

The custom rule:

@VisibleForTesting(otherwise = VisibleForTesting.NONE)
abstract class FragmentTestRule<F : Fragment> :
    ActivityTestRule<FragmentActivity>(FragmentActivity::class.java, true, true) {

    override fun afterActivityLaunched() {
        super.afterActivityLaunched()
        activity.runOnUiThread {
            val fm = activity.supportFragmentManager
            val transaction = fm.beginTransaction()

            transaction.replace(
                android.R.id.content,
                createFragment()
            ).commit()
        }
    }

    override fun beforeActivityLaunched() {
        super.beforeActivityLaunched()
        val app = InstrumentationRegistry.getInstrumentation()
            .targetContext.applicationContext as VideoWorldTestApp

        app.injectModules(getModules())
    }

    protected abstract fun createFragment(): F

    protected abstract fun getModules(): List<Module>

    fun launch() {
        launchActivity(Intent())
    }


}

   @VisibleForTesting(otherwise = VisibleForTesting.NONE)
   fun <F : Fragment> createRule(fragment: F, vararg module: Module): FragmentTestRule<F> =
    object : FragmentTestRule<F>() {
        override fun createFragment(): F = fragment
        override fun getModules(): List<Module> = module.toList()
    }

My test App:

@VisibleForTesting(otherwise = VisibleForTesting.NONE)
class VideoWorldTestApp: Application(){

    companion object {
        lateinit var instance: VideoWorldTestApp
    }

    override fun onCreate() {
        super.onCreate()
        instance = this

        startKoin {
            if (BuildConfig.DEBUG) androidLogger(Level.DEBUG) else EmptyLogger()
            androidContext(this@VideoWorldTestApp)
            modules(emptyList())
        }
        Timber.plant(Timber.DebugTree())
    }

    internal fun injectModules(modules: List<Module>) {
        loadKoinModules(modules)
    }

}

The custom test runner:

class CustomTestRunner: AndroidJUnitRunner() {

    override fun newApplication(
        cl: ClassLoader?,
        className: String?,
        context: Context?
    ): Application {
        return super.newApplication(cl, VideoWorldTestApp::class.java.name, context)
    }
}

The test:

@RunWith(AndroidJUnit4ClassRunner::class)
class HomeFragmentTest {


    private val twitchViewModel: TwitchViewModel = mockk(relaxed = true)
    private val userData = MutableLiveData<UserDataResponse>()
    private val fragment = HomeFragment()

    @get:Rule
    var fragmentRule = createRule(fragment, module {
        single(override = true) {
            twitchViewModel
        }
    })
    @get:Rule
    var countingTaskExecutorRule = CountingTaskExecutorRule()

    @Before
    fun setup() {
        val userResponse: UserResponse = mockk()
        every { userResponse.displayName } returns "Rubius"
        every { userResponse.profileImageUrl } returns ""
        every { userResponse.description } returns "Soy streamer"
        every { userResponse.viewCount } returns 5000
        every { twitchViewModel.userData } returns userData as LiveData<UserDataResponse>
        every { twitchViewModel.getUserByInput(any()) }.answers {
            userData.value = UserDataResponse(listOf(userResponse))
        }
    }

    @Test //This one is passing
    fun testInitialViewState() {
        onScreen<HomeScreen> {
            streamerNameTv.containsText("")
            streamerCardContainer.isVisible()
            nameInput.hasEmptyText()
            progressBar.isGone()
        }
    }

    @Test //This one is failing
    fun whenWritingAName_AndPressingTheImeAction_AssertTextChanges() {
        onScreen<HomeScreen> {
            nameInput.typeText("Rubius")
            //nameInput.pressImeAction()
            searchBtn.click()
            verify { twitchViewModel.getUserByInput(any()) } //This passes
            countingTaskExecutorRule.drainTasks(5, TimeUnit.SECONDS)
            streamerNameTv.hasText("Rubius") //Throws exception
            streamerDescp.hasText("Soy streamer")
            streamerCount.hasText("Views: ${5000.formatInt()}}")
        }
    }

}

The fragment being tested:

class HomeFragment : BaseFragment<FragmentHomeBinding>(R.layout.fragment_home) {

    override val bindingFunction: (view: View) -> FragmentHomeBinding
        get() = FragmentHomeBinding::bind


    val twitchViewModel: TwitchViewModel by sharedViewModel()

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

        twitchViewModel.getUserClips("")

        binding.nameInput.setOnEditorActionListener { _, actionId, _ ->
            if(actionId == EditorInfo.IME_ACTION_SEARCH) {
                twitchViewModel.getUserByInput(binding.nameInput.text.toString())
                hideKeyboard()
                return@setOnEditorActionListener true
            }
            return@setOnEditorActionListener false
        }

        binding.searchBtn.setOnClickListener {
            twitchViewModel.getUserByInput(binding.nameInput.text.toString() ?: "")
            hideKeyboard()
        }

        twitchViewModel.userData.observe(viewLifecycleOwner, Observer { data ->
            if (data != null && data.dataList.isNotEmpty()){

                binding.streamerCard.setOnClickListener {
                    findNavController().navigate(R.id.action_homeFragment_to_clipsFragment)
                }

                val streamer = data.dataList[0]
                Picasso.get()
                    .load(streamer.profileImageUrl)
                    .into(binding.profileIv)
                binding.streamerLoginTv.text = streamer.displayName
                binding.streamerDescpTv.text = streamer.description
                binding.streamerViewCountTv.text = "Views: ${streamer.viewCount.formatInt()}"
            }
            else {
                binding.streamerCard.setOnClickListener {  }
            }
        })

        twitchViewModel.errorMessage.observe(viewLifecycleOwner, Observer { msg ->
            showSnackbar(msg)
        })

        twitchViewModel.progressVisibility.observe(viewLifecycleOwner, Observer { visibility ->
            binding.progressBar.visibility = visibility
            binding.cardContent.visibility =
                if(visibility == View.VISIBLE)
                    View.GONE
                else
                    View.VISIBLE
        })

    }
}

The ViewModel:

class TwitchViewModel(private val repository: TwitchRepository): BaseViewModel() {

    private val _userData = MutableLiveData<UserDataResponse>()
    val userData = _userData as LiveData<UserDataResponse>
    private val _userClips = MutableLiveData<UserClipsResponse?>()
    val userClips = _userClips as LiveData<UserClipsResponse?>

    init {
        viewModelScope.launch {
            repository.authUser(this@TwitchViewModel)
        }
    }

    fun currentUserId() = userData.value?.dataList?.get(0)?.id ?: ""


    fun clipsListExists() = userClips.value != null


    fun getUserByInput(input: String){
        viewModelScope.launch {
            _progressVisibility.value = View.VISIBLE
            _userData.value = repository.getUserByName(input, this@TwitchViewModel)
            _progressVisibility.value = View.GONE
        }
    }

    /**
     * @param userId The ID of the Streamer whose clips are gonna fetch. If null, resets
     * If empty, sets the [userClips] value to null.
     */
    fun getUserClips(userId: String){
        if(userId.isEmpty()) {
            _userClips.postValue(null)
            return
        }
        if(userId == currentUserId() && _userClips.value != null) {
            _userClips.postValue(_userClips.value)
            return
        }

        viewModelScope.launch {
            _userClips.value = repository.getUserClips(userId, this@TwitchViewModel)
        }
    }
}

When running the test with the normal ActivityRule and launching the Activity as it were a normal launch, the observers are triggering successfully. I'm using a relaxed mock to avoid having to mock all functions and variables.


回答1:


Finally found the problem and the solution with the debugger. Apparently, the @Before function call runs after the ViewModel is injected into the fragment, so even if the variables pointed to the same reference, mocked answer where executing only in the test context, not in the android context.

I changed the ViewModel initialization to the module scope like this:

@get:Rule
val fragmentRule = createRule(fragment, module {
    single(override = true) {
        makeMocks()
        val twitchViewModel = mockViewModel()
        twitchViewModel
    }
})

private fun makeMocks() {
    mockkStatic(Picasso::class)
}

private fun mockViewModel(): TwitchViewModel {
    val userData = MutableLiveData<UserDataResponse>()
    val twitchViewModel = mockk<TwitchViewModel>(relaxed = true)
    every { twitchViewModel.userData } returns userData
    every { twitchViewModel.getUserByInput("Rubius") }.answers {
        updateUserDataLiveData(userData)
    }

    return twitchViewModel
}

And the Observer inside the Fragment got called!

Maybe it's not related, but I could not rebuild the gradle project if I have mockk(v1.10.0) as a testImplementation and as a debugImplementation.



来源:https://stackoverflow.com/questions/62537783/android-ui-testing-why-livedatas-observers-are-not-being-called

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!