Как синхронизировать тесты

Тесты Compose по умолчанию синхронизируются с интерфейсом. Когда вы вызываете утверждение или действие с помощью ComposeTestRule, тест синхронизируется заранее, ожидая, пока дерево интерфейса не станет неактивным.

Обычно никаких действий с вашей стороны не требуется. Однако есть некоторые пограничные случаи, о которых вам следует знать.

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

Однако если вы не используете методы, которые синхронизируют ваши тесты, рекомпозиция не произойдет и интерфейс будет выглядеть так, будто он приостановлен.

@Test
fun counterTest() {
    val myCounter = mutableStateOf(0) // State that can cause recompositions.
    var lastSeenValue = 0 // Used to track recompositions.
    composeTestRule.setContent {
        Text(myCounter.value.toString())
        lastSeenValue = myCounter.value
    }
    myCounter.value = 1 // The state changes, but there is no recomposition.

    // Fails because nothing triggered a recomposition.
    assertTrue(lastSeenValue == 1)

    // Passes because the assertion triggers recomposition.
    composeTestRule.onNodeWithText("1").assertExists()
}

Обратите внимание, что это требование относится только к иерархиям Compose, а не ко всему приложению.

Как отключить автоматическую синхронизацию

Когда вы вызываете утверждение или действие через ComposeTestRule, например assertExists(), ваш тест синхронизируется с интерфейсом Compose. В некоторых случаях вам может понадобиться отключить синхронизацию и управлять часами самостоятельно. Например, вы можете контролировать время, чтобы делать точные скриншоты анимации в момент, когда интерфейс ещё занят. Чтобы отключить автоматическую синхронизацию, установите для свойства autoAdvance в mainClock значение false:

composeTestRule.mainClock.autoAdvance = false

Обычно после этого вы сами переводите время. Вы можете перейти на один кадр вперед, нажав advanceTimeByFrame(), или на определенное время, нажав advanceTimeBy():

composeTestRule.mainClock.advanceTimeByFrame()
composeTestRule.mainClock.advanceTimeBy(milliseconds)

Бездействующие ресурсы

Compose может синхронизировать тесты и интерфейс, чтобы каждое действие и утверждение выполнялось в состоянии бездействия, ожидая или продвигая часы по мере необходимости. Однако некоторые асинхронные операции, результаты которых влияют на состояние интерфейса, могут выполняться в фоновом режиме, и тест не будет об этом знать.

Создайте и зарегистрируйте эти простаивающие ресурсы в тесте, чтобы они учитывались при определении того, занято или свободно тестируемое приложение. Вам не нужно предпринимать никаких действий, если только вам не нужно зарегистрировать дополнительные ресурсы, которые не используются, например если вы запускаете фоновое задание, не синхронизированное с Espresso или Compose.

Этот API очень похож на Idling Resources в Espresso, который позволяет определить, находится ли тестируемый объект в состоянии ожидания или занят. Используйте правило тестирования создания писем, чтобы зарегистрировать реализацию IdlingResource.

composeTestRule.registerIdlingResource(idlingResource)
composeTestRule.unregisterIdlingResource(idlingResource)

Синхронизация вручную

В некоторых случаях вам нужно синхронизировать интерфейс Compose с другими частями теста или приложения, которое вы тестируете.

Функция waitForIdle() ожидает, пока Compose не перейдет в состояние бездействия, но функция зависит от свойства autoAdvance:

composeTestRule.mainClock.autoAdvance = true // Default
composeTestRule.waitForIdle() // Advances the clock until Compose is idle.

composeTestRule.mainClock.autoAdvance = false
composeTestRule.waitForIdle() // Only waits for idling resources to become idle.

Обратите внимание, что в обоих случаях waitForIdle() также ожидает завершения проходов отрисовки и макета.

Кроме того, вы можете ускорить время до тех пор, пока не будет выполнено определенное условие, с помощью команды advanceTimeUntil().

composeTestRule.mainClock.advanceTimeUntil(timeoutMs) { condition }

Обратите внимание, что условие должно проверять состояние, на которое может повлиять этот таймер (он работает только с состоянием Compose).

Как оптимизировать тестирование анимации

При тестировании высококачественных анимаций часто требуется отключить автопереход и вручную переходить между кадрами, чтобы проверить промежуточные состояния интерфейса. Для таких циклов с покадровой анимацией используйте метод runWithoutImplicitWait, чтобы выполнить утверждения. Стандартные запросы узлов (например, onNodeWithTag или fetchSemanticsNode) запускают неявные синхронизации, которые избыточны, когда вы вручную управляете часами, поэтому их обход значительно ускоряет выполнение тестов.

Рекомендации по использованию

  • Управление часами вручную. Используйте этот API, когда для параметра mainClock.autoAdvance задано значение false и интерфейс находится в известном стабильном состоянии для текущего кадра.
  • Выполнение потока UI. Чтобы обеспечить стабильность дерева UI, вызовите runWithoutImplicitWait в потоке UI, например с помощью runOnUiThread. Если вы запустите его не в основном потоке, то в тесте могут возникнуть состояния гонки и устаревшие данные.
  • Утверждения, доступные только для чтения. Блок должен содержать только утверждения, доступные только для чтения. Любые действия, изменяющие состояние, должны выполняться за пределами этого блока.

Пример

@Test
fun runWithoutImplicitWaitSample() = runComposeUiTest {
    setContent { MainScreen() }
    mainClock.autoAdvance = false

    // Trigger an animation
    onNodeWithText("Start Animation").performClick()

    // Step through the animation frame-by-frame
    while (hasPendingWork()) {
        mainClock.advanceTimeByFrame()
        waitForIdle()
        runOnUiThread {
            // Suppress implicit synchronization inside this block to avoid redundant
            // waits on each node query, making the frame assertions execute much faster.
            runWithoutImplicitWait {
                val box1 = onNodeWithTag("Box1").fetchSemanticsNode()
                val box2 = onNodeWithTag("Box2").fetchSemanticsNode()
                val box3 = onNodeWithTag("Box3").fetchSemanticsNode()

                // Assert the exact intermediate state of all three properties for this frame
                assert(box1.boundsInRoot.right <= box2.boundsInRoot.left)
                assert(box2.boundsInRoot.right <= box3.boundsInRoot.left)
            }
        }
    }
}

Синхронизация основного потока

Теперь при тестировании Compose поддерживается синхронизация основного потока, что позволяет безопасно вызывать waitForIdle, а также действия и утверждения Compose UI непосредственно из основного потока.

Ранее при тестировании Compose строго использовалась двухпоточная модель: выполнение тестов происходило в фоновом потоке, а обновления интерфейса – в основном потоке. Вызов методов синхронизации, таких как waitForIdle или runOnIdle, из основного потока (например, внутри блока runOnUiThread) приведет к исключению IllegalStateException, поскольку фреймворк выполняет строгие проверки потоков, чтобы предотвратить синхронизацию основного потока.

Если синхронизация основного потока включена, фреймворк тестирования Compose может продвигать часы и обрабатывать ожидающие задачи, даже если в основном потоке выполняются блокирующие вызовы.

Когда использовать синхронизацию основного потока

Хотя тестирование в фоновом потоке остается стандартом для тестов Compose, синхронизация основного потока имеет ряд преимуществ в некоторых конкретных сценариях:

  • Взаимодействие со сложными представлениями. При тестировании гибридных интерфейсов, содержащих как Compose, так и устаревшие представления Android, для управления представлениями часто требуется выполнение в основном потоке. Теперь вы можете взаимодействовать с представлениями и утверждать узлы Compose последовательно, не переключаясь между контекстами потоков.
  • Синхронные изменения состояния. Если в вашей архитектуре используются держатели состояния, строго привязанные к основному потоку, теперь вы можете изменять состояние и сразу же ждать, пока интерфейс Compose не будет готов, не покидая основной поток.
  • Специальные средства запуска тестов. Если вы создаете собственную инфраструктуру тестирования или используете среды, в которых средство запуска тестов по умолчанию выполняется в основном потоке, тесты Compose теперь выполняются без проблем и не требуют делегирования фоновому потоку.

Пример

Раньше синхронизация в основном потоке была строго запрещена, поэтому разработчикам приходилось переключаться между потоком фонового средства запуска тестов и потоком UI, что приводило к фрагментации тестов:

@Test
fun testBidirectionalInteropUIUpdates_old() {
    val scenario = launchFragmentInContainer<InteropFragment>()
    composeTestRule.waitForIdle()
    scenario.onFragment { fragment ->
        fragment.legacyButton.performClick()
    }
    // Jump to Test Thread to verify state settles inside compose
    composeTestRule.waitForIdle()
    composeTestRule.onNodeWithText("Legacy Clicks: 1").assertIsDisplayed()
    composeTestRule.onNodeWithText("Increment Legacy TextView").performClick()
    composeTestRule.waitForIdle()
    // Jump back to Main Thread to verify target view state settles
    scenario.onFragment { fragment ->
        assert(fragment.legacyTextView.text.toString() == "Compose Clicks: 1")
    }
}

Если синхронизация основного потока включена, утверждения для иерархий Compose и View можно выполнять в одном блоке:

@Test
fun testBidirectionalInteropUIUpdates_new() {
    val scenario = launchFragmentInContainer<InteropFragment>()
    composeTestRule.waitForIdle()
    scenario.onFragment { fragment ->
        fragment.legacyButton.performClick()
        composeTestRule.waitForIdle()
        composeTestRule.onNodeWithText("Legacy Clicks: 1").assertIsDisplayed()
        composeTestRule.onNodeWithText("Increment Legacy TextView").performClick()
        composeTestRule.waitForIdle()
        assert(fragment.legacyTextView.text.toString() == "Compose Clicks: 1")
    }
}

Ожидание условий

Любое условие, зависящее от внешних действий, например загрузки данных или функций measure или draw в Android (то есть функций, внешних по отношению к Compose), должно использовать более общее понятие, например waitUntil():

composeTestRule.waitUntil(timeoutMs) { condition }

Вы также можете использовать следующие waitUntilвспомогательные функции:

composeTestRule.waitUntilAtLeastOneExists(matcher, timeoutMs)

composeTestRule.waitUntilDoesNotExist(matcher, timeoutMs)

composeTestRule.waitUntilExactlyOneExists(matcher, timeoutMs)

composeTestRule.waitUntilNodeCount(matcher, count, timeoutMs)

Дополнительные ресурсы

  • Тестирование приложений для Android. На этой целевой странице собрана основная информация о тестировании, а также описаны различные методы.
  • Основы тестирования. Узнайте больше об основных принципах тестирования приложений для Android.
  • Локальные тесты. Некоторые тесты можно запустить локально на своей рабочей станции.
  • Инструментальные тесты. Рекомендуем также проводить инструментальные тесты. То есть тесты, которые выполняются непосредственно на устройстве.
  • Непрерывная интеграция: Непрерывная интеграция позволяет встроить тесты в конвейер развертывания.
  • Проверьте, как сайт выглядит на экранах разных размеров. Пользователи могут просматривать ваш сайт на самых разных устройствах, поэтому важно убедиться, что он корректно отображается на экранах разных размеров.
  • Espresso. Хотя Espresso предназначен для тестирования интерфейсов на основе представлений, знания об этом фреймворке могут быть полезны при тестировании некоторых аспектов Compose.