Synchronizowanie testów

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

Zwykle nie musisz nic robić. Istnieją jednak pewne przypadki skrajne, o których warto wiedzieć.

Gdy test jest zsynchronizowany, aplikacja napisana w Compose jest przesuwana w czasie za pomocą zegara wirtualnego. Oznacza to, że testy Compose nie są przeprowadzane w czasie rzeczywistym, więc 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 użytkownika 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 to wymaganie dotyczy tylko hierarchii Compose, a nie reszty aplikacji.

Wyłączanie automatycznej synchronizacji

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

composeTestRule.mainClock.autoAdvance = false

Zwykle czas jest wtedy przesuwany ręcznie. Możesz przejść dokładnie o 1 klatkę za pomocą przycisku advanceTimeByFrame() lub o określony czas za pomocą przycisku advanceTimeBy():

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

Nieaktywne zasoby

Compose może synchronizować testy i interfejs, aby każde działanie i każde sprawdzenie było wykonywane w stanie bezczynności, w razie potrzeby oczekując lub przesuwając zegar. Niektóre operacje asynchroniczne, których wyniki wpływają na stan interfejsu, mogą być jednak wykonywane w tle, a test nie będzie o nich wiedzieć.

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

Ten interfejs API jest bardzo podobny do zasobów bezczynnych Espresso, które wskazują, czy testowany obiekt jest bezczynny, czy zajęty. Użyj reguły testowej tworzenia, aby zarejestrować wdrożenie IdlingResource.

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ż funkcja Compose będzie bezczynna, ale zależy od właściwości 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.

Pamiętaj, że w obu przypadkach waitForIdle() czeka też na oczekujące przejścia rysowania i układu.

Możesz też przyspieszyć zegar, aż zostanie spełniony określony warunek, za pomocą polecenia 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 on tylko ze stanem Compose).

Optymalizowanie testów animacji

Podczas testowania animacji o wysokiej jakości często trzeba wyłączyć automatyczne przechodzenie do następnej klatki i ręcznie przechodzić przez klatki, aby potwierdzić pośrednie stany interfejsu. W przypadku tych konkretnych pętli klatka po klatce użyj metody runWithoutImplicitWait, aby wykonać potwierdzenia. Standardowe zapytania o węzły (np. onNodeWithTag lub fetchSemanticsNode) wywołują niejawne synchronizacje, które są zbędne gdy ręcznie sterujesz zegarem. Ich pominięcie znacznie przyspiesza czas wykonywania testów.

Wytyczne dotyczące użytkowania

  • Ręczne zarządzanie zegarem: używaj tego interfejsu API, gdy mainClock.autoAdvance jest ustawione na false, a interfejs jest w znanym, stabilnym stanie dla bieżącej klatki.
  • Wykonywanie w wątku UI: aby zapewnić stabilność drzewa interfejsu, wywołuj runWithoutImplicitWait w wątku UI, np. za pomocą runOnUiThread. Uruchomienie go poza wątkiem UI naraża test na sytuacje wyścigu i odczytywanie nieaktualnego stanu.
  • Potwierdzenia tylko do odczytu: blok powinien zawierać wyłącznie potwierdzenia tylko do odczytu. Wszystkie działania, które zmieniają stan, powinny być wykonywane poza tym blokiem.

Przykład

@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 w Compose obsługuje teraz synchronizację wątku głównego, co umożliwia bezpieczne wywoływanie funkcji waitForIdle, a tym samym działań i asercji interfejsu Compose, bezpośrednio z wątku głównego.

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

Po włączeniu synchronizacji wątku głównego platforma testowa Compose może teraz przesuwać zegar i przetwarzać oczekujące zadania nawet wtedy, gdy w wątku głównym wykonywane są wywołania blokujące.

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

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

  • Interoperacyjność złożonych widoków: podczas testowania hybrydowych interfejsów, które zawierają zarówno widoki Compose, jak i starsze widoki Androida, manipulowanie widokami często wymaga uruchamiania na głównym wątku. Możesz teraz wchodzić w interakcje z widokami i sprawdzać węzły Compose sekwencyjnie bez ciągłego przełączania kontekstów wątków.
  • Synchroniczne zmiany stanu: jeśli Twoja architektura opiera się na ściśle powiązanych z głównym wątkiem elementach przechowujących stan, możesz teraz zmieniać stan i natychmiast czekać na ustabilizowanie się interfejsu Compose bez opuszczania głównego wątku.
  • 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 wykonywany w głównym wątku, testy Compose są teraz wykonywane bez konieczności delegowania do wątku w tle.

Przykład

W przeszłości synchronizacja w głównym wątku była surowo zabroniona, więc programiści musieli przełączać się między wątkiem narzędzia do testowania 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, asercje dotyczące hierarchii Compose i View 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")
    }
}

Oczekiwanie na warunki

Każdy warunek, który zależy od pracy zewnętrznej, np. wczytywania danych lub pomiaru lub rysowania w Androidzie (czyli pomiaru lub rysowania poza kompozycją), powinien używać bardziej ogólnej koncepcji, takiej jak waitUntil():

composeTestRule.waitUntil(timeoutMs) { condition }

Możesz też użyć dowolnego z tych waitUntilnarzędzi:

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 aplikacji na Androida zawiera szersze omówienie podstaw testowania i technik testowania.
  • Podstawy testowania: dowiedz się więcej o podstawowych koncepcjach związanych z testowaniem aplikacji na Androida.
  • Testy lokalne: niektóre testy możesz przeprowadzać lokalnie, na własnej stacji roboczej.
  • Testy z użyciem instrumentacji: warto też przeprowadzać testy z użyciem instrumentacji. 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 różnych rozmiarów ekranu: użytkownicy mają do dyspozycji wiele urządzeń, dlatego warto przeprowadzać testy na różnych rozmiarach ekranu.
  • Espresso: chociaż Espresso jest przeznaczony do interfejsów opartych na widokach, wiedza o nim może być przydatna w przypadku niektórych aspektów testowania Compose.