Введение: тест, который ждёт три секунды
Тест с delay(3000) — это не медленный тест. Это признак того, что вы не умеете управлять временем. Фраза, которую я повторяю на каждом разборе кода.
Тесты на корутинах — боль: код работает в проде, а тесты то падают по таймауту, то бегут по десять секунд, то зависают без объяснимых причин. Причина почти всегда одна — тест привязан к реальному времени и реальным диспетчерам. Решение — библиотека kotlinx-coroutines-test и её виртуальное время.
Разберём: runTest, TestScope, разницу между StandardTestDispatcher и UnconfinedTestDispatcher, как двигать виртуальное время и как тестировать ViewModel с Dispatchers.Main. Плюс ошибки, которые я вижу на собеседованиях и в чужом коде.
runTest: тест с виртуальным временем
Главный инструмент — функция runTest из kotlinx-coroutines-test. На JVM она ведёт себя как runBlocking, но с одним отличием: все delay внутри теста пропускаются — выполняются мгновенно, через виртуальное время. Тело теста выполняется в TestScope.
@Test
fun `fetchProfile returns data`() = runTest {
val repository = FakeProfileRepository(networkDelayMs = 2_000)
val profile = repository.fetchProfile()
assertEquals("Alice", profile.name)
}Внутри fetchProfile есть delay(2_000) — эмуляция медленной сети. В обычном runBlocking тест ждал бы две реальные секунды. В runTest delay пропускается, и тест пролетает за миллисекунды.
Пара деталей из документации, которые полезно знать:
- Таймаут по умолчанию — 60 секунд на тело теста. Если тест не уложился — AssertionError. Таймаут меняется параметром timeout для отдельного теста.
- Все запущенные в тесте корутины должны завершиться. Если после тела теста остались живые корутины — тест будет ждать или упадёт. Для фоновых корутин, которые должны пережить тест, есть TestScope.backgroundScope: их отменят автоматически при завершении.
- Неперехваченные исключения всплывают в конце теста. Корутина, которая бросила исключение, а вы его не обработали, — будет видна.
Формула
runTest = runBlocking + виртуальное время. delay не замедляет тест, а привязка к TestScope — чтобы виртуальный планировщик знал все корутины теста.
StandardTestDispatcher или UnconfinedTestDispatcher
Внутри runTest по умолчанию работает StandardTestDispatcher, и он не запускает корутины сразу, а ставит их в очередь планировщика. Посмотрите на такой тест:
@Test
fun `users are registered`() = runTest {
val userRepo = UserRepository()
launch { userRepo.register("Alice") }
launch { userRepo.register("Bob") }
// Корутины из launch ещё не выполнились!
assertEquals(listOf("Alice", "Bob"), userRepo.getAllUsers()) // FAIL
}Оба launch стоят в очереди и не запущены: assert увидит пустой список. Это не баг — это дизайн: вы сами решаете, когда корутинам выполниться.
Первый путь — advanceUntilIdle(): метод планировщика, который запускает все запланированные корутины, пока очередь не опустеет. Документация называет его хорошим выбором по умолчанию для большинства тестовых сценариев.
@Test
fun `users are registered`() = runTest {
val userRepo = UserRepository()
launch { userRepo.register("Alice") }
launch { userRepo.register("Bob") }
advanceUntilIdle()
assertEquals(listOf("Alice", "Bob"), userRepo.getAllUsers()) // PASS
}Второй путь — UnconfinedTestDispatcher. Он, наоборот, выполняет корутины нетерпеливо, в момент запуска. Удобно в простых тестах, но даёт иллюзию синхронности: в реальном коде всё происходит не так.
Ошибка-классика: кандидат знает про runTest, но пишет тест с launch и удивляется, что «всё не по порядку». Ответ, который засчитают: StandardTestDispatcher ставит в очередь — значит, нужен advanceUntilIdle или явное управление временем.
Виртуальное время: advanceTimeBy и runCurrent
Самое ценное в тестовой библиотеке — виртуальное время. Планировщик TestCoroutineScheduler хранит «часы» теста, доступ к ним — через testScheduler из TestScope. Базовые методы:
- advanceTimeBy(ms) — продвигает виртуальное время на заданную величину и запускает все сопрограммы, запланированные до этого момента.
- runCurrent() — запускает сопрограммы, запланированные на текущее виртуальное время.
- advanceUntilIdle() — гонит время, пока очередь не опустеет.
Классический сценарий — проверить, что событие происходит ровно в нужный момент:
@Test
fun `value becomes ready exactly after timeout`() = runTest {
var ready = false
launch {
delay(5_000)
ready = true
}
advanceTimeBy(4_000)
assertFalse(ready) // ещё рано
advanceTimeBy(1_000)
assertTrue(ready) // ровно на 5-й секунде
}Так можно проверять ретраи, таймеры, дебаунсы — всё, что раньше требовало «подождать и надеяться».
Секунд — таймаут теста
Если тест не завершился за 60 секунд виртуального времени, runTest падает с AssertionError. Реальные 60 секунд при пропуске delay могут пролететь за миллисекунды — но это уже сигнал, что в тесте что-то зациклилось.
Важный нюанс из документации: delay пропускаются только в диспетчерах, которые знают про TestCoroutineScheduler. Код, переключающийся на Dispatchers.Default или Dispatchers.IO через withContext, delay там будет ждать по-настоящему. Поэтому тестируемый код должен получать диспетчеры через конструктор.
Dispatchers.Main и тесты ViewModel
viewModelScope по умолчанию работает на Dispatchers.Main, которого в юнит-тестах нет — тест упадёт с ошибкой инициализации Main-диспетчера. Решение из официальной документации: подменить главный диспетчер через Dispatchers.setMain() и вернуть через Dispatchers.resetMain().
Обычно это оформляют как правило JUnit:
class MainDispatcherRule(
val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {
override fun starting(description: Description) {
Dispatchers.setMain(testDispatcher)
}
override fun finished(description: Description) {
Dispatchers.resetMain()
}
}И тест ViewModel:
class HomeViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
@Test
fun `loadMessage sets greeting`() = runTest {
val viewModel = HomeViewModel(repository = FakeRepository())
viewModel.loadMessage()
assertEquals("Greetings!", viewModel.message.value)
}
}Тонкость: если в setMain передан UnconfinedTestDispatcher, корутины viewModelScope выполняются сразу, без advance. Если StandardTestDispatcher — придётся двигать время. Можно собрать правило с StandardTestDispatcher(testScheduler) — тогда планировщик общий с runTest, и advanceUntilIdle в тесте управляет и корутинами ViewModel тоже.
Альтернатива без «магии» Main — передать TestScope как CoroutineScope в конструктор ViewModel. Документация Android прямо называет это преимуществом: TestScope позволяет контролировать время и проверять поведение корутин в модульных тестах.
Частая ошибка на собеседовании: кандидат говорит, что «viewModelScope тестировать нельзя». Можно — через подмену Dispatchers.Main или передачу скоупа в конструктор. Главное, чтобы скоуп в тесте был тестовым, а не реальным.
Ошибки, из-за которых тесты падают
Соберу типичные промахи, которые вижу в коде и на собеседованиях:
- runBlocking вместо runTest. delay выполняется по-настоящему, тесты медленные, тайминги настоящие.
- Реальное время в коде. System.currentTimeMillis(), Thread.sleep() или завязка на часы не подчиняются виртуальному времени.
- Живые корутины после теста. Бесконечные циклы в ViewModel не завершаются — тест висит или падает. Решение — backgroundScope или отмена в teardown.
- Завязка на порядок без advance. launch внутри runTest не выполняется мгновенно (StandardTestDispatcher) — без advanceUntilIdle assert стреляет по пустому состоянию.
- Диспетчеры «из воздуха». withContext(Dispatchers.IO) внутри тестируемого кода переключает на реальные потоки — delay там настоящие. Для детерминизма диспетчеры внедряют через конструктор.
- Тест на runTest, а не на runBlocking: delay пропускаются.
- Корутины, запущенные в тесте, завершаются или сидят в backgroundScope.
- Порядок контролируется: advanceUntilIdle / advanceTimeBy / runCurrent.
- Dispatchers.Main подменён через setMain и восстановлен через resetMain.
- Тестируемый код получает диспетчеры через конструктор.
Нужна помощь с корутинами и тестами?
Я Рустем, senior Android-разработчик и ментор Яндекс Практикума. Провожу собеседования и вижу, на чём сыплются кандидаты с опытом 1–2 года: корутины и тестирование — в топе. Бесплатная диагностика: разберу ваши пробелы и составлю план роста за 30–40 минут.
Итоги
Тестирование корутин — это не «ещё одна тема», а навык, который проверяют и на собеседованиях, и в ревью кода.
- Инструмент: runTest + TestScope дают виртуальное время: delay пропускаются, тайминги контролируются.
- Диспетчеры: StandardTestDispatcher ставит в очередь (нужен advanceUntilIdle), UnconfinedTestDispatcher выполняет сразу; Dispatchers.Main подменяется через setMain.
- Дисциплина: код получает диспетчеры через конструктор, фоновые корутины — в backgroundScope, реальное время — под запретом.
Главное
Быстрый и стабильный тест на корутины — это на 90% runTest и правильный тестовый диспетчер. Умеете объяснить, почему delay пропускается, а порядок запуска контролируется, — тема закрыта.
Если ваши тесты падают без объяснимых причин или собеседование уже близко — не зубрите дальше. Напишите в Telegram, разберём вашу ситуацию и составим план.