Слабость в тестах на Android с использованием LiveData, RxJava/RxKotlin и Spek
Настроить:
В нашем проекте (на работе - я не могу опубликовать реальный код) мы реализовали чистый MVVM. Представления общаются с ViewModels через LiveData. ViewModel содержит два типа сценариев использования: "сценарии использования действий" для выполнения каких-либо действий и "сценарии использования средства обновления состояния". Обратная связь является асинхронной (с точки зрения реакции на действие). Это не похоже на вызов API, где вы получаете результат от вызова. Это BLE, так что после написания характеристики будет характеристика уведомления, которую мы слушаем. Поэтому мы используем много Rx для обновления состояния. Это в Котлине.
ViewModel:
@PerFragment
class SomeViewModel @Inject constructor(private val someActionUseCase: SomeActionUseCase,
someUpdateStateUseCase: SomeUpdateStateUseCase) : ViewModel() {
private val someState = MutableLiveData<SomeState>()
private val stateSubscription: Disposable
// region Lifecycle
init {
stateSubscription = someUpdateStateUseCase.state()
.subscribeIoObserveMain() // extension function
.subscribe { newState ->
someState.value = newState
})
}
override fun onCleared() {
stateSubscription.dispose()
super.onCleared()
}
// endregion
// region Public Functions
fun someState() = someState
fun someAction(someValue: Boolean) {
val someNewValue = if (someValue) "This" else "That"
someActionUseCase.someAction(someNewValue)
}
// endregion
}
Обновление состояния использования:
@Singleton
class UpdateSomeStateUseCase @Inject constructor(
private var state: SomeState = initialState) {
private val statePublisher: PublishProcessor<SomeState> =
PublishProcessor.create()
fun update(state: SomeState) {
this.state = state
statePublisher.onNext(state)
}
fun state(): Observable<SomeState> = statePublisher.toObservable()
.startWith(state)
}
Мы используем Spek для юнит-тестов.
@RunWith(JUnitPlatform::class)
class SomeViewModelTest : SubjectSpek<SomeViewModel>({
setRxSchedulersTrampolineOnMain()
var mockSomeActionUseCase = mock<SomeActionUseCase>()
var mockSomeUpdateStateUseCase = mock<SomeUpdateStateUseCase>()
var liveState = MutableLiveData<SomeState>()
val initialState = SomeState(initialValue)
val newState = SomeState(newValue)
val behaviorSubject = BehaviorSubject.createDefault(initialState)
subject {
mockSomeActionUseCase = mock()
mockSomeUpdateStateUseCase = mock()
whenever(mockSomeUpdateStateUseCase.state()).thenReturn(behaviorSubject)
SomeViewModel(mockSomeActionUseCase, mockSomeUpdateStateUseCase).apply {
liveState = state() as MutableLiveData<SomeState>
}
}
beforeGroup { setTestRxAndLiveData() }
afterGroup { resetTestRxAndLiveData() }
context("some screen") {
given("the action to open the screen") {
on("screen opened") {
subject
behaviorSubject.startWith(initialState)
it("displays the initial state") {
assertEquals(liveState.value, initialState)
}
}
}
given("some setup") {
on("some action") {
it("does something") {
subject.doSomething(someValue)
verify(mockSomeUpdateStateUseCase).someAction(someOtherValue)
}
}
on("action updating the state") {
it("displays new state") {
behaviorSubject.onNext(newState)
assertEquals(liveState.value, newState)
}
}
}
}
}
Сначала мы использовали Observable вместо BehaviorSubject:
var observable = Observable.just(initialState)
...
whenever(mockSomeUpdateStateUseCase.state()).thenReturn(observable)
...
observable = Observable.just(newState)
assertEquals(liveState.value, newState)
вместо:
val behaviorSubject = BehaviorSubject.createDefault(initialState)
...
whenever(mockSomeUpdateStateUseCase.state()).thenReturn(behaviorSubject)
...
behaviorSubject.onNext(newState)
assertEquals(liveState.value, newState)
но юнит-тесты были облупленными. В основном они будут проходить (всегда, когда бегут в изоляции), но иногда они терпят неудачу, когда бегают весь костюм. Думая, что это связано с асинхронным характером Rx, который мы переместили в BehaviourSubject, чтобы иметь возможность контролировать, когда происходит onNext(). Теперь тесты проходят, когда мы запускаем их из AndroidStudio на локальном компьютере, но они все еще не работают на сборочном компьютере. Перезапуск сборки часто заставляет их пройти.
Неудачные тесты - это всегда те, в которых мы утверждаем значение LiveData. Таким образом, подозреваемыми являются LiveData, Rx, Spek или их комбинация.
Вопрос: У кого-нибудь был подобный опыт написания юнит-тестов с LiveData, с использованием Spek или, может быть, Rx, и вы нашли способы написать их, которые решают эти проблемы с ошибками?
....................
Используются вспомогательные функции и функции расширения:
fun instantTaskExecutorRuleStart() =
ArchTaskExecutor.getInstance().setDelegate(object : TaskExecutor() {
override fun executeOnDiskIO(runnable: Runnable) {
runnable.run()
}
override fun isMainThread(): Boolean {
return true
}
override fun postToMainThread(runnable: Runnable) {
runnable.run()
}
})
fun instantTaskExecutorRuleFinish() = ArchTaskExecutor.getInstance().setDelegate(null)
fun setRxSchedulersTrampolineOnMain() = RxAndroidPlugins.setInitMainThreadSchedulerHandler { Schedulers.trampoline() }
fun setTestRxAndLiveData() {
setRxSchedulersTrampolineOnMain()
instantTaskExecutorRuleStart()
}
fun resetTestRxAndLiveData() {
RxAndroidPlugins.reset()
instantTaskExecutorRuleFinish()
}
fun <T> Observable<T>.subscribeIoObserveMain(): Observable<T> =
subscribeOnIoThread().observeOnMainThread()
fun <T> Observable<T>.subscribeOnIoThread(): Observable<T> = subscribeOn(Schedulers.io())
fun <T> Observable<T>.observeOnMainThread(): Observable<T> =
observeOn(AndroidSchedulers.mainThread())
2 ответа
Вопрос не с LiveData
; это более распространенная проблема - одиночные игры. Здесь Update...StateUseCases
должны были быть одиночками; иначе, если бы наблюдатели получили другой экземпляр, у них был бы другой PublishProcessor, и он не получил бы то, что было опубликовано.
Существует тест для каждого Update...StateUseCases
и для каждого ViewModel есть тест, в который Update...StateUseCases
вводится (ну косвенно через ...StateObserver
).
Государство существует в Update...StateUseCases
и так как это одноэлементный файл, он изменяется в обоих тестах, и они используют один и тот же экземпляр, становясь зависимыми друг от друга.
Во-первых, старайтесь избегать использования синглетонов, если это возможно.
Если нет, сбросьте состояние после каждой группы испытаний.
Я не использовал Speck для юнит-тестирования. Я использовал платформу для юнит-тестирования java, и она отлично работает с Rx и LiveData, но вы должны помнить одну вещь. Rx и LiveData являются асинхронными, и вы не можете сделать что-то вроде someObserver.subscribe{}, someObserver.doSmth{}, assert{}
иногда это будет работать, но это не правильный способ сделать это.
Для Rx есть TestObservers
для наблюдения за событиями Rx. Что-то вроде:
@Test
public void testMethod() {
TestObserver<SomeObject> observer = new TestObserver()
someClass.doSomethingThatReturnsObserver().subscribe(observer)
observer.assertError(...)
// or
observer.awaitTerminalEvent(1, TimeUnit.SECONDS)
observer.assertValue(somethingReturnedForOnNext)
}
Для LiveData вам также придется использовать CountDownLatch, чтобы дождаться выполнения LiveData. Что-то вроде этого:
@Test
public void someLiveDataTest() {
CountDownLatch latch = new CountDownLatch(1); // if you want to check one time exec
somethingTahtReturnsLiveData.observeForever(params -> {
/// you can take the params value here
latch.countDown();
}
//trigger live data here
....
latch.await(1, TimeUnit.SECONDS)
assert(...)
}
Используя этот подход, ваш тест должен работать нормально в любом порядке на любой машине. Также время ожидания события защелки и терминала должно быть как можно меньше, тесты должны выполняться быстро.
Примечание 1: код находится в JAVA, но вы можете легко изменить его в kotlin.
Примечание 2: Синглтон - самый большой враг юнит-тестирования;). (Со статическими методами на их стороне).