Sincronizzare i test

I test di Compose vengono sincronizzati per impostazione predefinita con la tua UI. Quando chiami un'asserzione o un'azione con ComposeTestRule, il test viene sincronizzato in anticipo, in attesa che l'albero dell'interfaccia utente sia inattivo.

In genere, non è necessario intraprendere alcuna azione. Tuttavia, ci sono alcuni casi limite di cui dovresti essere a conoscenza.

Quando un test viene sincronizzato, l'app Compose viene avanzata nel tempo utilizzando un orologio virtuale. Ciò significa che i test di composizione non vengono eseguiti in tempo reale, quindi possono essere superati il più rapidamente possibile.

Tuttavia, se non utilizzi i metodi che sincronizzano i test, non si verificherà alcuna ricomposizione e la UI sembrerà in pausa.

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

Tieni presente che questo requisito si applica solo alle gerarchie di Composizione e non al resto dell'app.

Disattivare la sincronizzazione automatica

Quando chiami un'asserzione o un'azione tramite ComposeTestRule, ad esempio assertExists(), il test viene sincronizzato con la UI di Compose. In alcuni casi potresti voler interrompere questa sincronizzazione e controllare l'orologio manualmente. Ad esempio, puoi controllare il tempo per scattare screenshot accurati di un'animazione in un punto in cui la UI è ancora occupata. Per disattivare la sincronizzazione automatica, imposta la proprietà autoAdvance in mainClock su false:

composeTestRule.mainClock.autoAdvance = false

In genere, dovrai avanzare l'ora manualmente. Puoi avanzare esattamente di un fotogramma con advanceTimeByFrame() o di una durata specifica con advanceTimeBy():

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

Risorse inattive

Compose può sincronizzare i test e la UI in modo che ogni azione e asserzione venga eseguita in uno stato di inattività, in attesa o facendo avanzare l'orologio in base alle necessità. Tuttavia, alcune operazioni asincrone i cui risultati influiscono sullo stato dell'interfaccia utente possono essere eseguite in background mentre il test non ne è a conoscenza.

Crea e registra queste risorse inattive nel test in modo che vengano prese in considerazione quando si decide se l'app in fase di test è occupata o inattiva. Non devi intervenire a meno che tu non debba registrare risorse inattive aggiuntive, ad esempio se esegui un job in background che non è sincronizzato con Espresso o Compose.

Questa API è molto simile a Idling Resources di Espresso per indicare se il soggetto in fase di test è inattivo o occupato. Utilizza la regola di test Compose per registrare l'implementazione di IdlingResource.

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

Sincronizzazione manuale

In alcuni casi, devi sincronizzare la UI di Compose con altre parti del test o dell'app che stai testando.

La funzione waitForIdle() attende che Compose sia inattivo, ma la funzione dipende dalla proprietà 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.

Tieni presente che in entrambi i casi, waitForIdle() attende anche i passaggi di disegno e layout in attesa.

Inoltre, puoi far avanzare l'orologio fino a quando non viene soddisfatta una determinata condizione con advanceTimeUntil().

composeTestRule.mainClock.advanceTimeUntil(timeoutMs) { condition }

Tieni presente che la condizione specificata deve controllare lo stato che può essere interessato da questo orologio (funziona solo con lo stato di Compose).

Ottimizzare i test delle animazioni

Quando testi animazioni ad alta fedeltà, spesso devi disattivare l'avanzamento automatico e scorrere manualmente i frame per verificare gli stati intermedi dell'UI. Per questi loop frame per frame specifici, utilizza il runWithoutImplicitWait metodo per eseguire le asserzioni. Le query sui nodi standard (come onNodeWithTag o fetchSemanticsNode) attivano sincronizzazioni implicite ridondanti quando controlli manualmente l'orologio, quindi ignorarle velocizza notevolmente i tempi di esecuzione dei test.

Linee guida sull'utilizzo

  • Gestione manuale dell'orologio: utilizza questa API quando mainClock.autoAdvance è impostato su false e l'UI è in uno stato noto e stabile per il frame corrente.
  • Esecuzione del thread dell'interfaccia utente: per garantire la stabilità dell'albero dell'interfaccia utente, chiama runWithoutImplicitWait sul thread dell'interfaccia utente, ad esempio con runOnUiThread. L'esecuzione al di fuori del thread dell'interfaccia utente espone il test a race condition e letture di stati non aggiornati.
  • Asserzioni di sola lettura: il blocco deve contenere rigorosamente asserzioni di sola lettura. Eventuali azioni che modificano lo stato devono essere eseguite al di fuori di questo blocco.

Esempio

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

Sincronizzazione del thread principale

I test di Compose ora supportano la sincronizzazione del thread principale, consentendoti di chiamare in sicurezza waitForIdle e, per estensione, le azioni e le asserzioni dell'interfaccia utente di Compose direttamente dal thread principale.

In precedenza, i test di Compose applicavano rigorosamente un modello a due thread: l'esecuzione dei test avveniva su un thread di test in background, mentre gli aggiornamenti della UI avvenivano sul thread principale. La chiamata di metodi di sincronizzazione come waitForIdle o runOnIdle dal thread principale (ad esempio, all'interno di un blocco runOnUiThread) genererebbe un IllegalStateException perché il framework ha applicato controlli rigorosi dei thread per impedire la sincronizzazione del thread principale.

Con la sincronizzazione del thread principale attivata, il framework di test Compose ora può far avanzare l'orologio ed elaborare il lavoro in attesa anche quando vengono effettuate chiamate di blocco sul thread principale.

Quando utilizzare la sincronizzazione del thread principale

Sebbene mantenere i test sul thread in background rimanga lo standard per i test Compose puri, la sincronizzazione del thread principale è molto vantaggiosa in alcuni scenari specifici:

  • Interoperabilità di Complex View: quando si testano UI ibride contenenti sia Compose che View Android legacy, la manipolazione delle View spesso richiede l'esecuzione sul thread principale. Ora puoi interagire con le visualizzazioni e fare asserzioni sui nodi di composizione in sequenza senza cambiare continuamente i contesti dei thread.
  • Mutazioni di stato sincrone: se la tua architettura si basa su gestori di stato strettamente associati al thread principale, ora puoi modificare lo stato e attendere immediatamente che la UI di Compose si stabilizzi senza uscire dal thread principale.
  • Esecutori di test personalizzati: se stai creando un'infrastruttura di test personalizzata o utilizzando ambienti in cui l'esecutore di test viene eseguito in modo intrinseco sul thread principale, i test Compose ora vengono eseguiti in modo pulito senza richiedere la delega del thread in background.

Esempio

Storicamente, poiché la sincronizzazione era rigorosamente vietata nel thread principale, gli sviluppatori dovevano passare avanti e indietro tra il thread del runner di test in background e il thread dell'interfaccia utente, il che portava a test disgiunti:

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

Con la sincronizzazione del thread principale attivata, le asserzioni per le gerarchie Compose e View possono essere eseguite nello stesso blocco:

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

Attendere le condizioni

Qualsiasi condizione che dipende da un lavoro esterno, come il caricamento dei dati o la misurazione o il disegno di Android (ovvero la misurazione o il disegno esterni a Compose), deve utilizzare un concetto più generale come waitUntil():

composeTestRule.waitUntil(timeoutMs) { condition }

Puoi anche utilizzare uno qualsiasi degli helper waitUntil:

composeTestRule.waitUntilAtLeastOneExists(matcher, timeoutMs)

composeTestRule.waitUntilDoesNotExist(matcher, timeoutMs)

composeTestRule.waitUntilExactlyOneExists(matcher, timeoutMs)

composeTestRule.waitUntilNodeCount(matcher, count, timeoutMs)

Risorse aggiuntive

  • Testare le app su Android: la pagina di destinazione principale dei test Android offre una visione più ampia dei concetti fondamentali e delle tecniche di test.
  • Concetti fondamentali sul test di app Android: scopri di più sui concetti fondamentali alla base del test di un'app per Android.
  • Test locali: puoi eseguire alcuni test localmente, sulla tua workstation.
  • Test strumentati: è buona norma eseguire anche test strumentati. ovvero test eseguiti direttamente sul dispositivo.
  • Integrazione continua: l'integrazione continua consente di integrare i test nella pipeline di deployment.
  • Testa diverse dimensioni dello schermo: con tutti i dispositivi disponibili per gli utenti, devi eseguire test per diverse dimensioni dello schermo.
  • Espresso: sebbene sia destinato alle UI basate sulla visualizzazione, la conoscenza di Espresso può comunque essere utile per alcuni aspetti dei test di Compose.