Мок ViewModel в действии MVVM

Обновление от 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 базовой активности, но эта активность должна существовать, прежде чем мы сможем переопределить ее, чтобы использовать нашу фиктивную виртуальную машину.


person Daniel Wilson    schedule 20.04.2018    source источник
comment
Согласно этому ответу, stackoverflow.com/a/49397895/4232337 вам нужно не допускать автоматического создания экземпляра ActivityRule и запустите его самостоятельно, чтобы вы могли настроить вещи раньше. В вашем случае это будет означать установку ViewModelFactory ДО того, как действие будет запущено, и его обычная ViewModelFactory уже используется.   -  person NSimon    schedule 20.04.2018
comment
Интересный. Я пытался создать экземпляр активности сам, как вы сказали, но это не помогло. Спасибо, вот как выглядит тест: pastebin.com/m7MbLgze   -  person Daniel Wilson    schedule 20.04.2018
comment
Возможно, я неправильно понял настройку vm factory до того, как действие будет запущено, я не могу вызвать activityRule.activity.viewModelFactory = ViewModelUtil.createFor(viewModel) до запуска действия, потому что действие будет нулевым   -  person Daniel Wilson    schedule 20.04.2018
comment
Вам не нужно. Ссылка говорит о том, что @Rule выполняется до @Before, поэтому к тому времени, когда вы пытаетесь смоделировать Factory, действие уже существует. Предлагается: 1. Не запускать активность прямо (по ложному флагу в правиле), 2. В @Before издеваться над viewModelFactory и 3. В @Before запускать активность   -  person NSimon    schedule 20.04.2018
comment
Извините, простите мою глупость, я честно прочитал ваш комментарий 20 раз, и это действительно похоже на то, что я делаю в той ссылке pastebin в моем первом ответе, что вы думаете?! Я начинаю думать, может быть, сейчас это не связано, проблема в том, что метод getUser() вообще вызывается, поэтому я не вижу, как связан порядок создания экземпляров, но трудно сказать!   -  person Daniel Wilson    schedule 20.04.2018
comment
Извините, вы правы. Рассматривали ли вы тогда другой подход, при котором вы бы издевались над модулем, созданным Dagger напрямую? (Тот, который внедряет Factory внутрь Activity). Вот пример: medium.com/@fabioCollini/ (под пунктом «Эспрессо-тест»)   -  person NSimon    schedule 20.04.2018
comment
Спасибо @NSimon, я думаю, вы в основном правы. Я переименовал вопрос, поскольку проблема кажется довольно нерешенной в сообществе.   -  person Daniel Wilson    schedule 29.04.2018