【问题标题】:Android UI testing: Why LiveData's observers are not being called?Android UI 测试:为什么没有调用 LiveData 的观察者?
【发布时间】:2020-06-23 14:58:15
【问题描述】:

我一直在尝试在 Android 上进行一些 UI 测试,但没有成功。 我的应用遵循 MVVM 架构并使用 Koin 进行 DI。

我按照 this tutorial 正确设置了一个带有 Koin、MockK 和 Kakao 的 Fragment 的 UI 测试。

我创建了用于注入模拟的自定义规则,设置 ViewModel,并在 @Before 调用上,运行预期的答案并使用 MockK 返回。问题在于,即使 Fragment 的 viewmodel 的 LiveData 对象与测试类的 LiveData 对象相同,也不会在 Fragment 上触发 Observer 的 onChange。

我使用调试器运行测试,似乎 LiveData 函数和 MockK 的答案被正确调用。日志显示 LiveData 对象持有的值是相同的。测试运行时 Fragment 的生命周期为 Lifecycle.RESUMED。那么为什么 Observer 的 onChange(T) 没有被触发呢?

自定义规则:

@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()
    }

我的测试应用:

@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)
    }

}

自定义测试运行器:

class CustomTestRunner: AndroidJUnitRunner() {

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

测试:

@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()}}")
        }
    }

}

正在测试的片段:

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
        })

    }
}

视图模型:

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)
        }
    }
}

当使用正常的 ActivityRule 运行测试并像正常启动一样启动 Activity 时,观察者触发成功。 我正在使用轻松的模拟来避免模拟所有函数和变量。

【问题讨论】:

    标签: android android-fragments android-viewmodel ui-testing mockk


    【解决方案1】:

    终于用调试器找到了问题和解决方法。显然,@Before 函数调用在 ViewModel 注入片段之后运行,因此即使变量指向相同的引用,模拟答案只在 测试上下文 中执行,而不是在 android 上下文

    我将 ViewModel 初始化更改为模块范围,如下所示:

    @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
    }
    

    片段中的观察者被调用了!

    也许它不相关,但如果我将 mockk(v1.10.0) 作为 testImplementation 和作为 debugImplementation,我无法重建 gradle 项目。

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 2018-07-22
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2018-10-18
      • 1970-01-01
      • 2021-09-11
      • 2019-01-18
      相关资源
      最近更新 更多