Android

9 nov, 2016

Testes no Android com Espresso – Parte 06

Publicidade

No artigo anterior, aprendemos como fazer asserções e interações em uma recyclerview. Caso queira iniciar a partir desta parte, utilize o branch ‘part_5’ do projeto.

Nesta parte, vamos ver como fazer um matcher customizado e aprender a tratar as runtime permissions. Para isso, foi necessário fazer uma alteração no projeto inicial. Confira esta alteração na classe ImageAndTextView.java(linha 47) e na classe UserDetailsActivity. Entenda bem esta alteração antes de prosseguir.

Primeiro cenário

A UserDetailsActivity possui o seguinte layout:

1-2e5ck9mdfovgehsr6lvhcw

Temos a foto do usuário, o nome, telefone, e-mail e endereço. Este pode ser o primeiro cenário: verificar se todas as informações aparecem na tela. Como é um cenário simples, vou deixar como tarefa para você implementar.

Segundo cenário

Com exceção do nome, o usuário nem sempre terá todos estes dados e, caso ele não tenha telefone, e-mail ou endereço, a mensagem “No info available” deve aparecer, em vermelho. Conforme imagem abaixo:

1-ogfkyvm6gtxjefm-mo3rwa

Então, um outro cenário é: verificar se o texto “No info available” aparece quando o usuário não possui e-mail, telefone ou endereço.

@Test
public void whenEmailIsMissing_shouldDisplay_noInfoMessage() {
  mActivityRule.launchActivity(createIntent(true));
  onView(withId(R.id.user_details_image)).check(matches(isDisplayed()));
  onView(withId(R.id.user_details_name)).check(matches(isDisplayed()));
  onView(allOf(
      withId(R.id.image_and_text_image),
      hasSibling(withText("No info available."))))
      .check(matches(isDisplayed()));
}

private Intent createIntent(boolean missingInfo) {
  return new Intent().putExtra(UserDetailsActivity.CLICKED_USER, getMockedUser(missingInfo));
}

private UserVO getMockedUser(boolean missingInfo) {
  final String mock = missingInfo ? Mocks.USER_MISSING_INFO : Mocks.USER;
  return UsersApi.GSON.fromJson(mock, UserVO.class);
}

Nada de novo neste teste, apenas iniciando a UserDetailsActivity com uma intent que contém um usuário sem e-mail (Mocks.USER_MISSING_INFO) e verificando se o texto “No info available” está visível. Porém, não testamos se a cor do texto é vermelha. Para fazer isso, vamos criar um custom matcher.

Crie um pacote matcher e uma classe TextColorMatcher, conforme imagem abaixo:

1-zdjgqgzpfjecmuwld8khga

A classe TextColorMatcher ficará assim:

public class TextColorMatcher {

  private TextColorMatcher(){}
  
  public static Matcher<View> withTextColor(@ColorInt final int expectedColor) {
    return new BoundedMatcher<View, TextView>(TextView.class) {
      int currentColor = 0;
      @Override
      public void describeTo(Description description) {
        description.appendText("expected TextColor: ")
            .appendValue(Integer.toHexString(expectedColor));
        description.appendText(" current TextColor: ")
            .appendValue(Integer.toHexString(currentColor));
      }
  
      @Override
      protected boolean matchesSafely(TextView item) {
        if(currentColor == 0)
          currentColor = item.getCurrentTextColor();
        return currentColor == expectedColor;
      }
    };
  }
}
  • Linha 5: declaramos o método withTextColor que recebe uma cor como parâmetro e retorna um Matcher<View>;
  • Linha 6: criamos uma nova instância da classe BoundedMatcher, que permitirá a criação do nosso view matcher;
  • Linha 7: inicializamos a variável que vai armazenar o valor atual da cor do texto do nosso textview;
  • Linhas 8 à 13: sobrescrevemos o método describeTo. É neste método que iremos atribuir ao objeto, do tipo Description, aquilo que será exibido no log caso a asserção falhe;
  • Linhas 16 à 21: sobrescrevemos o método matchesSafely, que é onde faremos a comparação entre a cor atual do TextView, e a cor que esperamos que ele tenha.

Agora é só usarmos o nosso custom matcher no teste:

@Test
public void whenEmailIsMissing_shouldDisplay_noInfoMessage() {
  mActivityRule.launchActivity(createIntent(true));
  onView(withId(R.id.user_details_image)).check(matches(isDisplayed()));
  onView(withId(R.id.user_details_name)).check(matches(isDisplayed()));
  onView(allOf(
      withId(R.id.image_and_text_image),
      hasSibling(withText("No info available.")))
  ).check(matches(isDisplayed()));

  onView(allOf(
      withText("No info available."),
      withTextColor(ContextCompat.getColor(mActivityRule.getActivity(), R.color.red)))
  ).check(matches(isDisplayed()));
}

Para garantir que está funcionando, tente passar uma cor diferente para verificar se o teste falha. Verifique também se a mensagem de erro é a que foi configurada no nosso custom matcher.

NoMatchingViewException: No views in hierarchy found matching: (with text: is “No info available.” and expected TextColor: “ffff4081” current TextColor: “fff44336”)

Outro detalhe desta tela é que cada informação executa uma ação quando clicada:

  • No número do telefone, uma ligação é feita;
  • No e-mail, uma nova mensagem é aberta para ser enviada ao usuário;
  • No endereço, o Google Maps é aberto e é possível traçar uma rota.

Vamos escrever o teste para o clique no telefone. Os outros dois testes deixo para você implementar.

Runtime Permissions

A partir do Android 6, as permissões são solicitadas em tempo de execução. Isso afeta os nossos testes, pois temos que fazer um tratamento especial para este tipo de situação.

1-szct-x15p5iml4d75ulcsg

Por exemplo, teremos problemas se executarmos um teste como esse:

@Test
public void clickOnPhone_shouldStartPhoneIntent() {
  mActivityRule.launchActivity(createIntent(false));
  Intents.init();
  intending(hasAction(Intent.ACTION_CALL))
      .respondWith(new Instrumentation.ActivityResult(Activity.RESULT_OK, new Intent()));
  onView(withId(R.id.user_details_phone)).perform(scrollTo(), click());
  intended(hasAction(Intent.ACTION_CALL));
  Intents.release();
}

Em devices anteriores ao Android 6, este teste passa; mas em devices com versão Marshmallow em diante, ele vai falhar.

Também não conseguimos fazer o Espresso clicar no botão “Allow”, pois este dialog está fora do contexto da aplicação.

Para este tipo de interação, vamos usar o UiAutomator, pois ele nos permite executar interações com apps do Android. Se você quiser saber melhor a diferença entre Espresso e UiAutomator, dê uma olhada nesta discussão no stackoverflow.

Para começar, adicione esta configuração no seu arquivo build.gradle:

// UiAutomator
androidTestCompile "com.android.support.test.uiautomator:uiautomator-v18:2.1.2"

Sincronize o projeto, você provavelmente verá este erro:

Manifest merger failed : 
uses-sdk:minSdkVersion 16 cannot be smaller than version 18 declared in library ... uiautomator-v18:2.1.2
...
Suggestion: 
use tools:overrideLibrary=”android.support.test.uiautomator.v18" to force usage

Para solucionar este problema, é só seguir a sugestão do próprio log de erro: “use tools:overrideLibrary=”android.support.test.uiautomator.v18 to force usage”.

Primeiro, crie um novo arquivo AndroidManifest.xml dentro da sua pasta androidTest:

1-edfomautgkpu1phfesuhvw

Dentro deste arquivo, coloque o código abaixo:

<?xml version="1.0" encoding="utf-8"?>
<manifest
    xmlns:tools="http://schemas.android.com/tools"
    package="com.example.heitorcolangelo.espressotests">

  <uses-sdk tools:overrideLibrary="android.support.test.uiautomator.v18"/>
</manifest>

Quando ocorrer o merge do Manifest, o erro não ocorrerá novamente.

Eu peguei essa dica desta resposta no stackoverflow. O que acontece é que a lib do UiAutomator tem minSdk 18, e o nosso app tem minSdk 16. Mas como vamos utilizar o UiAutomator somente para os testes, não tem problema sobrescrevermos este valor no Manifest. Fiz os testes com essa alteração em emuladores com API 16+ e tudo funcionou normalmente.

Seguindo com nosso teste, temos o UiAutomator configurado corretamente. Vamos utilizá-lo para interagir com o dialog de permissão do Android.

/**
 * From: https://gist.github.com/rocboronat/65b1187a9fca9eabfebb5121d818a3c4
  */
public class PermissionUtils {
  private static final int PERMISSIONS_DIALOG_DELAY = 3000;
  private static final int GRANT_BUTTON_INDEX = 1;

  public static void allowPermissionsIfNeeded(String permissionNeeded) {
    try {
      Context context = InstrumentationRegistry.getTargetContext();
      if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !hasNeededPermission(context, permissionNeeded)) {
        sleep(PERMISSIONS_DIALOG_DELAY);
        UiDevice device = UiDevice.getInstance(getInstrumentation());
        UiObject allowPermissions = device.findObject(new UiSelector()
            .clickable(true)
            .checkable(false)
            .index(GRANT_BUTTON_INDEX));
        if (allowPermissions.exists()) {
          allowPermissions.click();
        }
      }
    } catch (UiObjectNotFoundException e) {
      System.out.println("There is no permissions dialog to interact with");
    }
  }

  private static boolean hasNeededPermission(Context context, String permissionNeeded) {
    int permissionStatus = ContextCompat.checkSelfPermission(context, permissionNeeded);
    return permissionStatus == PackageManager.PERMISSION_GRANTED;
  }

  private static void sleep(long millis) {
    try {
      Thread.sleep(millis);
    } catch (InterruptedException e) {
      throw new RuntimeException("Cannot execute Thread.sleep()");
    }
  }
}

Esta classe eu encontrei neste gist. Vamos analisá-la por partes:

  • Linha 11: Verificamos se a versão do Android é 6 ou mais, e se a permissão que precisamos ainda não foi dada;
  • Linha 13: Recuperamos a instância singleton da class UiDevice. Isto permitirá a interação com o dispositivo;
  • Linha 14 até 17: Procuramos pelo botão “Allow” do dialog de permissão. Para isso, criamos um objeto UiSelector e chamamos alguns métodos do builder desta classe para configurar nosso objeto. Um destes métodos é o index(int index). Este método vai definir o ID do botão que queremos clicar. No caso do dialog, o botão “Allow” tem valor 1. Se quiséssemos clicar no “Deny” usaríamos o index = 0;
  • Linha 18 e 19: Se o botão “Allow” foi encontrado, efetuamos um clique nele.

No nosso teste, vamos chamar o método allowPermissionsIfNeeded do PermissionUtils logo depois do clique no telefone. Ficará assim:

@Test
public void clickOnPhone_shouldStartPhoneIntent() throws IOException {
  mActivityRule.launchActivity(createIntent(false));
  Intents.init();
  intending(hasAction(Intent.ACTION_CALL))
      .respondWith(new Instrumentation.ActivityResult(Activity.RESULT_OK, new Intent()));
  onView(withId(R.id.user_details_phone)).perform(scrollTo(), click());
  PermissionUtils.allowPermissionsIfNeeded(Manifest.permission.CALL_PHONE);
  intended(hasAction(Intent.ACTION_CALL));
  Intents.release();
}

Tente executar o teste, verifique se ele passa. Se algo deu errado, retome os passos anteriores ou deixe um comentário para que eu possa ajudar.

Existem outros cenários a serem testados nesta tela, mas o conhecimento necessário para isso já foi abordado neste tutorial. Então, mãos à obra.

Ao final desta etapa, seu código deve estar parecido com o da branch ‘part_6’.