Synchronizowanie testów

Testy Compose są domyślnie synchronizowane z interfejsem. Gdy wywołujesz potwierdzenie lub działanie za pomocą ComposeTestRule, test jest wcześniej synchronizowany i czeka, aż drzewo interfejsu będzie nieaktywne.

Zwykle nie musisz podejmować żadnych działań. Musisz jednak znać kilka przypadków brzegowych.

Gdy test jest synchronizowany, czas w aplikacji Compose jest przyspieszany za pomocą wirtualnego zegara. Oznacza to, że testy Compose nie są przeprowadzane w czasie rzeczywistym, dzięki czemu mogą zakończyć się tak szybko, jak to możliwe.

Jeśli jednak nie użyjesz metod synchronizujących testy, nie nastąpi rekompozycja i interfejs będzie wyglądał na wstrzymany.

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

Pamiętaj, że ten wymóg dotyczy tylko hierarchii Compose, a nie reszty aplikacji.

Wyłączanie automatycznej synchronizacji

Gdy wywołujesz potwierdzenie lub działanie za pomocą ComposeTestRule, np. assertExists(), test jest synchronizowany z interfejsem Compose. W niektórych przypadkach możesz chcieć zatrzymać tę synchronizację i samodzielnie sterować zegarem. Możesz na przykład sterować czasem, aby robić dokładne zrzuty ekranu animacji w momencie, gdy interfejs jest nadal zajęty. Aby wyłączyć automatyczną synchronizację, ustaw właściwość autoAdvance w mainClock na false:

composeTestRule.mainClock.autoAdvance = false

Zwykle będziesz wtedy samodzielnie przyspieszać czas. Możesz przyspieszyć czas o dokładnie 1 klatkę za pomocą advanceTimeByFrame() lub o określony czas trwania za pomocą advanceTimeBy():

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

Nieaktywne zasoby

Compose może synchronizować testy i interfejs, tak aby każde działanie i potwierdzenie było wykonywane w stanie nieaktywnym, w razie potrzeby czekając lub przyspieszając zegar. Niektóre operacje asynchroniczne, których wyniki wpływają na stan interfejsu, mogą jednak być wykonywane w tle, a test nie będzie o nich wiedział.

Utwórz i zarejestruj te nieaktywne zasoby w teście, aby były uwzględniane podczas określania, czy testowana aplikacja jest zajęta, czy nieaktywna. Nie musisz podejmować żadnych działań, chyba że chcesz zarejestrować dodatkowe nieaktywne zasoby, np. jeśli uruchamiasz zadanie w tle, które nie jest zsynchronizowane z Espresso ani Compose.

Ten interfejs API jest bardzo podobny do nieaktywnych zasobów Espresso, które wskazują, czy testowany obiekt jest nieaktywny, czy zajęty. Aby zarejestrować implementację IdlingResource, użyj reguły testu Compose.

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

Synchronizacja ręczna

W niektórych przypadkach musisz zsynchronizować interfejs Compose z innymi częściami testu lub testowanej aplikacji.

Funkcja waitForIdle() czeka, aż Compose będzie nieaktywny, ale funkcja zależy od właściwości autoAdvance property:

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.

Pamiętaj, że w obu przypadkach waitForIdle() czeka też na oczekujące rysowanie i układ pasy.

Możesz też przyspieszyć zegar, aż zostanie spełniony określony warunek, za pomocą advanceTimeUntil().

composeTestRule.mainClock.advanceTimeUntil(timeoutMs) { condition }

Pamiętaj, że podany warunek powinien sprawdzać stan, na który może wpływać ten zegar (działa tylko ze stanem Compose).

Optymalizowanie testów animacji

When testing high-fidelity animations, you often need to disable auto-advance and manually step through frames to assert intermediate UI states. For these specific frame-by-frame loops, use the runWithoutImplicitWait method to execute your assertions. Standard node queries (like onNodeWithTag or fetchSemanticsNode) trigger implicit synchronizations that are redundant when you are manually controlling the clock, so bypassing them significantly speeds up your test runtimes.

Usage guidelines

  • Manual clock management: Use this API when mainClock.autoAdvance is set to false and the UI is in a known, stable state for the current frame.
  • UI thread execution: To ensure the stability of the UI tree, call runWithoutImplicitWait on the UI thread, such as with runOnUiThread. Running it off the UI thread exposes your test to race conditions and stale state reads.
  • Read-only assertions: The block should strictly contain read-only assertions. Any actions that mutate state should be performed outside of this block.

Example

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

Synchronizacja wątku głównego

Testowanie Compose obsługuje teraz synchronizację wątku głównego, co pozwala bezpiecznie wywoływać waitForIdle – a co za tym idzie, działania i potwierdzenia interfejsu Compose – bezpośrednio z wątku głównego.

Wcześniej testowanie Compose ściśle wymuszało model 2 wątków: wykonywanie testu odbywało się w wątku testu w tle, a aktualizacje interfejsu – w wątku głównym. Wywoływanie metod synchronizacji, takich jak waitForIdle czy runOnIdle, z wątku głównego (np. w bloku runOnUiThread) powodowało zgłoszenie IllegalStateException, ponieważ framework wymuszał ścisłe sprawdzanie wątków, aby zapobiec synchronizacji wątku głównego.

Gdy synchronizacja wątku głównego jest włączona, framework testowania Compose może teraz przyspieszać zegar i przetwarzać oczekujące zadania nawet wtedy, gdy w wątku głównym są wykonywane blokujące wywołania.

Kiedy używać synchronizacji wątku głównego

Chociaż standardem w przypadku testów Compose jest utrzymywanie testów w wątku w tle, synchronizacja wątku głównego jest bardzo korzystna w kilku konkretnych scenariuszach:

  • Złożona interoperacyjność widoków: podczas testowania hybrydowych interfejsów zawierających zarówno widoki Compose, jak i starsze widoki Androida, manipulowanie widokami często wymaga uruchamiania w wątku głównym. Możesz teraz kolejno wchodzić w interakcje z widokami i potwierdzać węzły Compose bez ciągłego przełączania kontekstów wątków.
  • Synchroniczne mutacje stanu: jeśli Twoja architektura opiera się na posiadaczach stanu ściśle powiązanych z wątkiem głównym, możesz teraz zmieniać stan i natychmiast czekać aż interfejs Compose się ustabilizuje, bez opuszczania wątku głównego.
  • Niestandardowe programy do uruchamiania testów: jeśli tworzysz niestandardową infrastrukturę testową lub korzystasz ze środowisk, w których program do uruchamiania testów jest z natury wykonywany w wątku głównym, testy Compose są teraz wykonywane bez konieczności delegowania do wątku w tle.

Przykład

W przeszłości, ponieważ synchronizacja w wątku głównym była ściśle zabroniona, deweloperzy musieli przełączać się między wątkiem programu do uruchamiania testów w tle a wątkiem UI, co prowadziło do niespójnych testów:

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

Gdy synchronizacja wątku głównego jest włączona, potwierdzenia hierarchii Compose i widoków można wykonywać w tym samym bloku:

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

Czekanie na warunki

Każdy warunek, który zależy od pracy zewnętrznej, np. wczytywania danych lub pomiaru czy rysowania w Androidzie (czyli pomiaru lub rysowania poza Compose), powinien używać bardziej ogólnego pojęcia, takiego jak waitUntil():

composeTestRule.waitUntil(timeoutMs) { condition }

Możesz też użyć dowolnego z waitUntil pomocników:

composeTestRule.waitUntilAtLeastOneExists(matcher, timeoutMs)

composeTestRule.waitUntilDoesNotExist(matcher, timeoutMs)

composeTestRule.waitUntilExactlyOneExists(matcher, timeoutMs)

composeTestRule.waitUntilNodeCount(matcher, count, timeoutMs)

Dodatkowe materiały

  • Testowanie aplikacji na Androida: główna strona docelowa testowania na Androida zawiera szersze omówienie podstaw i technik testowania.
  • Podstawy testowania: dowiedz się więcej o podstawowych koncepcjach testowania aplikacji na Androida.
  • Testy lokalne: niektóre testy możesz przeprowadzać lokalnie, na własnej stacji roboczej.
  • Testy instrumentowane: warto też przeprowadzać testy instrumentowane. Są to testy, które są przeprowadzane bezpośrednio na urządzeniu.
  • Tryb ciągłej integracji: tryb ciągłej integracji umożliwia zintegrowanie testów z potokiem wdrażania.
  • Testowanie na różnych rozmiarach ekranu: użytkownicy mają do dyspozycji wiele urządzeń, dlatego warto testować na różnych rozmiarach ekranu.
  • Espresso: chociaż Espresso jest przeznaczone do interfejsów opartych na widokach, wiedza na jego temat może być przydatna w niektórych aspektach testowania Compose.