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

Тесты 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 очень похож на бездействующие ресурсы Espresso, которые указывают, свободен или занят тестируемый объект. Используйте тестовое правило Compose, чтобы зарегистрировать реализацию 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. Если запустить его вне потока UI, то в тесте могут возникнуть состояния гонки и чтение устаревших состояний.
  • Утверждения только для чтения. Блок должен содержать только утверждения только для чтения. Любые действия, изменяющие состояние, должны выполняться за пределами этого блока.

Пример

@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")
    }
}

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

Любое условие, зависящее от внешних процессов, таких как загрузка данных, измерение или отрисовка 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)

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