Concrete | O diferente valor dos diferentes testes em Android – Parte I
O diferente valor dos diferentes testes em Android – Parte I
No dia 25 de agosto apresentei uma palestra sobre testes em Android na Android Dev Conference, um dos maiores eventos sobre o tema, realizado em São Paulo. Estou feliz por ter participado e o que se segue é um post sobre o assunto que apresentei. Os slides estão disponíveis aqui.
As aplicações Android podem ser sujeitas a vários tipos de testes: local X instrumentado, unidade X end to end (e2e), estresse (monkey), desempenho, segurança etc. Os desenvolvedores Android, geralmente, se concentram em testes de unidade que podem ser executados localmente ou através de instrumentação no dispositivo/emulador. A maioria dos desenvolvedores sabe como codificar cada teste, ou seja, testes locais têm seu código fonte em src/test enquanto testes instrumentados vão no src/androidTest. Mas qual é o valor de cada tipo de teste?
Valor de um teste? Vou explicar melhor: os testes são ferramentas que nós, desenvolvedores, utilizamos para verificar se uma determinada implementação está mais em conformidade com algumas especificações ou design. Assim, cada aplicação tem suas características em um determinado design. Quando escrevemos testes queremos saber se o nosso código vai funcionar da maneira adequada nas mãos de nossos usuários.
É aí que a discussão sobre testes no Android toma caminhos diversificados. Para ganhar confiança de que o nosso código vai funcionar direito, uso testes locais ou instrumentados? Já escrevi um pouco sobre isso. Como eu acho que não foi o suficiente, vou aprofundar o assunto aqui, mas quero focar em:
Local X Instrumentado
Unidade X e2e
Como visto na imagem e na minha última publicação, a diferença entre local e instrumentado é principalmente o meio de execução. Gostaria de destacar:
JVM X Dalvik
O test runner
O segundo item da caixa destacada -> Mock objects X Instrumentação
Sabemos que o Android tem a sua própria máquina virtual que é diferente da Máquina Virtual Java. Mas como isso afetaria nossos testes? A maioria das pessoas subestima essa diferença. Se um meio de execução é diferente do outro pode haver algumas implicações estranhas. Mesmo que o código esteja igual sendo executado em ambas as VMs, pode ser que elas interpretem de forma diferente.
Isso já ocorreu entre diferentes implementações da Java VM (embora muito raras), agora imaginem entre as diferentes especificações de VM. Você sabia que a Dalvik é uma máquina virtual baseada em registro (register based), enquanto a JVM é uma máquina virtual baseada em pilha (stack based)? Bem, é exatamente isso que as faz diferentes… Vamos comparar dois stacktraces desse código simples:
// Instrumented test @RunWith(AndroidJUnit4::class) class UniverseTest { @Test fun assertSmt() { Assert.assertTrue(Universe.isInfinite()) } } // Local test class AnotherUniverseTest { @Test fun assertSmt() { Assert.assertTrue(Universe.isInfinite()) } } // Universe definition class Universe { companion object { fun isInfinite() = Random().nextBoolean() } }
// Instrumented test
class UniverseTest {
@Test fun assertSmt() {
Assert.assertTrue(Universe.isInfinite())
// Local test
class AnotherUniverseTest {
// Universe definition
fun isInfinite() = Random().nextBoolean()
Primeiro stacktrace:
java.lang.AssertionError at org.junit.Assert.fail(Assert.java:86) at org.junit.Assert.assertTrue(Assert.java:41) at org.junit.Assert.assertTrue(Assert.java:52) at br.com.concrete.androidtesting.AnotherUniverseTest.assertSmt(AnotherUniverseTest.kt:9) at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) at java.lang.reflect.Method.invoke(Method.java:498) at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:50) at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12) at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:47) at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17) … at org.junit.runners.ParentRunner.run(ParentRunner.java:363) at org.junit.runner.JUnitCore.run(JUnitCore.java:137) …
at br.com.concrete.androidtesting.AnotherUniverseTest.assertSmt(AnotherUniverseTest.kt:9)
Segundo stacktrace:
java.lang.AssertionError at org.junit.Assert.fail(Assert.java:86) at org.junit.Assert.assertTrue(Assert.java:41) at org.junit.Assert.assertTrue(Assert.java:52) at br.com.concrete.androidtesting.UniverseTest.assertSmt(UniverseTest.kt:12) at java.lang.reflect.Method.invoke(Native Method) … at org.junit.runners.ParentRunner.run(ParentRunner.java:363) at org.junit.runner.JUnitCore.run(JUnitCore.java:137) at org.junit.runner.JUnitCore.run(JUnitCore.java:115) at android.support.test.internal.runner.TestExecutor.execute(TestExecutor.java:58) at android.support.test.runner.AndroidJUnitRunner.onStart(AndroidJUnitRunner.java:369) at android.app.Instrumentation$InstrumentationThread.run(Instrumentation.java:1932)
at br.com.concrete.androidtesting.UniverseTest.assertSmt(UniverseTest.kt:12)
at android.support.test.internal.runner.TestExecutor.execute(TestExecutor.java:58)
at android.support.test.runner.AndroidJUnitRunner.onStart(AndroidJUnitRunner.java:369)
Podemos dizer, olhando somente para eles, qual é o teste local e qual é o teste instrumentado? Claro! O nome do teste está na pilha. 🙂 Mas além do nome podemos ver que, em um deles, há algumas chamadas para um nome de pacote sun.reflect que não existe no Android.
Isso ocorre porque o Android foi baseado primeiro no projeto Apache Harmony para sua implementação em Java e depois no OpenJDK (Nougat e mais). Eles não podiam conter código privado (o pacote sun não é de código aberto). Isso é algo que temos que ter em mente ao fazer testes locais: estamos testando em um meio de execução diferente do que é executado nas mãos de nossos usuários.
Como pode ser visto na última imagem, usamos o runner padrão Junit em testes locais e o agora famoso AndroidJUnitRunner nos testes instrumentados. Embora pareça um pequeno detalhe, isso de fato mostra a grande diferença que existe: AndroidJUnitRunner é uma implementação de Instrumentation e não um JUnit. Whaaaaaaat? Não é um test runner? Sim. Não é.
Então o que é instrumentação? É um conceito de ciência da computação muito veeeelho. Toda JVM possui instrumentação integrada. Para citar a Wikipedia:
"Os programadores implementam instrumentação sob a forma de instruções de código que monitoram componentes específicos em um sistema (por exemplo, instruções podem ser exibidas na tela). Quando um aplicativo contém o código de instrumentação, ele pode ser gerenciado usando uma ferramenta externa."
Então, um teste instrumentado é um código fonte que gerencia o aplicativo alvo. Ele é executado no mesmo processo que o aplicativo de destino no mesmo ambiente desse aplicativo. É o mais próximo que chegamos ao que será executado nas mãos dos nossos clientes.
Não devemos contá-lo como um teste black box (um em que não sabemos como o código é implementado) porque, como visto na imagem, o código-fonte de teste está no mesmo processo que o aplicativo de destino e, portanto, tem acesso a sua fonte. Podemos ter testes instrumentados headless, isto é, testes instrumentados que não possuem interface (sem Espresso, por exemplo). Por que queremos fazer isso? Bem, como eu disse: usar a interpretação Dalvik do bytecode é ter a certeza de que ele não vai explodir nas mãos dos nossos usuários.
Mock objects X Instrumentação
A última parte desta publicação é a diferença entre mock e instrumentação. Este também é subestimado por muitas pessoas, então vamos tomar outro mergulho. Quando você executa um teste local, você geralmente quer seu código livre do código Android o máximo possível.
Se você simplesmente usar um TextUtils.isDigitsOnly (…), você vai precisar dele em seu classpath Java. Para isso, poderíamos "mockar" ou usar uma estrutura simulada como Robolectric, o que não é exatamente uma simulação. Se usamos Robolectric, então pense nisso: você vai burlar não apenas uma API Java, mas todo o ambiente que é um sistema operacional totalmente desenvolvido. Então veja esta imagem:
Bem, com o Robolectric temos apenas a primeira caixa (Application Framework) implementada. Esse é o propósito da biblioteca, mas não devemos subestimar isso. Um sistema operacional possui todos os tipos de concorrência de recursos e restrições que não podem ser simplesmente simulados. Eu já vi várias vezes aplicativos que optaram por fazer apenas testes locais com Robolectric e que tiveram problemas ao serem executados no Android por causa disso.
Tudo certo! Se você leu até aqui, provavelmente está concluindo que nunca mais deve escrever um teste local. Isso certamente não foi o que eu quis dizer. Se os testes instrumentados fossem perfeitos, não estaríamos tendo essa discussão. O ponto que estou tentando trazer é que cada teste tem um valor diferente: os testes locais são mais rápidos e muito mais fáceis de implementar em sistemas de integração contínua. Não há preocupações sobre dispositivos ou emuladores. Essa é uma grande vantagem em comparação aos testes instrumentados. Mas lembre-se de que o valor que eles trazem é completamente diferente daquele dos testes instrumentados. Se o seu aplicativo tiver poucas regras de negócio (lembrando que nós desenvolvemos front-end), então você deve se concentrar em testes instrumentados porque a maioria de sua base de código depende do sistema operacional.
Agora se você tem muita lógica de negócio, então você pode isolá-lo do sistema operacional e verificá-lo separadamente do seu código de front-end. É isso. 🙂 Da próxima vez vou discutir o valor dos testes de unidade e testes de ponta a ponta.
Se você quiser me seguir, estou no twitter @olinasc.
É desenvolvedor Android e quer trabalhar em um time ágil de verdade e multidisciplinar? Clique aqui.
Desenvolvedor Java por experiência e Erlang por paixão. Bacharel em Filosofia (por opção!), acredita que o mundo das linguagens de programação anda se esquecendo muito do lado "linguagem" e se focando muito em "programação". Gerente do capítulo Android na Concrete, tem uma paixão pela plataforma Erlang, Wittgenstein e Kant.