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 que pode ser composto até uma tela cheia. A árvore semântica é gerada junto com a hierarquia da 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 createAndroidComposeRule:
debugImplementation("androidx.compose.ui:ui-test-manifest:$compose_version")

Esse módulo inclui uma ComposeTestRule e uma implementação para Android chamada AndroidComposeTestRule. Com essa regra, é possível definir o conteúdo do Compose ou acessar a atividade. O teste de IU 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 no 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 é usar seletores, que podem tornar 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 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 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 cujos resultados afetam o estado da IU podem ser executadas em segundo plano, enquanto não afetarem 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. Qualquer condição que dependa de medidas ou desenhos do Android (ou seja, medidas ou desenhos externos ao Compose) precisa usar um conceito mais geral, como waitUntil():

composeTestRule.waitUntil(timeoutMs) { condition }

Padrões comuns

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

Teste em isolamento

A ComposeTestRule permite que você inicie uma atividade exibindo qualquer elemento que pode ser composto, como o app inteiro, uma única tela ou um pequeno elemento. 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.

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

A partir dos testes, você pode usar SemanticsMatcher.expectValue para declarar o valor da propriedade:

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

Depurar

A principal maneira de resolver problemas nos testes é observar a árvore semântica. Você pode gerar a árvore chamando findRoot().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. As visualizações são correspondidas usando onView do Espresso e os elementos do Compose, usando 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()))
}

Saiba mais

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