【发布时间】: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