Como testar o layout do Compose

O teste de IUs ou de telas é usado para verificar o comportamento correto do código do Compose, melhorando a qualidade do app ao detectar erros no início do processo de desenvolvimento.

O Compose fornece um conjunto de APIs de teste para encontrar elementos, verificar atributos e executar ações do usuário. As APIs também incluem recursos avançados, como manipulação de tempo.

Semântica

Os testes de IU no Compose usam semântica para interagir com a hierarquia da IU. A semântica, como o nome indica, dá um significado para uma parte da IU. Nesse contexto, uma "parte da IU" (ou elemento) pode significar qualquer coisa, desde um único elemento combinável até uma tela cheia. A árvore semântica é gerada junto com a hierarquia de IU e a descreve.

Diagrama mostrando um layout típico de IU e a maneira como esse layout seria mapeado para uma árvore semântica correspondente

Figura 1. Uma hierarquia de IU típica e a árvore de semântica dela.

O framework de semântica é usado principalmente para acessibilidade. Portanto, os testes aproveitam as informações expostas pela semântica sobre a hierarquia da IU. Os desenvolvedores decidem o que e quanto precisa ser exposto.

Um botão com elemento gráfico e texto

Figura 2. Um botão típico com um ícone e um texto.

Por exemplo, considerando um botão como este, que consiste em um ícone e um elemento de texto, a árvore semântica padrão contém apenas o rótulo de texto "Like". Isso ocorre porque alguns elementos que podem ser compostos, como Text, já expõem algumas propriedades à árvore semântica. É possível adicionar propriedades à árvore semântica usando um Modifier.

MyButton(
    modifier = Modifier.semantics { contentDescription = "Add to favorites" }
)

Configurar

Esta seção descreve como configurar seu módulo para permitir que você teste o código do Compose.

Primeiro, adicione as seguintes dependências ao arquivo build.gradle do módulo que contém seus testes de IU:

// Test rules and transitive dependencies:
androidTestImplementation("androidx.compose.ui:ui-test-junit4:$compose_version")
// Needed for createComposeRule(), but not for createAndroidComposeRule<YourActivity>():
debugImplementation("androidx.compose.ui:ui-test-manifest:$compose_version")

Esse módulo inclui um ComposeTestRule e uma implementação para Android chamada AndroidComposeTestRule. Com essa regra, é possível definir o conteúdo do Compose ou acessar a atividade. As regras são criadas usando funções de fábrica createComposeRule ou createAndroidComposeRule se você precisar de acesso a uma atividade. Um teste de interface típico do Compose tem esta aparência:

// file: app/src/androidTest/java/com/package/MyComposeTest.kt

class MyComposeTest {

    @get:Rule val composeTestRule = createComposeRule()
    // use createAndroidComposeRule<YourActivity>() if you need access to
    // an activity

    @Test
    fun myTest() {
        // Start the app
        composeTestRule.setContent {
            MyAppTheme {
                MainScreen(uiState = fakeUiState, /*...*/)
            }
        }

        composeTestRule.onNodeWithText("Continue").performClick()

        composeTestRule.onNodeWithText("Welcome").assertIsDisplayed()
    }
}

Como testar APIs

Existem três maneiras principais de interagir com os elementos:

  • Os finders permitem selecionar um ou vários elementos (ou nós na árvore semântica) para fazer declarações ou executar ações.
  • As declarações são usadas para verificar se os elementos existem ou têm determinados atributos.
  • As ações injetam eventos de usuário simulados nos elementos, como cliques ou outros gestos.

Algumas APIs aceitam um SemanticsMatcher para se referir a um ou mais nós na árvore semântica.

Finders

É possível usar onNode e onAllNodes para selecionar um ou vários nós, respectivamente. Você também pode usar finders de conveniência para as pesquisas mais comuns, como onNodeWithText, onNodeWithContentDescription etc. A lista completa está disponível na Folha de referência de testes do Compose.

Selecionar um único nó

composeTestRule.onNode(<<SemanticsMatcher>>, useUnmergedTree = false): SemanticsNodeInteraction
// Example
composeTestRule
    .onNode(hasText("Button")) // Equivalent to onNodeWithText("Button")

Selecionar vários nós

composeTestRule
    .onAllNodes(<<SemanticsMatcher>>): SemanticsNodeInteractionCollection
// Example
composeTestRule
    .onAllNodes(hasText("Button")) // Equivalent to onAllNodesWithText("Button")

Como usar a árvore não mesclada

Alguns nós mesclam informações de semântica dos filhos deles. Por exemplo, um botão com dois elementos de texto mescla os rótulos:

MyButton {
    Text("Hello")
    Text("World")
}

Em um teste, podemos usar printToLog() para exibir a árvore semântica:

composeTestRule.onRoot().printToLog("TAG")

Esse código gera a seguinte saída:

Node #1 at (...)px
 |-Node #2 at (...)px
   Role = 'Button'
   Text = '[Hello, World]'
   Actions = [OnClick, GetTextLayoutResult]
   MergeDescendants = 'true'

Se você precisar fazer a correspondência de um nó do que seria a árvore não mesclada, defina useUnmergedTree como true:

composeTestRule.onRoot(useUnmergedTree = true).printToLog("TAG")

Esse código gera a seguinte saída:

Node #1 at (...)px
 |-Node #2 at (...)px
   OnClick = '...'
   MergeDescendants = 'true'
    |-Node #3 at (...)px
    | Text = '[Hello]'
    |-Node #5 at (83.0, 86.0, 191.0, 135.0)px
      Text = '[World]'

O parâmetro useUnmergedTree está disponível em todos os finders. Por exemplo, aqui ele é usado em um finder onNodeWithText.

composeTestRule
    .onNodeWithText("World", useUnmergedTree = true).assertIsDisplayed()

Declarações

Verifique as declarações chamando assert() na SemanticsNodeInteraction retornada por um finder com um ou vários matchers:

// Single matcher:
composeTestRule
    .onNode(matcher)
    .assert(hasText("Button")) // hasText is a SemanticsMatcher

// Multiple matchers can use and / or
composeTestRule
    .onNode(matcher).assert(hasText("Button") or hasText("Button2"))

Também é possível usar funções de conveniência para as declarações mais comuns, como assertExists, assertIsDisplayed, assertTextEquals, entre outras. A lista completa pode ser encontrada na Folha de referência de testes no Compose.

Também há funções para verificar declarações em uma coleção de nós:

// Check number of matched nodes
composeTestRule
    .onAllNodesWithContentDescription("Beatle").assertCountEquals(4)
// At least one matches
composeTestRule
    .onAllNodesWithContentDescription("Beatle").assertAny(hasTestTag("Drummer"))
// All of them match
composeTestRule
    .onAllNodesWithContentDescription("Beatle").assertAll(hasClickAction())

Ações

Para injetar uma ação em um nó, chame uma função perform…():

composeTestRule.onNode(...).performClick()

Veja alguns exemplos de ações:

performClick(),
performSemanticsAction(key),
performKeyPress(keyEvent),
performGesture { swipeLeft() }

A lista completa pode ser encontrada na Folha de referência de testes no Compose.

Matchers

Esta seção descreve alguns dos matchers disponíveis para testes do código do Compose.

Matchers hierárquicos

Os matchers hierárquicos permitem subir ou descer na árvore semântica e realizar correspondências simples.

fun hasParent(matcher: SemanticsMatcher): SemanticsMatcher
fun hasAnySibling(matcher: SemanticsMatcher): SemanticsMatcher
fun hasAnyAncestor(matcher: SemanticsMatcher): SemanticsMatcher
fun hasAnyDescendant(matcher: SemanticsMatcher):  SemanticsMatcher

Veja alguns exemplos desses matchers em uso:

composeTestRule.onNode(hasParent(hasText("Button")))
    .assertIsDisplayed()

Seletores

Uma forma alternativa de criar testes é usando seletores, que podem deixar alguns testes mais legíveis.

composeTestRule.onNode(hasTestTag("Players"))
    .onChildren()
    .filter(hasClickAction())
    .assertCountEquals(4)
    .onFirst()
    .assert(hasText("John"))

A lista completa pode ser encontrada na Folha de referência de testes no Compose.

Sincronização

Por padrão, os testes do Compose são sincronizados com sua IU. Quando você chama uma declaração ou uma ação usando ComposeTestRule, o teste vai ser sincronizado antecipadamente enquanto aguarda até que a árvore da IU fique inativa.

Normalmente, não é necessário fazer nada. No entanto, existem alguns casos extremos que você precisa conhecer.

Quando um teste é sincronizado, o tempo do app Compose é avançado usando um relógio virtual. Isso significa que os testes do Compose não são executados em tempo real, para que possam ser realizados o mais rápido possível.

No entanto, caso você não use os métodos que sincronizam os testes, nenhuma recomposição vai ocorrer, e a IU aparentará estar pausada.

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

Também é importante observar que esse requisito se aplica apenas a hierarquias do Compose, e não ao restante do app.

Como desativar a sincronização automática

Quando você chama uma declaração ou ação usando ComposeTestRule, como assertExists(), seu teste é sincronizado com a IU do Compose. Em alguns casos, pode ser necessário interromper essa sincronização e controlar o relógio. Por exemplo, você pode controlar o tempo para fazer capturas de tela precisas de uma animação em um ponto em que a IU ainda estaria ocupada. Para desativar a sincronização automática, defina a propriedade autoAdvance em mainClock como false:

composeTestRule.mainClock.autoAdvance = false

Normalmente, isso fará com que o tempo seja avançado. É possível avançar exatamente um frame com advanceTimeByFrame() ou um intervalo específico com advanceTimeBy():

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

Recursos de inatividade

O Compose pode sincronizar testes e a IU para que todas as ações e declarações sejam executadas em estado inativo enquanto estão aguardando ou avançando o relógio conforme necessário. No entanto, algumas operações assíncronas com resultados que afetam o estado da IU podem ser executadas em segundo plano enquanto não afetam os testes.

É possível criar e registrar esses recursos de inatividade no teste, para que eles sejam considerados ao decidir se o app sendo testado está ocupado ou inativo. Não é necessário fazer nada, a menos que você precise registrar outros recursos de inatividade, por exemplo, executar um job em segundo plano que não esteja sincronizado com o Espresso ou Compose.

Essa API é muito semelhante aos Recursos de inatividade do Espresso, usados para indicar se o assunto sendo testado está inativo ou ocupado. Use a regra de teste do Compose para registrar a implementação de IdlingResource.

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

Sincronização manual

Em alguns casos, você precisa sincronizar a IU do Compose com outras partes do teste ou do app sendo testado.

waitForIdle aguarda que o Compose esteja inativo, mas depende da propriedade 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

Em ambos os casos, waitForIdle também aguardará transmissões de layout e desenho pendentes.

Além disso, você pode avançar o relógio até que uma determinada condição seja atendida com advanceTimeUntil().

composeTestRule.mainClock.advanceTimeUntil(timeoutMs) { condition }

A condição especificada precisa ser verificar o estado que pode ser afetado por esse relógio. Isso só funciona com estados no Compose.

Aguardando condições

Qualquer condição que dependa de trabalho externo, como o carregamento de dados ou a medida ou desenho do Android (ou seja, medição ou desenho externo ao Compose), precisa usar um conceito mais geral, como waitUntil():

composeTestRule.waitUntil(timeoutMs) { condition }

Também é possível usar qualquer um dos auxiliares waitUntil:

composeTestRule.waitUntilAtLeastOneExists(matcher, timeoutMs)

composeTestRule.waitUntilDoesNotExist(matcher, timeoutMs)

composeTestRule.waitUntilExactlyOneExists(matcher, timeoutMs)

composeTestRule.waitUntilNodeCount(matcher, count, timeoutMs)

Padrões comuns

Esta seção descreve algumas abordagens comuns que você verá nos testes do Compose.

Testar de forma isolada

A ComposeTestRule permite que você inicie uma atividade mostrando qualquer elemento combinável, como o app inteiro, uma única tela ou um elemento pequeno. Também é aconselhável verificar se os elementos que podem ser compostos estão encapsulados corretamente e se funcionam de forma independente, permitindo testes de IU mais fáceis e mais focados.

Isso não significa que você pode criar testes de IU de unidade. Os testes voltados para partes maiores da IU também são muito importantes.

Acessar a atividade e os recursos depois de definir seu conteúdo

Muitas vezes, é necessário definir o conteúdo que está em teste usando composeTestRule.setContent e também acessar recursos de atividade, por exemplo, para declarar que um texto mostrado corresponde a um recurso de string. No entanto, não vai ser possível chamar o setContent em uma regra criada com createAndroidComposeRule() se ele já tiver sido chamado pela atividade.

Um padrão comum para fazer isso é criar uma AndroidComposeTestRule usando uma atividade vazia, como ComponentActivity.

class MyComposeTest {

    @get:Rule
    val composeTestRule = createAndroidComposeRule<ComponentActivity>()

    @Test
    fun myTest() {
        // Start the app
        composeTestRule.setContent {
            MyAppTheme {
                MainScreen(uiState = exampleUiState, /*...*/)
            }
        }
        val continueLabel = composeTestRule.activity.getString(R.string.next)
        composeTestRule.onNodeWithText(continueLabel).performClick()
    }
}

Observe que a ComponentActivity precisa ser adicionada ao arquivo AndroidManifest.xml do app. Você pode fazer isso adicionando esta dependência ao módulo:

debugImplementation("androidx.compose.ui:ui-test-manifest:$compose_version")

Propriedades de semântica personalizadas

É possível criar propriedades de semântica personalizadas para expor informações aos testes. Para fazer isso, defina uma nova SemanticsPropertyKey e disponibilize-a usando o SemanticsPropertyReceiver.

// Creates a Semantics property of type Long
val PickedDateKey = SemanticsPropertyKey<Long>("PickedDate")
var SemanticsPropertyReceiver.pickedDate by PickedDateKey

Agora, você pode usar essa propriedade usando o modificador semantics:

val datePickerValue by remember { mutableStateOf(0L) }
MyCustomDatePicker(
    modifier = Modifier.semantics { pickedDate = datePickerValue }
)

Após os testes, você pode usar SemanticsMatcher.expectValue para declarar o valor da propriedade:

composeTestRule
    .onNode(SemanticsMatcher.expectValue(PickedDateKey, 1445378400)) // 2015-10-21
    .assertExists()

Verificar a restauração do estado

Verifique se o estado dos elementos do Compose é restaurado corretamente quando a atividade ou o processo são recriados. É possível fazer essa verificação com a classe StateRestorationTester sem depender da recriação de atividades.

Essa classe permite simular a recriação de um elemento combinável. Ela é especialmente útil para verificar a implementação de rememberSaveable.


class MyStateRestorationTests {

    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun onRecreation_stateIsRestored() {
        val restorationTester = StateRestorationTester(composeTestRule)

        restorationTester.setContent { MainScreen() }

        // TODO: Run actions that modify the state

        // Trigger a recreation
        restorationTester.emulateSavedInstanceStateRestore()

        // TODO: Verify that state has been correctly restored.
    }
}

Testar diferentes configurações de dispositivos

Os apps Android precisam se adaptar a muitas condições em mudança: tamanhos de janela, localidades, tamanhos de fonte, temas escuros e claros e muito mais. A maioria dessas condições é derivada de valores no nível do dispositivo controlados pelo usuário e expostas com a instância Configuration atual. Testar configurações diferentes diretamente em um teste é difícil, porque o teste precisa configurar propriedades no nível do dispositivo.

DeviceConfigurationOverride é uma API somente de teste que permite simular diferentes configurações de dispositivos de maneira localizada para o conteúdo @Composable em teste.

O objeto complementar de DeviceConfigurationOverride tem as seguintes funções de extensão, que substituem as propriedades de configuração no nível do dispositivo:

Para aplicar uma substituição específica, envolva o conteúdo em teste em uma chamada para a função de nível superior DeviceConfigurationOverride(), transmitindo a substituição para aplicar como um parâmetro.

Por exemplo, o código abaixo aplica a substituição DeviceConfigurationOverride.ForcedSize() para mudar a densidade localmente, forçando o elemento combinável MyScreen a ser renderizado em uma janela grande no modo paisagem, mesmo que o dispositivo em que o teste está sendo executado não ofereça suporte direto a esse tamanho de janela:

composeTestRule.setContent {
    DeviceConfigurationOverride(
        DeviceConfigurationOverride.ForcedSize(DpSize(1280.dp, 800.dp))
    ) {
        MyScreen() // will be rendered in the space for 1280dp by 800dp without clipping
    }
}

Para aplicar várias substituições juntas, use DeviceConfigurationOverride.then():

composeTestRule.setContent {
    DeviceConfigurationOverride(
        DeviceConfigurationOverride.FontScale(1.5f) then
            DeviceConfigurationOverride.FontWeightAdjustment(200)
    ) {
        Text(text = "text with increased scale and weight")
    }
}

Depuração

A principal maneira de resolver problemas nos testes é observar a árvore semântica. Você pode gerar a árvore chamando composeTestRule.onRoot().printToLog() a qualquer momento no teste. Essa função gera um registro como este:

Node #1 at (...)px
 |-Node #2 at (...)px
   OnClick = '...'
   MergeDescendants = 'true'
    |-Node #3 at (...)px
    | Text = 'Hi'
    |-Node #5 at (83.0, 86.0, 191.0, 135.0)px
      Text = 'There'

Esses registros contêm informações importantes para encontrar bugs.

Interoperabilidade com o Espresso

Em um app híbrido, é possível encontrar componentes do Compose dentro de hierarquias de visualização e visualizações dentro dos elementos do Compose (com o elemento AndroidView).

Não há etapas específicas que sejam necessárias para fazer correspondência a nenhum dos tipos. A correspondência de visualizações é feita usando o método onView do Espresso e os elementos do Compose com ComposeTestRule.

@Test
fun androidViewInteropTest() {
    // Check the initial state of a TextView that depends on a Compose state:
    Espresso.onView(withText("Hello Views")).check(matches(isDisplayed()))
    // Click on the Compose button that changes the state
    composeTestRule.onNodeWithText("Click here").performClick()
    // Check the new value
    Espresso.onView(withText("Hello Compose")).check(matches(isDisplayed()))
}

Interoperabilidade com o UiAutomator

Por padrão, os elementos combináveis podem ser acessados no UiAutomator apenas pelos descritores convenientes (texto mostrado, descrição do conteúdo etc.). Caso queira acessar um elemento combinável que use Modifier.testTag, é necessário ativar a propriedade semântica testTagsAsResourceId para a subárvore de elementos combináveis específicos. Ativar esse comportamento é útil para elementos combináveis que não têm nenhum outro elemento exclusivo, por exemplo, elementos roláveis como a LazyColumn.

Só é possível ativar o recurso uma vez na hierarquia dos elementos de composição para garantir que todos os elementos de composição aninhados com Modifier.testTag possam ser acessados pelo UiAutomator.

Scaffold(
    // Enables for all composables in the hierarchy.
    modifier = Modifier.semantics {
        testTagsAsResourceId = true
    }
){
    // Modifier.testTag is accessible from UiAutomator for composables nested here.
    LazyColumn(
        modifier = Modifier.testTag("myLazyColumn")
    ){
        // content
    }
}

Todos os elementos de composição com Modifier.testTag(tag) podem ser acessados com By.res(resourceName) usando a mesma tag do resourceName.

val device = UiDevice.getInstance(getInstrumentation())

val lazyColumn: UiObject2 = device.findObject(By.res("myLazyColumn"))
// some interaction with the lazyColumn

Saiba mais

Para saber mais, consulte o codelab sobre como testar no Jetpack Compose.

Exemplos