Como testar no Jetpack Compose

1. Introdução e configuração

Neste codelab, você aprenderá a testar interfaces criadas com o Jetpack Compose. Você criará seus primeiros testes enquanto aprende sobre testes em isolamento, depuração de testes, árvores semânticas e sincronização.

O que é necessário

Conferir o código deste codelab (Rally)

Você usará o estudo de Rally Material (em inglês) como a base para este codelab. Ele pode ser encontrado no repositório do GitHub compose-codelabs. Para clonar, execute:

git clone https://github.com/android/codelab-android-compose.git

Após o download, abra o projeto TestingCodelab.

Outra opção é fazer o download de dois arquivos ZIP:

Abra a pasta TestingCodelab, que contém um app chamado Rally.

Examinar a estrutura do projeto

Os testes do Compose são testes de instrumentação. Isso significa que eles precisam de um dispositivo (dispositivo físico ou emulador) para serem executados.

O Rally já contém alguns testes de instrumentação de interface. Você pode encontrá-los no conjunto de origem androidTest:

b14721ae60ee9022.png

Esse é o diretório em que você colocará os novos testes. Fique à vontade para analisar o arquivo AnimatingCircleTests.kt para saber como é um teste do Compose.

O Rally já está configurado, mas tudo que você precisa para ativar os testes do Compose em um novo projeto são as dependências do teste no arquivo build.gradle do módulo relevante, que são:

androidTestImplementation "androidx.compose.ui:ui-test-junit4:$version"

debugImplementation "androidx.compose.ui:ui-test-manifest:$rootProject.composeVersion"

Você pode executar o app e se familiarizar com ele.

2. O que testar?

Vamos nos concentrar na barra de guias do Rally, que contém uma fileira de guias (Overview, Accounts e Bills). Ela tem esta aparência no contexto:

19c6a7eb9d732d37.gif

Neste codelab, você testará a interface da barra.

Isso pode ter muitos significados:

  • Testar se as guias mostram o ícone e o texto desejados
  • Testar se a animação corresponde à especificação
  • Testar se os eventos de navegação acionados estão corretos
  • Testar o posicionamento e as distâncias dos elementos da IU em diferentes estados
  • Fazer uma captura de tela da barra e comparar com uma captura de tela anterior

Não há regras exatas sobre quanto ou como testar um componente. Você pode executar todas as etapas acima. Neste codelab, você testará se a lógica de estado está correta verificando se:

  • uma guia mostra a etiqueta apenas quando está selecionada;
  • a tela ativa define a guia selecionada.

3. Criar um teste simples da interface

Criar o arquivo TopAppBarTest

Crie um novo arquivo na mesma pasta que AnimatingCircleTests.kt (app/src/androidTest/com/example/compose/rally) e chame de TopAppBarTest.kt.

O Compose vem com uma ComposeTestRule, que você pode receber chamando createComposeRule(). Essa regra permite que você defina o conteúdo do Compose em teste e interaja com ele.

Adicionar a ComposeTestRule

package com.example.compose.rally

import androidx.compose.ui.test.junit4.createComposeRule
import org.junit.Rule

class TopAppBarTest {

    @get:Rule
    val composeTestRule = createComposeRule()

    // TODO: Add tests
}

Teste em isolamento

Em um teste do Compose, é possível iniciar a atividade principal do app como você faria no contexto da View do Android usando, por exemplo, Espresso. Faça isso com createAndroidComposeRule.

// Don't copy this over

@get:Rule
val composeTestRule = createAndroidComposeRule(RallyActivity::class.java)

No entanto, com o Compose, podemos simplificar tudo consideravelmente testando um componente em isolamento. Você pode escolher o conteúdo da interface do Compose a ser usado no teste. Isso é feito com o método setContent da ComposeTestRule, e você pode chamá-lo em qualquer lugar (mas apenas uma vez).

// Don't copy this over

class TopAppBarTest {

    @get:Rule
    val composeTestRule = createComposeRule()
    
    @Test
    fun myTest() {
        composeTestRule.setContent { 
            Text("You can set any Compose content!")
        }
    }
}

Queremos testar a TopAppBar, então vamos nos concentrar nisso. Chame RallyTopAppBar dentro de setContent e deixe o Android Studio completar os nomes dos parâmetros.

import androidx.compose.ui.test.junit4.createComposeRule
import com.example.compose.rally.ui.components.RallyTopAppBar
import org.junit.Rule
import org.junit.Test

class TopAppBarTest {

    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun rallyTopAppBarTest() {
        composeTestRule.setContent {
            RallyTopAppBar(
                allScreens = ,
                onTabSelected = { /*TODO*/ },
                currentScreen =
            )
        }
    }
}

A importância de um elemento que pode ser composto e testado

A RallyTopAppBar utiliza três parâmetros que são fáceis de fornecer para que possamos passar dados falsos que controlamos. Por exemplo:

    @Test
    fun rallyTopAppBarTest() {
        val allScreens = RallyScreen.values().toList()
        composeTestRule.setContent { 
            RallyTopAppBar(
                allScreens = allScreens,
                onTabSelected = { },
                currentScreen = RallyScreen.Accounts
            )
        }
        Thread.sleep(5000)
    }

Também adicionamos um sleep() para você acompanhar o que está acontecendo. Clique com o botão direito do mouse em rallyTopAppBarTest e clique em "Run rallyTopAppBarTest()...".

baca545ddc8c3fa9.png

O teste mostra a barra superior de apps (por cinco segundos), mas ela não está com a aparência esperada: ela tem um tema claro.

Isso ocorre porque a barra é criada usando componentes do Material Design, que precisam estar dentro de um MaterialTheme ou voltam para as cores dos estilos do "valor de referência".

O MaterialTheme tem bons padrões para que não falhe. Como não testaremos o tema nem faremos capturas de tela, podemos omiti-lo e trabalhar com o tema claro padrão. Você pode unir RallyTopAppBar a RallyTheme para corrigir isso.

Verificar se a guia está selecionada

Para encontrar elementos da IU, verificar as propriedades deles e realizar ações, use a regra de teste, seguindo este padrão:

composeTestRule{.finder}{.assertion}{.action}

Neste teste, você procurará a palavra "Accounts" para verificar se a etiqueta da guia selecionada é exibida.

baca545ddc8c3fa9.png

Uma boa forma de entender quais ferramentas estão disponíveis é usar a Folha de referência de teste do Compose ou a documentação de referência do pacote de teste. Procure localizadores e declarações que possam ajudar na nossa situação. Por exemplo: onNodeWithText, onNodeWithContentDescription, isSelected, hasContentDescription, assertIsSelected...

Cada guia tem uma descrição do conteúdo diferente:

  • Overview
  • Accounts
  • Bills

Sabendo disso, substitua Thread.sleep(5000) por uma instrução que procure uma descrição do conteúdo e declare que ele existe:

import androidx.compose.ui.test.assertIsSelected
import androidx.compose.ui.test.onNodeWithContentDescription
...

@Test
fun rallyTopAppBarTest_currentTabSelected() {
    val allScreens = RallyScreen.values().toList()
    composeTestRule.setContent {
        RallyTopAppBar(
            allScreens = allScreens,
            onTabSelected = { },
            currentScreen = RallyScreen.Accounts
        )
    }

    composeTestRule
        .onNodeWithContentDescription(RallyScreen.Accounts.name)
        .assertIsSelected()
}

Execute o teste novamente e um teste verde será exibido:

75bab3b37e795b65.png

Parabéns! Você criou seu primeiro teste do Compose. Você aprendeu como testar em isolamento e usar localizadores e declarações.

Isso foi simples, mas exigiu algum conhecimento sobre o componente (as descrições de conteúdo e a propriedade selected). Na próxima etapa, você vai aprender como inspecionar as propriedades disponíveis.

4. Testes de depuração

Nesta etapa, você vai verificar se a etiqueta da guia atual é exibida em letras maiúsculas.

baca545ddc8c3fa9.png

Uma possível solução seria tentar encontrar o texto e afirmar que ele existe:

import androidx.compose.ui.test.onNodeWithText
...

@Test
fun rallyTopAppBarTest_currentLabelExists() {
    val allScreens = RallyScreen.values().toList()
    composeTestRule.setContent {
        RallyTopAppBar(
            allScreens = allScreens,
            onTabSelected = { },
            currentScreen = RallyScreen.Accounts
        )
    }

    composeTestRule
        .onNodeWithText(RallyScreen.Accounts.name.uppercase())
        .assertExists()
}

No entanto, se você executar o teste, ele vai falhar 😱

5755586203324389.png

Nesta etapa, você vai aprender como depurar isso usando a árvore semântica.

Árvore semântica

Os testes do Compose usam uma estrutura chamada árvore semântica para procurar elementos na tela e ler as propriedades deles. Essa é a estrutura que os serviços de acessibilidade também usam, já que foram projetados para serem lidos por um serviço como o TalkBack.

É possível imprimir a árvore semântica usando a função printToLog em um nó. Adicione uma nova linha ao teste:

import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.printToLog
...

fun rallyTopAppBarTest_currentLabelExists() {
    val allScreens = RallyScreen.values().toList()
    composeTestRule.setContent {
        RallyTopAppBar(
            allScreens = allScreens,
            onTabSelected = { },
            currentScreen = RallyScreen.Accounts
        )
    }

    composeTestRule.onRoot().printToLog("currentLabelExists")

    composeTestRule
        .onNodeWithText(RallyScreen.Accounts.name.uppercase())
        .assertExists() // Still fails
}

Agora, execute o teste e confira o Logcat no Android Studio (procure currentLabelExists).

...com.example.compose.rally D/currentLabelExists: printToLog:
    Printing with useUnmergedTree = 'false'
    Node #1 at (l=0.0, t=63.0, r=1080.0, b=210.0)px
     |-Node #2 at (l=0.0, t=63.0, r=1080.0, b=210.0)px
       [SelectableGroup]
       MergeDescendants = 'true'
        |-Node #3 at (l=42.0, t=105.0, r=105.0, b=168.0)px
        | Role = 'Tab'
        | Selected = 'false'
        | StateDescription = 'Not selected'
        | ContentDescription = 'Overview'
        | Actions = [OnClick]
        | MergeDescendants = 'true'
        | ClearAndSetSemantics = 'true'
        |-Node #6 at (l=189.0, t=105.0, r=468.0, b=168.0)px
        | Role = 'Tab'
        | Selected = 'true'
        | StateDescription = 'Selected'
        | ContentDescription = 'Accounts'
        | Actions = [OnClick]
        | MergeDescendants = 'true'
        | ClearAndSetSemantics = 'true'
        |-Node #11 at (l=552.0, t=105.0, r=615.0, b=168.0)px
          Role = 'Tab'
          Selected = 'false'
          StateDescription = 'Not selected'
          ContentDescription = 'Bills'
          Actions = [OnClick]
          MergeDescendants = 'true'
          ClearAndSetSemantics = 'true'

Observando a árvore semântica, você verá que há um SelectableGroup com três elementos filhos, que são as guias da barra de apps superior. Acontece que não há uma propriedade text com o valor "ACCOUNTS", e é por isso que o teste falha. No entanto, há uma descrição de conteúdo para cada guia. É possível verificar como essa propriedade é definida no elemento que pode ser composto RallyTab dentro de RallyTopAppBar.kt:

private fun RallyTab(text: String...)
...
    Modifier
        .clearAndSetSemantics { contentDescription = text }

Esse modificador está limpando as propriedades dos descendentes e definindo a própria descrição de conteúdo, por isso você vê "Accounts" em vez de "ACCOUNTS".

Substitua o localizador onNodeWithText por onNodeWithContentDescription e execute o teste novamente:

fun rallyTopAppBarTest_currentLabelExists() {
    val allScreens = RallyScreen.values().toList()
    composeTestRule.setContent {
        RallyTopAppBar(
            allScreens = allScreens,
            onTabSelected = { },
            currentScreen = RallyScreen.Accounts
        )
    }

    composeTestRule
        .onNodeWithContentDescription(RallyScreen.Accounts.name)
        .assertExists()
}

b5a7ae9f8f0ed750.png

Parabéns! Você corrigiu o teste e aprendeu sobre a ComposeTestRule, o teste em isolamento, os localizadores, as declarações e a depuração com a árvore semântica.

Má notícia: esse teste não é muito útil. Se você observar a árvore semântica de perto, as descrições de conteúdo das três guias serão exibidas mesmo que a guia não esteja selecionada. Precisamos de mais detalhes.

5. Árvores semânticas mescladas e não mescladas

A árvore semântica sempre tenta ser o mais compacta possível, mostrando apenas as informações relevantes.

Por exemplo, em nossa TopAppBar, não é necessário que os ícones e as etiquetas sejam nós diferentes. Confira o nó "Overview":

120e5327856286cd.png

        |-Node #3 at (l=42.0, t=105.0, r=105.0, b=168.0)px
        | Role = 'Tab'
        | Selected = 'false'
        | StateDescription = 'Not selected'
        | ContentDescription = 'Overview'
        | Actions = [OnClick]
        | MergeDescendants = 'true'
        | ClearAndSetSemantics = 'true'

Esse nó tem propriedades (como Selected e Role) que são definidas especificamente para um componente selectable e uma descrição de conteúdo para a guia inteira. Essas propriedades de alto nível são muito úteis para testes simples. Os detalhes sobre o ícone ou o texto seriam redundantes, por isso não são exibidos.

O Compose expõe essas propriedades semânticas automaticamente em alguns elementos que podem ser compostos, como Text. Também é possível personalizar e mesclar para representar um único componente composto por um ou vários descendentes. Por exemplo: é possível representar um Button que contenha um Text que pode ser composto. A propriedade MergeDescendants = 'true' nos informa que esse nó tinha descendentes, mas eles foram mesclados a ele. Muitas vezes, precisamos acessar todos os nós nos testes.

Para verificar se o Text dentro da guia é exibido ou não, podemos consultar a árvore semântica não mesclada passando useUnmergedTree = true para o localizador onRoot.

@Test
fun rallyTopAppBarTest_currentLabelExists() {
    val allScreens = RallyScreen.values().toList()
    composeTestRule.setContent {
        RallyTopAppBar(
            allScreens = allScreens,
            onTabSelected = { },
            currentScreen = RallyScreen.Accounts
        )
    }

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

}

A saída no Logcat está um pouco mais longa agora:

    Printing with useUnmergedTree = 'true'
    Node #1 at (l=0.0, t=63.0, r=1080.0, b=210.0)px
     |-Node #2 at (l=0.0, t=63.0, r=1080.0, b=210.0)px
       [SelectableGroup]
       MergeDescendants = 'true'
        |-Node #3 at (l=42.0, t=105.0, r=105.0, b=168.0)px
        | Role = 'Tab'
        | Selected = 'false'
        | StateDescription = 'Not selected'
        | ContentDescription = 'Overview'
        | Actions = [OnClick]
        | MergeDescendants = 'true'
        | ClearAndSetSemantics = 'true'
        |-Node #6 at (l=189.0, t=105.0, r=468.0, b=168.0)px
        | Role = 'Tab'
        | Selected = 'true'
        | StateDescription = 'Selected'
        | ContentDescription = 'Accounts'
        | Actions = [OnClick]
        | MergeDescendants = 'true'
        | ClearAndSetSemantics = 'true'
        |  |-Node #9 at (l=284.0, t=105.0, r=468.0, b=154.0)px
        |    Text = 'ACCOUNTS'
        |    Actions = [GetTextLayoutResult]
        |-Node #11 at (l=552.0, t=105.0, r=615.0, b=168.0)px
          Role = 'Tab'
          Selected = 'false'
          StateDescription = 'Not selected'
          ContentDescription = 'Bills'
          Actions = [OnClick]
          MergeDescendants = 'true'
          ClearAndSetSemantics = 'true'

O nó 3 ainda não tem descendentes:

        |-Node #3 at (l=42.0, t=105.0, r=105.0, b=168.0)px
        | Role = 'Tab'
        | Selected = 'false'
        | StateDescription = 'Not selected'
        | ContentDescription = 'Overview'
        | Actions = [OnClick]
        | MergeDescendants = 'true'
        | ClearAndSetSemantics = 'true'

Mas o nó 6, a guia selecionada, tem um, e agora podemos ver a propriedade "Text":

        |-Node #6 at (l=189.0, t=105.0, r=468.0, b=168.0)px
        | Role = 'Tab'
        | Selected = 'true'
        | StateDescription = 'Selected'
        | ContentDescription = 'Accounts'
        | Actions = [OnClick]
        | MergeDescendants = 'true'
        |  |-Node #9 at (l=284.0, t=105.0, r=468.0, b=154.0)px
        |    Text = 'ACCOUNTS'
        |    Actions = [GetTextLayoutResult]

Para verificar o comportamento correto que queremos, crie um correspondente que encontre um nó com o texto "ACCOUNTS" cujo pai é um nó com a descrição de conteúdo "Accounts".

Verifique a Folha de referência de teste do Compose novamente e tente encontrar uma forma de criar esse correspondente. É possível usar operadores booleanos, como and e or, com correspondentes.

Todos os localizadores têm um parâmetro chamado useUnmergedTree. Defina-o como true para usar a árvore não mesclada.

Crie o teste sem examinar a solução.

Solução

import androidx.compose.ui.test.hasParent
import androidx.compose.ui.test.hasText
...

@Test
fun rallyTopAppBarTest_currentLabelExists() {
    val allScreens = RallyScreen.values().toList()
    composeTestRule.setContent {
        RallyTopAppBar(
            allScreens = allScreens,
            onTabSelected = { },
            currentScreen = RallyScreen.Accounts
        )
    }

    composeTestRule
        .onNode(
            hasText(RallyScreen.Accounts.name.uppercase()) and
            hasParent(
                hasContentDescription(RallyScreen.Accounts.name)
            ),
            useUnmergedTree = true
        )
        .assertExists()
}

Continue e execute o teste:

94c57e2cfc12c10b.png

Parabéns! Nesta etapa, você aprendeu sobre a fusão de propriedades e as árvores semânticas mescladas e não mescladas.

6. Sincronização

Todos os testes criados precisam ser sincronizados corretamente com o tema a ser testado. Por exemplo, quando você usa um localizador como onNodeWithText, o teste aguarda até que o app esteja inativo antes de consultar a árvore semântica. Sem a sincronização, os testes poderiam buscar elementos antes de serem exibidos ou esperariam desnecessariamente.

Usaremos a tela "Overview" nesta etapa, que aparece assim quando você executa o app:

8c467af3570b8de6.gif

Observe a animação intermitente do cartão Alertas, chamando atenção para esse elemento.

Crie outra classe de teste chamada OverviewScreenTest e adicione o seguinte conteúdo:

package com.example.compose.rally

import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import com.example.compose.rally.ui.overview.OverviewBody
import org.junit.Rule
import org.junit.Test

class OverviewScreenTest {

    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun overviewScreen_alertsDisplayed() {
        composeTestRule.setContent {
            OverviewBody()
        }

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

Ao executar esse teste, você vai observar que ele nunca termina (expira em 30 segundos).

b2d71bd417326bd3.png

O erro diz:

androidx.compose.ui.test.junit4.android.ComposeNotIdleException: Idling resource timed out: possibly due to compose being busy.
IdlingResourceRegistry has the following idling resources registered:
- [busy] androidx.compose.ui.test.junit4.android.ComposeIdlingResource@d075f91 

Isso significa que o Compose está permanentemente ocupado, portanto, não é possível sincronizar o app com o teste.

Talvez você já tenha percebido que o problema é a animação intermitente infinita. Como o app nunca fica inativo, o teste não pode continuar.

Confira a implementação da animação infinita:

app/src/main/java/com/example/compose/rally/ui/overview/OverviewBody.kt

var currentTargetElevation by remember {  mutableStateOf(1.dp) }
LaunchedEffect(Unit) {
    // Start the animation
    currentTargetElevation = 8.dp
}
val animatedElevation = animateDpAsState(
    targetValue = currentTargetElevation,
    animationSpec = tween(durationMillis = 500),
    finishedListener = {
        currentTargetElevation = if (currentTargetElevation > 4.dp) {
            1.dp
        } else {
            8.dp
        }
    }
)
Card(elevation = animatedElevation.value) { ... }

Essencialmente, esse código aguarda a conclusão de uma animação (finishedListener) e a executa novamente.

Uma abordagem para corrigir esse teste seria desativar as animações nas opções para desenvolvedores. Essa é uma das formas amplamente aceitas de lidar com isso no contexto da View.

No Compose, as APIs de animação foram projetadas pensando na capacidade de teste, então o problema pode ser corrigido usando a API correta. Em vez de reiniciar a animação animateDpAsState, podemos usar animações infinitas.

Substitua o código em OverviewScreen pela API adequada:

import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.VectorConverter
import androidx.compose.animation.core.animateValue
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.ui.unit.Dp
...

    val infiniteElevationAnimation = rememberInfiniteTransition()
    val animatedElevation: Dp by infiniteElevationAnimation.animateValue(
        initialValue = 1.dp,
        targetValue = 8.dp,
        typeConverter = Dp.VectorConverter,
        animationSpec = infiniteRepeatable(
            animation = tween(500),
            repeatMode = RepeatMode.Reverse
        )
    )
    Card(elevation = animatedElevation) {

Se você executar o teste, ele será concluído agora:

369e266eed40e4e4.png

Parabéns! Nesta etapa, você aprendeu sobre sincronização e como as animações podem afetar os testes.

7. Exercício opcional

Nesta etapa, você vai usar uma ação (consulte a Folha de referência de teste) para verificar se clicar nas diferentes guias da RallyTopAppBar muda a seleção.

Dicas:

  • O escopo do teste precisa incluir o estado, que pertence a RallyApp.
  • Verifique o estado, não o comportamento. Use declarações no estado da IU em vez de depender dos objetos que foram chamados e de como isso foi feito.

Este exercício não oferece uma solução.

8. Próximas etapas

Parabéns! Você concluiu o codelab Como testar no Jetpack Compose. Agora você tem os elementos básicos para criar uma boa estratégia de teste para as IUs do Compose.

Para saber mais sobre os testes do Compose, confira estes recursos:

  1. A documentação de teste contém mais informações sobre localizadores, declarações, ações e correspondentes, além de mecanismos de sincronização, manipulação de tempo etc.
  2. Adicione a Folha de referência de teste aos favoritos.
  3. O exemplo do Rally vem com uma classe simples de teste de captura de tela. Explore o arquivo AnimatingCircleTests.kt para saber mais.
  4. Para ver orientações gerais sobre como testar apps Android, siga estes três codelabs:
  1. O repositório de exemplos do Compose (link em inglês) no GitHub tem vários apps com testes de IU.
  2. O Caminho do Jetpack Compose mostra uma lista de recursos para você começar a usar o Compose.

Divirta-se!