Estabilidade de testes grandes

A natureza assíncrona dos aplicativos e frameworks para dispositivos móveis muitas vezes dificulta a criação de testes confiáveis e repetíveis. Quando um evento do usuário é injetado, o framework de teste precisa esperar que o app termine de reagir a ele, o que pode variar desde a alteração de algum texto na tela até uma recriação completa de uma atividade. Quando um teste não tem um comportamento determinístico, ele é instável.

Frameworks modernos, como o Compose ou o Espresso, são projetados com foco em testes, portanto, há uma certa garantia de que a interface ficará inativa antes da próxima ação ou declaração de teste. Isso é chamado de sincronização.

Testar a sincronização

Os problemas ainda podem surgir quando você executa operações assíncronas ou em segundo plano desconhecidas ao teste, como o carregamento de dados de um banco de dados ou a exibição de animações infinitas.

diagrama de fluxo mostrando um loop que verifica se o app está inativo antes de fazer uma transmissão de teste
Figura 1: teste de sincronização.

Para aumentar a confiabilidade do seu pacote de testes, é possível instalar uma maneira de rastrear operações em segundo plano, como os recursos de inatividade do Espresso. Além disso, é possível substituir módulos para testar versões que podem ser consultadas para inatividade ou que melhoram a sincronização, como TestDispatcher para corrotinas ou RxIdler para RxJava.

Diagrama mostrando uma falha de teste quando a sincronização é baseada na espera por um tempo fixo
Figura 2: o uso do sleep em testes resulta em testes lentos ou instáveis.

Maneiras de melhorar a estabilidade

Os testes grandes podem detectar muitas regressões ao mesmo tempo porque testam vários componentes de um app. Eles geralmente são executados em emuladores ou dispositivos, o que significa que têm alta fidelidade. Embora os testes completos grandes ofereçam cobertura abrangente, eles são mais propensos a falhas ocasionais.

As principais medidas que você pode tomar para reduzir a instabilidade são as seguintes:

  • Configurar os dispositivos corretamente
  • Evitar problemas de sincronização
  • Implementar novas tentativas

Para criar testes grandes usando o Compose ou o Espresso, normalmente você inicia uma das atividades e navega como um usuário, verificando se a interface se comporta corretamente usando declarações ou testes de captura de tela.

Outros frameworks, como o UI Automator, permitem um escopo maior, já que você pode interagir com a interface do sistema e outros apps. No entanto, os testes do UI Automator podem exigir mais sincronização manual, portanto, eles tendem a ser menos confiáveis.

Configurar dispositivos

Primeiro, para melhorar a confiabilidade dos testes, verifique se o sistema operacional do dispositivo não interrompe inesperadamente a execução dos testes. Por exemplo, quando uma caixa de diálogo de atualização do sistema é mostrada sobre outros apps ou quando o espaço em disco é insuficiente.

Os provedores de farms de dispositivos configuram os dispositivos e emuladores. Normalmente, não é necessário fazer nada. No entanto, eles podem ter as próprias diretivas de configuração para casos especiais.

Dispositivos gerenciados pelo Gradle

Se você mesmo gerenciar os emuladores, poderá usar dispositivos gerenciados pelo Gradle para definir quais dispositivos usar para executar os testes:

android {
  testOptions {
    managedDevices {
      localDevices {
        create("pixel2api30") {
          // Use device profiles you typically see in Android Studio.
          device = "Pixel 2"
          // Use only API levels 27 and higher.
          apiLevel = 30
          // To include Google services, use "google".
          systemImageSource = "aosp"
        }
      }
    }
  }
}

Com essa configuração, o comando a seguir vai criar uma imagem do emulador, iniciar uma instância, executar os testes e encerrar.

./gradlew pixel2api30DebugAndroidTest

Os dispositivos gerenciados pelo Gradle contêm mecanismos para tentar novamente em caso de desconexões e outras melhorias.

Evitar problemas de sincronização

Componentes que executam operações em segundo plano ou assíncronas podem levar a falhas porque uma instrução de teste foi executada antes que a interface estivesse pronta. À medida que o escopo de um teste aumenta, as chances de ele se tornar instável também aumentam. Esses problemas de sincronização são uma das principais fontes de instabilidade porque os frameworks de teste precisam deduzir se uma atividade foi concluída ou se ela precisa esperar mais.

Soluções

É possível usar os recursos de inatividade do Espresso para indicar quando um app está ocupado, mas é difícil rastrear cada operação assíncrona, principalmente em testes de ponta a ponta muito grandes. Além disso, pode ser difícil instalar recursos ociosos sem poluir o código em teste.

Em vez de estimar se uma atividade está ocupada ou não, você pode fazer com que os testes aguardem até que condições específicas sejam atendidas. Por exemplo, você pode esperar até que um texto ou componente específico seja mostrado na interface.

O Compose tem uma coleção de APIs de teste como parte do ComposeTestRule para aguardar diferentes comparadores:

fun waitUntilAtLeastOneExists(matcher: SemanticsMatcher, timeout: Long = 1000L)

fun waitUntilDoesNotExist(matcher: SemanticsMatcher, timeout: Long = 1000L)

fun waitUntilExactlyOneExists(matcher: SemanticsMatcher,  timeout: Long = 1000L)

fun waitUntilNodeCount(matcher: SemanticsMatcher, count: Int, timeout: Long = 1000L)

E uma API genérica que usa qualquer função que retorna um booleano:

fun waitUntil(timeoutMillis: Long, condition: () -> Boolean): Unit

Exemplo de uso:

composeTestRule.waitUntilExactlyOneExists(hasText("Continue")</code>)</p></td>

Mecanismos de nova tentativa

É preciso corrigir testes instáveis, mas, às vezes, as condições que os fazem falhar são tão improváveis que são difíceis de reproduzir. Embora seja necessário sempre monitorar e corrigir testes instáveis, um mecanismo de nova tentativa pode ajudar a manter a produtividade do desenvolvedor executando o teste várias vezes até que ele seja aprovado.

As novas tentativas precisam acontecer em vários níveis para evitar problemas, como:

  • A conexão com o dispositivo expirou ou foi perdida
  • Falha em um único teste

A instalação ou configuração de novas tentativas depende dos frameworks de teste e da infraestrutura, mas os mecanismos típicos incluem:

  • Uma regra do JUnit que tenta novamente qualquer teste várias vezes
  • Uma ação ou etapa de nova tentativa no fluxo de trabalho de CI
  • Um sistema para reiniciar um emulador quando ele não responde, como dispositivos gerenciados pelo Gradle.