Android UI 测试:为什么 LiveData 的观察者没有被调用?

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

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

我按照 this tutorial 使用 Koin、MockK 和 Kakao 正确设置了片段的 UI 测试。

我创建了用于注入模拟的自定义规则,设置 ViewModel,并在 @Before 调用中,运行 预期的答案和 returns 与 MockK。问题是,即使片段的视图模型的 LiveData 对象与测试 class 的 LiveData 对象相同,Observer 的 onChange 也永远不会在片段上触发。

我 运行 使用调试器进行测试,似乎正确调用了 LiveData 函数和 MockK 的答案。日志显示 LiveData 对象持有的值是相同的。测试为运行ning时Fragment的生命周期为Lifecycle.RESUMED。那么为什么观察者的 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)
    }

}

自定义测试运行ner:

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

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

终于用调试器找到了问题和解决方案。显然,@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
}

Fragment 中的 Observer 被调用了!

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