Введение: тест, который ждёт три секунды

Тест с delay(3000) — это не медленный тест. Это признак того, что вы не умеете управлять временем. Фраза, которую я повторяю на каждом разборе кода.

Тесты на корутинах — боль: код работает в проде, а тесты то падают по таймауту, то бегут по десять секунд, то зависают без объяснимых причин. Причина почти всегда одна — тест привязан к реальному времени и реальным диспетчерам. Решение — библиотека kotlinx-coroutines-test и её виртуальное время.

Разберём: runTest, TestScope, разницу между StandardTestDispatcher и UnconfinedTestDispatcher, как двигать виртуальное время и как тестировать ViewModel с Dispatchers.Main. Плюс ошибки, которые я вижу на собеседованиях и в чужом коде.

runTest: тест с виртуальным временем

Главный инструмент — функция runTest из kotlinx-coroutines-test. На JVM она ведёт себя как runBlocking, но с одним отличием: все delay внутри теста пропускаются — выполняются мгновенно, через виртуальное время. Тело теста выполняется в TestScope.

RepositoryTest.kt
@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, и он не запускает корутины сразу, а ставит их в очередь планировщика. Посмотрите на такой тест:

RegistrationTest.kt
@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(): метод планировщика, который запускает все запланированные корутины, пока очередь не опустеет. Документация называет его хорошим выбором по умолчанию для большинства тестовых сценариев.

RegistrationTest.kt
@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() — гонит время, пока очередь не опустеет.

Классический сценарий — проверить, что событие происходит ровно в нужный момент:

TimeoutTest.kt
@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:

MainDispatcherRule.kt
class MainDispatcherRule(
    val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {
    override fun starting(description: Description) {
        Dispatchers.setMain(testDispatcher)
    }

    override fun finished(description: Description) {
        Dispatchers.resetMain()
    }
}

И тест ViewModel:

HomeViewModelTest.kt
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, разберём вашу ситуацию и составим план.