Как ждать suspendCoroutine в модульном тесте? - PullRequest
2 голосов
/ 02 июля 2019

Я хочу написать тесты для моего приложения для Android.Иногда viewModel выполняет задачи в фоновом режиме, используя функцию запуска сопрограммы Kotlins.Эти задачи выполняются в viewModelScope, который так легко предоставляет библиотека androidx.lifecycle.Чтобы по-прежнему тестировать эти функции, я заменил диспетчера Android по умолчанию на Dispatchers.Unconfined, который выполняет код синхронно.

По крайней мере, в большинстве случаев.При использовании suspendCoroutine Dispatchers.Unconfined не будет приостановлен и позднее возобновлен, а вместо этого просто вернется.Документация Dispatchers.Unconfined показывает, почему:

[Dispatchers.Unconfined] позволяет сопрограмме возобновлять работу в любом потоке, который используется соответствующей функцией приостановки.

Насколько я понимаю, сопрограмма фактически не приостанавливается, но остальная часть асинхронной функции после suspendCoroutine запускается в потоке, который вызывает continuation.resume.Поэтому тест не пройден.

Пример:

class TestClassTest {
var testInstance = TestClass()

@Test
fun `Test with default dispatchers should fail`() {
    testInstance.runAsync()
    assertFalse(testInstance.valueToModify)
}

@Test
fun `Test with dispatchers replaced by Unconfined should pass`() {
    testInstance.DefaultDispatcher = Dispatchers.Unconfined
    testInstance.IODispatcher = Dispatchers.Unconfined
    testInstance.runAsync()
    assertTrue(testInstance.valueToModify)
}

@Test
fun `I need to also test some functions that use suspend coroutine - How can I do that?`() {
    testInstance.DefaultDispatcher = Dispatchers.Unconfined
    testInstance.IODispatcher = Dispatchers.Unconfined
    testInstance.runSuspendCoroutine()
    assertTrue(testInstance.valueToModify)//fails
}
}

class TestClass {
var DefaultDispatcher: CoroutineContext = Dispatchers.Default
var IODispatcher: CoroutineContext = Dispatchers.IO
val viewModelScope = CoroutineScope(DefaultDispatcher)
var valueToModify = false

fun runAsync() {
    viewModelScope.launch(DefaultDispatcher) {
        valueToModify = withContext(IODispatcher) {
            sleep(1000)
            true
        }
    }
}

fun runSuspendCoroutine() {
    viewModelScope.launch(DefaultDispatcher) {
        valueToModify = suspendCoroutine {
            Thread {
                sleep(1000)
                //long running operation calls listener from background thread
                it.resume(true)
            }.start()
        }
    }
}
}

Я экспериментировал с runBlocking, однако он помогает только при вызове запуска с использованием CoroutineScope, созданного runBlocking.Но так как мой код запускается на CoroutineScope, предоставленном viewModelScope, это не будет работать.Если это возможно, я бы предпочел не вводить CoroutineScope везде, потому что любой класс более низкого уровня мог бы (и делает) свой собственный CoroutineScope, как в примере, и тогда Test должен знать много о деталях реализации.Основная идея заключается в том, что каждый класс может контролировать отмену своих асинхронных операций.Например, viewModelScope по умолчанию отменяется, когда viewModel уничтожается.

Мой вопрос: Какой диспетчер сопрограмм я мог бы использовать для запуска, запуска с блокировкой контекста и т. Д. (Например, Dispatchers.Unconfined), а также для блокировки suspendCoroutine

Ответы [ 4 ]

1 голос
/ 09 июля 2019

Как я понял, вы хотите, чтобы все в вашем рабочем коде выполнялось в основном потоке вашего теста. Но это не представляется достижимым, поскольку что-то может быть запущено в обычном пуле потоков / фоне, и если у вас нет средств для синхронизации / присоединения фонового процесса в вашем коде, у вас, вероятно, проблемы. Лучше всего как-то объединить фоновые потоки в сопрограмме и await, которая сопрограммируется в тесте перед утверждениями. Может потребоваться изменить существующий производственный код.

Я говорю о другом подходе к написанию вашего кода. Конечно, это не всегда возможно. Я обновил ваш пример, чтобы проиллюстрировать мою точку зрения:

class TestClassTest {
    var testInstance = TestClass()

    @Test
    fun `Test with default dispatchers should fail`() = runBlocking {
        val valueToModify = testInstance.runAsync().await()
        assertTrue(valueToModify)
    }

    @Test
    fun `Test with dispatchers replaced by Unconfined should pass`() = runBlocking {
        testInstance.DefaultDispatcher = Dispatchers.Unconfined
        testInstance.IODispatcher = Dispatchers.Unconfined
        val valueToModify = testInstance.runAsync().await()
        assertTrue(valueToModify)
    }

    @Test
    fun `I need to also test some functions that use suspend coroutine - How can I do that?`() = runBlocking {
        testInstance.DefaultDispatcher = Dispatchers.Unconfined
        testInstance.IODispatcher = Dispatchers.Unconfined
        val valueToModify = testInstance.runSuspendCoroutine().await()
        assertTrue(valueToModify)//fails
    }
}

class TestClass {
    var DefaultDispatcher: CoroutineContext = Dispatchers.Default
    var IODispatcher: CoroutineContext = Dispatchers.IO
    val viewModelScope = CoroutineScope(DefaultDispatcher)

    fun runAsync(): Deferred<Boolean> {
        return viewModelScope.async(DefaultDispatcher) {
            withContext(IODispatcher) {
                sleep(1000)
                true
            }
        }
    }

    fun runSuspendCoroutine(): Deferred<Boolean> {
        return viewModelScope.async(DefaultDispatcher) {
            suspendCoroutine<Boolean> {
                Thread {
                    sleep(1000)
                    //long running operation calls listener from background thread
                    it.resume(true)
                }.start()
            }
        }
    }
}
1 голос
/ 09 июля 2019

Как насчет передачи контекста сопрограммы в вашу модель?Что-то вроде

class Model(parentContext: CoroutineContext = Dispatchers.Default) {
    private val modelScope = CoroutineScope(parentContext)
    var result = false

    fun doWork() {
        modelScope.launch {
            Thread.sleep(3000)
            result = true
        }
    }
}

@Test
fun test() {
    val model = runBlocking {
        val model = Model(coroutineContext)
        model.doWork()
        model
    }
    println(model.result)
}

Обновление для viewModelScope от androidx.lifecycle. Вы можете просто использовать это правило теста

@ExperimentalCoroutinesApi
class CoroutinesTestRule : TestWatcher() {
    private val dispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher()

    override fun starting(description: Description?) {
        super.starting(description)
        Dispatchers.setMain(dispatcher)
    }

    override fun finished(description: Description?) {
        super.finished(description)
        Dispatchers.resetMain()
        dispatcher.cleanupTestCoroutines()
    }
}

. Вот тестовая модель и тест

class MainViewModel : ViewModel() {
    var result = false

    fun doWork() {
        viewModelScope.launch {
            Thread.sleep(3000)
            result = true
        }
    }
}

class MainViewModelTest {
    @ExperimentalCoroutinesApi
    @get:Rule
    var coroutinesRule = CoroutinesTestRule()

    private val model = MainViewModel()

    @Test
    fun `check result`() {
        model.doWork()
        assertTrue(model.result)
    }
}

не забудьте добавить testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version", чтобы получить TestCoroutineDispatcher

0 голосов
/ 15 июля 2019

С помощью других ответов я нашел следующее решение. Как предположил Седовав в своем ответе, я могу запустить задачу асинхронно и ждать отложенного. По сути, та же идея, я могу запустить задачу с запуском и ждать задания, которое выполняет задачу. Проблема в обоих случаях заключается в том, как мне получить отложенный или работу.

Я нашел решение для этого. Такое задание всегда является потомком задания, содержащегося в CoroutineContext, который является частью CoroutineScope. Поэтому следующий код решает проблему, как в моем примере кода, так и в реальном приложении для Android.

@Test
fun `I need to also test some functions that use suspend coroutine - How can I do that?`() {
    replaceDispatchers()
    testInstance.runSuspendCoroutine()
    runBlocking {
        (testInstance.viewModelScope.coroutineContext[Job]?.children?.forEach { it.join() }
    }
    assertTrue(testInstance.valueToModify)//fails
}

Это похоже на взлом, если у кого-то есть причина, почему это опасно, скажите, пожалуйста. Также это не будет работать, если есть другой базовый CoroutineScope, созданный другим классом. Но это лучшее решение, которое у меня есть.

0 голосов
/ 02 июля 2019

Это именно то, для чего был создан runBlocking.Просто замените GlobalScope.launch вызов:

result = runBlocking {
    suspendCoroutine { continuation ->
        Thread {
            sleep(1000)
            //long running operation calls listener from background thread
            continuation.resume(true)
        }.start()
    }
}
...