Обновление от 29 апреля 2018 г.
Переименовано для большей точности. Проблема проста: ViewModels нельзя просто имитировать в Activity, потому что они создаются в методе onCreate() Activity. Как лучше всего это обойти?
Некоторые связанные идеи находятся здесь (безуспешно пытались реализовать)
Исходный вопрос
Используя кодовую базу MVVM GithubBrowserSample от Google, я пытаюсь провести инструментальный тест для проверки того, что состояние загрузки вызывает индикатор выполнения. В частности, зеркало метод UserFragmentTest.loading(). Это довольно просто, я попытался максимально приблизить свои настройки к настройкам Google.
Однако я вижу, что это неправильно. В частности, я вижу, что функции в моей ViewModel (VM) вызываются, когда я явно прошу, чтобы их не было в моей тестовой функции @Before
. Я использую Kotlin, Dagger2 и компоненты архитектуры.
Когда я запускаю тест UserFragmentTest.loading()
, я вижу, что код действительно ничего не вызывает (даже конструктор) в виртуальной машине. Мой, однако, вызывает блок инициализации VM (который устанавливает BaseActivity
) и функцию getUser(), хотя я прошу вернуть фиктивные данные. Единственное существенное отличие, которое я вижу, это то, что у меня это Activity, а Google тестирует Fragment, а фиктивная функция ViewModel использует Mockito-Kotlin.
LoginActivityTest.kt
@RunWith(AndroidJUnit4::class)
class LoginActivityTest {
private val email = "***********@gmail.com"
private val password = "123456"
@Suppress("MemberVisibilityCanBePrivate")
@get:Rule
val activityRule = ActivityTestRule(LoginActivity::class.java)
private lateinit var viewModel:LoginViewModel
private val userData = MutableLiveData<Resource<User>>()
@Before
@Throws(Throwable::class)
fun init() {
EspressoTestUtil.disableProgressBarAnimations(activityRule)
Log.d("LoginTest Init", "vm mocked....")
viewModel = mock()
`when`(viewModel.getUser()).thenReturn(userData)
doNothing().`when`(viewModel).setLogin(anyString(), anyString())
activityRule.activity.viewModelFactory = ViewModelUtil.createFor(viewModel)
}
@Test
fun loading(){
//Given: our login event has been kicked off
onView(withId(R.id.etEmail)).perform(replaceText(email))
onView(withId(R.id.etPassword)).perform(replaceText(password))
Log.d("LoginTest", "Posting loading value ")
userData.postValue(Resource.loading(null))
onView(withId(R.id.progress_text_view)).check(matches(isDisplayed()))
}
}
LoginActivity.kt
class LoginActivity : BaseActivity<ActivityLoginBinding, LoginViewModel>(), LoginNavigator {
@Inject
lateinit var viewModelFactory: ViewModelProvider.Factory
internal set
override val viewModel: LoginViewModel
get() = ViewModelProviders.of(this, viewModelFactory).get(LoginViewModel::class.java)
private lateinit var activityLoginBinding: ActivityLoginBinding
override val bindingVariable: Int
get() = BR.viewModel
override val layoutId: Int
get() = R.layout.activity_login
override val progressTextView: TextView?
get() = activityLoginBinding.progressInclude?.progressTextView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
activityLoginBinding = viewDataBinding
viewModel.navigator = this
Log.d("LoginActivity", "1 - onCreate, observing vm.getUser changes")
viewModel.getUser().observe(this, Observer {
Log.d("LoginActivity", "4 - onCreate, user change detected")
activityLoginBinding.userResource = it
it?.let {
Log.d("LoginActivity", "5 - onCreate, user change, not null: " + it)
viewModel.login(it)
}
})
}
override fun login() {
Log.d("LoginActivity", "2 - activity login() called, getting email + pass for vm")
val email = activityLoginBinding.etEmail.text.toString()
val password = activityLoginBinding.etPassword.text.toString()
if (viewModel.isEmailAndPasswordValid(email, password)) {
Log.d("LoginActivity", "3, setting email and pass on vm")
viewModel.setLogin(password, email)
} else {
Toast.makeText(this, "invalid email or password", Toast.LENGTH_SHORT).show()
}
}
}
LoginViewModel.kt
@OpenForTesting
class LoginViewModel @Inject constructor(loginInteractor: LoginInteractor,
schedulerProvider: SchedulerProvider)
: BaseViewModel<LoginNavigator, LoginInteractor>(loginInteractor, schedulerProvider) {
@VisibleForTesting
final var loginCredentials: MutableLiveData<Pair<String, String>> = MutableLiveData()
set(value) {
if (value.value === field.value) return
field = value
}
final var user: LiveData<Resource<User>>
init {
Log.d("LoginVM", "* - Init block, shouldn't be here...")
user = Transformations.switchMap(loginCredentials) {
Log.d("LoginVM", "user switchmap returning " + it.first)
if (it.first.isBlank() || it.second.isBlank())
AbsentLiveData.create()
else
interactor.callServerLoginRepo(it.first, it.second)
}
}
@VisibleForTesting
fun getUser(): LiveData<Resource<User>> {
Log.d("LoginVM", "* - calling get User, shouldn't be here: " + user.value)
return user
}
@VisibleForTesting
fun setLogin(password: String, email: String) {
Log.d("LoginVM", "* - calling setLogin, shouldn't be here")
loginCredentials.value = Pair(password, email)
}
fun onServerLoginClick() {
navigator?.login()
}
override fun onUnknownError(message: String) {
navigator?.handleError(message)
}
fun isEmailAndPasswordValid(email: String, password: String): Boolean {
return email.isValidEmail() && password.isValidPassword(AppConstants.MINIMUM_PASSWORD_LENGTH)
}
fun login(resource: Resource<User>) {
Log.d("LoginVM", "* - vm login resource: " + resource)
if (resource.status == Status.SUCCESS && resource.data != null) {
//Login success
}
else if (resource.status == Status.ERROR){
resource.message?.let {
navigator?.handleError(it)
}
}
}
}
Logcat после запуска теста:
04-20 14:25:45.769 1635-1650/? I/TestRunner: started: loading(app.core.sdk.ui.login.LoginActivityTest)
04-20 14:25:45.771 1635-1650/? I/ActivityTestRule: Launching activity: ComponentInfo{app.core.sdk/app.core.sdk.ui.login.LoginActivity}
04-20 14:25:45.826 1635-1635/? D/LifecycleMonitor: Lifecycle status change: app.core.sdk.ui.login.LoginActivity@9ab279d in: PRE_ON_CREATE
04-20 14:25:45.931 1635-1635/? D/LoginVM: * - Init block, shouldn't be here...
04-20 14:25:45.932 1635-1635/? D/LoginActivity: 1 - onCreate, observing vm.getUser changes
04-20 14:25:45.932 1635-1635/? D/LoginVM: * - calling get User, shouldn't be here: null
04-20 14:25:45.935 1635-1635/? D/LifecycleMonitor: Lifecycle status change: app.core.sdk.ui.login.LoginActivity@9ab279d in: CREATED
04-20 14:25:45.936 1635-1635/? D/LifecycleMonitor: Lifecycle status change: app.core.sdk.ui.login.LoginActivity@9ab279d in: STARTED
04-20 14:25:45.937 1635-1635/? D/LifecycleMonitor: Lifecycle status change: app.core.sdk.ui.login.LoginActivity@9ab279d in: RESUMED
04-20 14:25:46.518 1635-1650/? D/LoginTest Init: vm mocked....
04-20 14:25:47.290 1635-1650/app.core.sdk D/LoginTest: Posting loading value
04-20 14:25:47.317 1635-1635/app.core.sdk D/LifecycleMonitor: Lifecycle status change: app.core.sdk.ui.login.LoginActivity@9ab279d in: PAUSED
04-20 14:25:47.401 1635-1650/app.core.sdk I/TestRunner: failed: loading(app.core.sdk.ui.login.LoginActivityTest)
Редактировать: похоже, проблема в том, что виртуальная машина неправильно имитируется. Есть пара связанных вопросов здесь и здесь, у которых очень похожие проблемы. Внедренная фабрика виртуальных машин сначала используется в onCreate базовой активности, но эта активность должна существовать, прежде чем мы сможем переопределить ее, чтобы использовать нашу фиктивную виртуальную машину.
activityRule.activity.viewModelFactory = ViewModelUtil.createFor(viewModel)
до запуска действия, потому что действие будет нулевым - person Daniel Wilson   schedule 20.04.2018@Rule
выполняется до@Before
, поэтому к тому времени, когда вы пытаетесь смоделировать Factory, действие уже существует. Предлагается: 1. Не запускать активность прямо (по ложному флагу в правиле), 2. В@Before
издеваться над viewModelFactory и 3. В@Before
запускать активность - person NSimon   schedule 20.04.2018getUser()
вообще вызывается, поэтому я не вижу, как связан порядок создания экземпляров, но трудно сказать! - person Daniel Wilson   schedule 20.04.2018