Я пыталась написать интеграционный тест на фрагмент в отдельной ветке с помощью следующих инструментов:
- JUnit 4
- Robolectric
- MockWebServer
- kotlinx-coroutines-test
Суть проблемы
Т.к. я запускаю свой интеграционный тест на Robolectric, для теста я подменяю корутинный Dispatchers.Main
на TestCoroutineDispatcher
. Он до поры до времени работает нормально, но когда дело доходит до withContext {...}
, возникает проблема:
- Сначала корутина работает на
Dispatchers.Main
.
- Затем переключается на
Dispatchers.IO
с помощью withContext(Dispatchers.IO) {...}
.
- После завершения функции
withContext {...}
корутина должна переключиться обрано на Dispatchers.Main
, но этого не происходит - работа почему-то продолжается в Dispatchers.IO
, на бэкграунд-потоке.
Подробное описание проблемы
WordFragmentIntegrationTest:
@RunWith(RobolectricTestRunner::class)
@Config(application = TestAppWithFacade::class)
class WordFragmentIntegrationTest {
// Подменяю Dispatchers.Main на TestCoroutineDispatcher.
@get:Rule
val coroutinesRule = CoroutinesRule()
private val mockWebServer = MockWebServer()
@Before
fun setup() {
mockWebServer.start(8080)
}
@After
fun teardown() {
mockWebServer.shutdown()
}
@Test
fun `should fetch a word from api and populate the view`() = runBlockingTest {
val response = MockResponse().setResponseCode(HttpURLConnection.HTTP_OK)
.setBody(SAMPLE_API_WORD_JSON)
.setBodyDelay(0, TimeUnit.MILLISECONDS)
mockWebServer.enqueue(response)
// Запускаю WordFragment. Он сразу делает запрос к api через WordViewModel.
val arguments = Bundle().apply { putString("wordId", SAMPLE_WORD_ID) }
val fragmentScenario = launchFragmentInContainer<WordFragment>(arguments)
.moveToState(Lifecycle.State.RESUMED)
// Дожидаюсь, пока WordFragment сделает запрос к api, и делаю проверки.
fragmentScenario.onFragment { fragmentUnderTest ->
runBlocking {
while (fragmentUnderTest.progressBar.visibility == VISIBLE) { yield() }
// (Проверки на то, что UI правильно заполнился).
}
}
}
}
CoroutinesRule:
class CoroutinesRule : ExternalResource() {
override fun before() {
Dispatchers.setMain(TestCoroutineDispatcher())
}
override fun after() {
Dispatchers.resetMain()
}
}
WordViewModel:
val wordLiveData = liveData {
// Блок liveData {...} запустился в UI-потоке, всё нормально.
// Эмичу UIState.ShowLoading, чтобы показывался ProgressBar.
printCurrentThread("LiveData block is started on the UI thread")
emit(UIState.ShowLoading)
// Получаю данные в background-потоке, здесь тоже всё нормально.
// Ответ успешно приходит с MockWebServer'a.
val word = withContext(Dispatchers.IO) {
printCurrentThread("Fetching a word from api on a background thread")
resultMapper.mapToExternalLayer(loadWordUseCase(wordId))
}
// Вот здесь проблема. Функция withContext {...} после своего завершения должна переключиться
// обратно на UI-поток, но она не переключается. Функция emit() вызывается в
// background-потоке, что ведет к ошибке "Cannot invoke setValue on a background thread".
//
// Эта проблема касается только тестов, где я подменяю Dispatchers.Main на TestCoroutineDispatcher,
// т.е. в основном коде приложения переключение на UI-поток происходит нормально.
printCurrentThread("withContext {...} has ended and should switch back to Dispatchers.Main, but it doesn't")
emit(word)
}
private fun printCurrentThread(message: String) {
val threadInfo = "Current thread id: ${Thread.currentThread().id}. UI thread id: ${Looper.getMainLooper().thread.id}"
println("=================================================================================")
println(message)
println(threadInfo)
println("=================================================================================")
println()
println()
}
Логи:
=================================================================================
LiveData block is started on the UI thread
Current thread id: 11. UI thread id: 11
=================================================================================
=================================================================================
Fetching a word from api on a background thread
Current thread id: 19. UI thread id: 11
=================================================================================
=================================================================================
withContext {...} has ended and should switch back to Dispatchers.Main, but it doesn't
Current thread id: 19. UI thread id: 11
=================================================================================
Exception in thread "DefaultDispatcher-worker-1 @coroutine#2" java.lang.IllegalStateException: Cannot invoke setValue on a background thread
at androidx.lifecycle.LiveData.assertMainThread(LiveData.java:462)
at androidx.lifecycle.LiveData.setValue(LiveData.java:304)
at androidx.lifecycle.MutableLiveData.setValue(MutableLiveData.java:50)
at androidx.lifecycle.LiveDataScopeImpl$emit$2.invokeSuspend(CoroutineLiveData.kt:99)
at androidx.lifecycle.LiveDataScopeImpl$emit$2.invoke(CoroutineLiveData.kt)
at kotlinx.coroutines.intrinsics.UndispatchedKt.startUndispatchedOrReturn(Undispatched.kt:91)
at kotlinx.coroutines.BuildersKt__Builders_commonKt.withContext(Builders.common.kt:160)
at kotlinx.coroutines.BuildersKt.withContext(Unknown Source)
at androidx.lifecycle.LiveDataScopeImpl.emit(CoroutineLiveData.kt:97)
at jp.neechan.akari.dictionary.feature_word.presentation.WordViewModel$wordLiveData$1.invokeSuspend(WordViewModel.kt:51)
(Coroutine boundary)
at jp.neechan.akari.dictionary.feature_word.presentation.WordViewModel$wordLiveData$1.invokeSuspend(WordViewModel.kt:51)
at androidx.lifecycle.BlockRunner$maybeRun$1.invokeSuspend(CoroutineLiveData.kt:176)
Caused by: java.lang.IllegalStateException: Cannot invoke setValue on a background thread
at androidx.lifecycle.LiveData.assertMainThread(LiveData.java:462)
at androidx.lifecycle.LiveData.setValue(LiveData.java:304)
at androidx.lifecycle.MutableLiveData.setValue(MutableLiveData.java:50)
at androidx.lifecycle.LiveDataScopeImpl$emit$2.invokeSuspend(CoroutineLiveData.kt:99)
at androidx.lifecycle.LiveDataScopeImpl$emit$2.invoke(CoroutineLiveData.kt)
at kotlinx.coroutines.intrinsics.UndispatchedKt.startUndispatchedOrReturn(Undispatched.kt:91)
at kotlinx.coroutines.BuildersKt__Builders_commonKt.withContext(Builders.common.kt:160)
at kotlinx.coroutines.BuildersKt.withContext(Unknown Source)
at androidx.lifecycle.LiveDataScopeImpl.emit(CoroutineLiveData.kt:97)
at jp.neechan.akari.dictionary.feature_word.presentation.WordViewModel$wordLiveData$1.invokeSuspend(WordViewModel.kt:51)
at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:56)
at kotlinx.coroutines.test.TestCoroutineDispatcher.dispatch(TestCoroutineDispatcher.kt:50)
at kotlinx.coroutines.test.internal.TestMainDispatcher.dispatch(MainTestDispatcher.kt:35)
at kotlinx.coroutines.DispatchedContinuationKt.resumeCancellableWith(DispatchedContinuation.kt:288)
at kotlinx.coroutines.DispatchedCoroutine.afterResume(Builders.common.kt:261)
at kotlinx.coroutines.AbstractCoroutine.resumeWith(AbstractCoroutine.kt:113)
at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:46)
at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:56)
at kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:571)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.executeTask(CoroutineScheduler.kt:738)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.runWorker(CoroutineScheduler.kt:678)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:665)
Предположение
Т.к. в основном коде приложения корутина WordViewModel
работает нормально (переключается обратно на Dispatchers.Main
после withContext(Dispatchers.IO) {...}
), я предположила, что проблема в тестовом диспатчере. Т.е. либо у меня CoroutinesRule
неправильный, либо тестовый диспатчер пока нестабилен.
Я написала issue в тестовую библиотеку корутин.