Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Ready to production - Testing your Android App

Ready to production - Testing your Android App

Presentation about testing approach for Android apps.

Presented at

- Androidos Day 2017
- Devfest Maceió 2017

Ubiratan Soares
PRO

September 23, 2017
Tweet

More Decks by Ubiratan Soares

Other Decks in Programming

Transcript

  1. READY TO PRODUCTION TESTANDO SEU APP ANDROID Ubiratan Soares Agosto

    / 2017
  2. COMO VOCÊ SABE QUE O PRODUTO DE SOFTWARE QUE VOCÊ

    ENTREGA FUNCIONA ???
  3. None
  4. None
  5. É SEMPRE BOM VER O RESULTADO DAQUILO QUE FAZEMOS. PORÉM

  6. ALGUMAS COISAS COMEÇAM A FICAR DIFÍCEIS DE SE ENTENDER …

  7. ACTIVITY ( ~ 300 LoC) ADAPTER ( ~ 200 LoC)

    DATA ( ~ 150 LoC)
  8. ACTIVITY ( ~ 300 LoC) ADAPTER ( ~ 300 LoC)

    DATA ( ~ 50 LoC) ANOTHER DATA ( ~ 50 LoC)
  9. ACTIVITY ( ~ 300 LoC) MYADAPTER ( ~ 100 LoC)

    MYDATASOURCE ( ~ 50 LoC) BASE DATASOURCE ( ~ 50 LoC) BASEADAPTER ( ~ 250 LoC)
  10. App (Visão Romântica)

  11. CONTROLLER CONTROLLER CONTROLLER CONTROLLER CONTROLLER CONTROLLER CONTROLLER CONTROLLER CONTROLLER CONTROLLER

    CONTROLLER CONTROLLER App (Visão Real) SINGLETON
  12. COISAS DIFÍCEIS DE SE EXPLICAR …

  13. None
  14. None
  15. NÃO PRECISA SER ASSIM !!!

  16. UMA IDÉIA SIMPLES

  17. DELEGAR O TRABALHO DE VERIFICAÇÃO PARA UM ROBÔ

  18. UMA QUEIXA COMUM NA COMUNIDADE MOBILE ?

  19. TEM PELO MENOS UM UNIT TEST NO APP?

  20. POR QUÊ ESCREVER TESTES

  21. Documentação de alta fidelidade das decisões de API tomadas na

    base de código 1
  22. Garantem e verificam comportamentos esperados 2

  23. Oferecem confiança para refactor, updates de bibliotecas, aplicar Proguard e

    outras operações que não impactam em alterações de regras de negócio 3
  24. Oferecem a condição correta para práticas de Continuous Integration e

    Continuous Delivery 4
  25. Podem ser complementados para cobrir novos comportamentos, não esperados (bugs)

    ou não mapeados (funcionalidades) 5
  26. Oferecem uma métrica fundamental para garantir a qualidade de produto

    6
  27. Mas nem tudo são flores …

  28. É PRECISO PROJETAR SUAS APIs INTERNAS PARA QUE SEJAM BOAS

  29. É PRECISO PENSAR NO DESIGN DAS SUAS CLASSES

  30. Single Responsability Open-Closed Lyskov Substituition Interface Segregation Dependency Inversion

  31. É PRECISO PENSAR EM QUE PAPÉIS SUAS CLASSES REPRESENTAM NA

    APLICAÇÃO
  32. MVP MVVM VIPER FLUX REDUX DDD MVC … MVI

  33. None
  34. TESTES EXISTEM EM VÁRIOS SABORES

  35. UNIT ACCEPTANCE E2E INTEGRATION Tempo de execução Custo de programação

  36. NÃO HÁ SILVER BULLETS!

  37. COMO TESTAR UM APP ANDROID ?

  38. ESTUDO DE CASO

  39. DEMO

  40. UNIT TESTS (Mocked Contract) FUNCTIONAL UI TESTS INTEGRATION TESTS USECASE

    EXTERNAL WORLD ADAPTER PRESENTER VIEW CONTRACT PLATAFORM CONTROLLER SOURCE CONTRACT ENTITY INTEGRATION TESTS (DOUBLES) UNIT ACCEPTANCE E2E INTEGRATION UNIT TESTS (Mocked Contract + Mocked Usecase)
  41. VIEW PROTOCOL VIEW IMPL. PRESENTER FlowableSubscriber<U> Flowable<U> LifecycleStrategist Disposable DisposeStrategy

    LifecycleObserver LifecycleOwner (eg. Activity) DATA SOURCE INFRASTRUCTURE Flowable<T>
  42. COMO TESTAR UMA UNIDADE SIMPLES ?

  43. /** * Created by bira on 6/29/17. * * Handles

    any errors throwed by GSON and report an * UnexpectedResponse to the foward steps * */ public class DeserializationIssuesHandler<T> implements FlowableTransformer<T, T> {
  44. public class DeserializationIssuesHandler<T> implements FlowableTransformer<T, T> { @Override public Publisher<T>

    apply(Flowable<T> upstream) { return upstream.onErrorResumeNext(this::handleErrorFromDeserializer); } private Publisher<T> handleErrorFromDeserializer(Throwable throwable) { if (isDeserializationError(throwable)) { return Flowable.error( new UnexpectedResponseError("Deserialization Error”) ); } return Flowable.error(throwable); } }
  45. public class DeserializationIssuesHandler<T> implements FlowableTransformer<T, T> { @Override public Publisher<T>

    apply(Flowable<T> upstream) { return upstream.onErrorResumeNext(this::handleErrorFromDeserializer); } private Publisher<T> handleErrorFromDeserializer(Throwable throwable) { if (isDeserializationError(throwable)) { return Flowable.error( new UnexpectedResponseError("Deserialization Error”) ); } return Flowable.error(throwable); } }
  46. public class DeserializationIssuesHandler<T> implements FlowableTransformer<T, T> { @Override public Publisher<T>

    apply(Flowable<T> upstream) { return upstream.onErrorResumeNext(this::handleErrorFromDeserializer); } private Publisher<T> handleErrorFromDeserializer(Throwable throwable) { if (isDeserializationError(throwable)) { return Flowable.error( new UnexpectedResponseError("Deserialization Error”) ); } return Flowable.error(throwable); } }
  47. private boolean isDeserializationError(Throwable throwable) { return throwable instanceof IllegalStateException ||

    throwable instanceof JsonIOException || throwable instanceof JsonSyntaxException || throwable instanceof JsonParseException; }
  48. @Test public void shouldHandle_GsonThrowsIllegalStateException() { Flowable<String> broken = Flowable.error(new IllegalStateException("Some

    message")); broken.compose(handler) .test() .assertError(this::checkHandledAsDeserializationError);
  49. @Test public void shouldHandle_GsonThrowsIllegalStateException() { Flowable<String> broken = Flowable.error(new IllegalStateException("Some

    message")); broken.compose(handler) .test() .assertError(this::checkHandledAsDeserializationError);
  50. @Test public void shouldHandle_GsonThrowsIllegalStateException() { Flowable<String> broken = Flowable.error(new IllegalStateException("Some

    message")); broken.compose(handler) .test() .assertError(this::checkHandledAsDeserializationError);
  51. @Test public void shouldHandle_GsonThrowsIllegalStateException() { Flowable<String> broken = Flowable.error(new IllegalStateException("Some

    message")); broken.compose(handler) .test() .assertError(this::checkHandledAsDeserializationError);
  52. DeserializationIssuesHandler<String> handler = new DeserializationIssuesHandler<>(); private boolean checkHandledAsDeserializationError(Throwable throwable) {

    if (throwable instanceof UnexpectedResponseError) { return throwable.getMessage().contentEquals("Deserialization Error"); } return false; }
  53. @Test public void shouldHandle_GsonThrowsIOException() { Flowable<String> broken = Flowable.error(new JsonIOException("Cannot

    read file")); broken.compose(handler) .test() .assertError(this::checkHandledAsDeserializationError); }
  54. @Test public void shouldNotHandle_OtherErrors() { IllegalAccessError ops = new IllegalAccessError(“OPS!");

    Flowable<String> broken = Flowable.error(ops); broken.compose(handler) .test() .assertError(throwable -> throwable.equals(ops)); }
  55. COMO TESTAR A INFRAESTRUTURA DE DADOS EM NÍVEL DE INTEGRAÇÃO

    ?
  56. public class TriviaInfrastructure implements GetRandomFacts { // Constructor and fields

    ... @Override public Flowable<FactAboutNumber> fetchTrivia() { List<Integer> numbersForTrivia = triviaGenerator.numberForTrivia(); String formattedUrlPath = formatPathWithCommas(numbersForTrivia); return webService .getTrivia(formattedUrlPath) .subscribeOn(executionScheduler) .compose(networkingErrorHandler) .compose(restErrorsHandler) .compose(deserializationIssuesHandler) .filter(payload -> validator.accept(payload)) .map(payload -> mapper.toNumberFacts(payload)) .flatMap(Flowable::fromIterable); }
  57. public class TriviaInfrastructure implements GetRandomFacts { // Constructor and fields

    @Override public Flowable<FactAboutNumber> fetchTrivia() { List<Integer> numbersForTrivia = triviaGenerator.numberForTrivia(); String formattedUrlPath = formatPathWithCommas(numbersForTrivia); return webService .getTrivia(formattedUrlPath) .subscribeOn(executionScheduler) .compose(networkingErrorHandler) .compose(restErrorsHandler) .compose(deserializationIssuesHandler) .filter(payload -> validator.accept(payload)) .map(payload -> mapper.toNumberFacts(payload)) .flatMap(Flowable::fromIterable); }
  58. public class TriviaInfrastructureTests { TriviaInfrastructure infrastructure; MockWebServer server; @Before public

    void beforeEachTest() { server = new MockWebServer(); NumbersWebService numberAPI = new Retrofit.Builder() .baseUrl(server.url("/").toString()) .create(NumbersWebService.class); infrastructure = new TriviaInfrastructure( numberAPI, new TriviaGenerator(), new PayloadMapper(), new PayloadValidator(), Schedulers.trampoline() // non-concurrent integration on tests ); }
  59. public class TriviaInfrastructureTests { TriviaInfrastructure infrastructure; MockWebServer server; @Before public

    void beforeEachTest() { server = new MockWebServer(); NumbersWebService numberAPI = new Retrofit.Builder() .baseUrl(server.url("/").toString()) .create(NumbersWebService.class); infrastructure = new TriviaInfrastructure( numberAPI, new TriviaGenerator(), new PayloadMapper(), new PayloadValidator(), Schedulers.trampoline() // non-concurrent integration on tests ); }
  60. @Test public void checkIntegration_200OK_CorrectPayload() { String json = readFile("sample_response_200OK.json"); server.enqueue(

    new MockResponse() .setResponseCode(200) .setBody(json) ); infrastructure.fetchTrivia() .test() .assertNoErrors() .assertComplete() .assertValueCount(6); // Check at payload file }
  61. @Test public void checkIntegration_200OK_CorrectPayload() { String json = readFile("sample_response_200OK.json"); server.enqueue(

    new MockResponse() .setResponseCode(200) .setBody(json) ); infrastructure.fetchTrivia() .test() .assertNoErrors() .assertComplete() .assertValueCount(6); // Check at payload file }
  62. @Test public void checkIntegration_200OK_CorrectPayload() { String json = readFile("sample_response_200OK.json"); server.enqueue(

    new MockResponse() .setResponseCode(200) .setBody(json) ); infrastructure.fetchTrivia() .test() .assertNoErrors() .assertComplete() .assertValueCount(6); // Check at payload file }
  63. @Test public void checkIntegration_404Error_AsNotFound() { server.enqueue(new MockResponse().setResponseCode(404)); infrastructure.fetchTrivia() .test() .assertNoValues()

    .assertError(ContentNotFoundError.class); }
  64. @Test public void checkIntegration_404Error_AsNotFound() { server.enqueue(new MockResponse().setResponseCode(404)); infrastructure.fetchTrivia() .test() .assertNoValues()

    .assertError(ContentNotFoundError.class); }
  65. @Test public void checkIntegration_5xxError_AsUnexpected() { server.enqueue(new MockResponse().setResponseCode(502)); infrastructure.fetchTrivia() .test() .assertNoValues()

    .assertError(UnexpectedResponseError.class); }
  66. @Test public void checkIntegration_5xxError_AsUnexpected() { server.enqueue(new MockResponse().setResponseCode(502)); infrastructure.fetchTrivia() .test() .assertNoValues()

    .assertError(UnexpectedResponseError.class); }
  67. COMO TESTAR UM COMPORTAMENTO DE APRESENTAÇÃO ISOLADAMENTE ?

  68. public class LoadingCoordination<T> implements FlowableTransformer<T, T> { private LoadingView view;

    private Scheduler uiScheduler; // Constructor @Override public Publisher<T> apply(Flowable<T> upstream) { ShowAtStartHideWhenDone<T> delegate = new ShowAtStartHideWhenDone<>( view.showLoading(), view.hideLoading(), uiScheduler ); return upstream.compose(delegate); } }
  69. public class LoadingCoordinationTests { Scheduler uiScheduler = Schedulers.trampoline(); LoadingCoordination<String> loadingCoordination;

    @Mock Action showAction; @Mock Action hideAction; @Before public void beforeEachTest() { MockitoAnnotations.initMocks(this); LoadingView view = new LoadingView() { @Override public Action showLoading() {return showAction;} @Override public Action hideLoading() {return hideAction;} }; loadingCoordination = new LoadingCoordination<>(view, uiScheduler); }
  70. public class LoadingCoordinationTests { Scheduler uiScheduler = Schedulers.trampoline(); LoadingCoordination<String> loadingCoordination;

    @Mock Action showAction; @Mock Action hideAction; @Before public void beforeEachTest() { MockitoAnnotations.initMocks(this); LoadingView view = new LoadingView() { @Override public Action showLoading() {return showAction;} @Override public Action hideLoading() {return hideAction;} }; loadingCoordination = new LoadingCoordination<>(view, uiScheduler); }
  71. @Test public void shouldCoordinateLoading_WhenFlowEmmits() throws Exception { Flowable.just("A", "B", "C")

    .compose(loadingCoordination) .subscribe(); checkLoadingCoordinated(); } @Test public void shouldCoordinateLoading_WithEmptyFlow() throws Exception { Flowable<String> empty = Flowable.empty(); empty.compose(loadingCoordination).subscribe(); checkLoadingCoordinated(); }
  72. private void checkLoadingCoordinated() throws Exception { InOrder inOrder = Mockito.inOrder(showAction,

    hideAction); inOrder.verify(showAction, oneTimeOnly()).run(); inOrder.verify(hideAction, oneTimeOnly()).run(); }
  73. private void checkLoadingCoordinated() throws Exception { InOrder inOrder = Mockito.inOrder(showAction,

    hideAction); inOrder.verify(showAction, oneTimeOnly()).run(); inOrder.verify(hideAction, oneTimeOnly()).run(); }
  74. private void checkLoadingCoordinated() throws Exception { InOrder inOrder = Mockito.inOrder(showAction,

    hideAction); inOrder.verify(showAction, oneTimeOnly()).run(); inOrder.verify(hideAction, oneTimeOnly()).run(); }
  75. COMO TESTAR MÚLTIPLOS COMPORTAMENTOS DE APRESENTAÇÃO POR FLUXO ?

  76. @Test public void shouldPresent_NoContentError_IntoView() throws Exception { Flowable<FactAboutNumber> noContent =

    Flowable.error(new ContentNotFoundError()); when(usecase.fetchTrivia()).thenReturn(noContent); presenter.fetchRandomFacts(); BehavioursVerifier.with(view) .showLoadingFirstHideLoadingAfter() .shouldShowEmptyState() .shouldNotShowErrorState(); }
  77. @Test public void shouldPresent_NoContentError_IntoView() throws Exception { Flowable<FactAboutNumber> noContent =

    Flowable.error(new ContentNotFoundError()); when(usecase.fetchTrivia()).thenReturn(noContent); presenter.fetchRandomFacts(); BehavioursVerifier.with(view) .showLoadingFirstHideLoadingAfter() .shouldShowEmptyState() .shouldNotShowErrorState(); }
  78. public class BehavioursVerifier { private Object target; // Via factory

    method public BehavioursVerifier shouldShowErrorState() throws Exception { checkErrorStateView(); ErrorStateView view = (ErrorStateView) target; verify(view.showErrorState(), oneTimeOnly()).run(); return this; } // For each View and each behavior, check if bind / apply is possible private void checkEmptyStateView() { if (!(target instanceof EmptyStateView)) throw new IllegalArgumentException("Not an EmptyStateView"); }
  79. public class BehavioursVerifier { private Object target; public BehavioursVerifier shouldShowErrorState()

    throws Exception { checkErrorStateView(); ErrorStateView view = (ErrorStateView) target; verify(view.showErrorState(), oneTimeOnly()).run(); return this; } // For each View and each behavior, check if bind / apply is possible private void checkEmptyStateView() { if (!(target instanceof EmptyStateView)) throw new IllegalArgumentException("Not an EmptyStateView"); }
  80. public class BehavioursVerifier { private Object target; public BehavioursVerifier shouldShowErrorState()

    throws Exception { checkErrorStateView(); ErrorStateView view = (ErrorStateView) target; verify(view.showErrorState(), oneTimeOnly()).run(); return this; } // For each View and each behavior, check if bind / apply is possible private void checkEmptyStateView() { if (!(target instanceof EmptyStateView)) throw new IllegalArgumentException(“Not an EmptyStateView"); }
  81. @Test public void shouldPresent_AvailableData_IntoView() throws Exception { Flowable<FactAboutNumber> data =

    Flowable.just( FactAboutNumber.of("1", "1 is the first"), FactAboutNumber.of("2", "2 is the second") ); when(usecase.fetchTrivia()).thenReturn(data); presenter.fetchRandomFacts(); BehavioursRobot.with(view) .showLoadingFirstHideLoadingAfter() .disableRefreshFirstAndEnableAfter() .shouldNotShowEmptyState() .shouldNotShowErrorState() .shouldNotReportNetworkingError(); }
  82. @Test public void shouldPresent_AvailableData_IntoView() throws Exception { Flowable<FactAboutNumber> data =

    Flowable.just( FactAboutNumber.of("1", "1 is the first"), FactAboutNumber.of("2", "2 is the second") ); when(usecase.fetchTrivia()).thenReturn(data); presenter.fetchRandomFacts(); BehavioursRobot.with(view) .showLoadingFirstHideLoadingAfter() .disableRefreshFirstAndEnableAfter() .shouldNotShowEmptyState() .shouldNotShowErrorState() .shouldNotReportNetworkingError(); }
  83. COMO TESTAR A LÓGICA ESPECÍFICA DE FRONT-END ?

  84. @RunWith(RobolectricTestRunner.class) @Config( constants = BuildConfig.class, application = MainApplication.class, sdk =

    25 ) public class FactsViewModelMapperTests { FactsViewModelMapper mapper; @Before public void beforeEachTest() { Context context = RuntimeEnvironment.application; mapper = new FactsViewModelMapper(context); }
  85. @RunWith(RobolectricTestRunner.class) @Config( constants = BuildConfig.class, application = MainApplication.class, sdk =

    25 ) public class FactsViewModelMapperTests { FactsViewModelMapper mapper; @Before public void beforeEachTest() { Context context = RuntimeEnvironment.application; mapper = new FactsViewModelMapper(context); }
  86. @Test public void shouldMap_UpTo50Chars_AsTwoLabelsModel() { String text = "A text

    that should have 49 chars at most, no more”; FactAboutNumber fact = FactAboutNumber.of("1", text); FactViewModel model = mapper.translate(fact); assertThat(model).isInstanceOf(NumberAndFact.class); } @Test public void shouldMap_Above50Chars_AsSingleLabelModel() { String text = "Another text that is much,” + "much larger than 50 characters”; FactAboutNumber fact = FactAboutNumber.of("17", text); FactViewModel model = mapper.translate(fact); assertThat(model).isInstanceOf(ComposedWithSpannedStyles.class); }
  87. @Test public void shouldMap_UpTo50Chars_AsTwoLabelsModel() { String text = "A text

    that should have 49 chars at most, no more”; FactAboutNumber fact = FactAboutNumber.of("1", text); FactViewModel model = mapper.translate(fact); assertThat(model).isInstanceOf(NumberAndFact.class); } @Test public void shouldMap_Above50Chars_AsSingleLabelModel() { String text = "Another text that is much,” + "much larger than 50 characters”; FactAboutNumber fact = FactAboutNumber.of("17", text); FactViewModel model = mapper.translate(fact); assertThat(model).isInstanceOf(ComposedWithSpannedStyles.class); }
  88. @Test public void shouldMap_UpTo50Chars_AsTwoLabelsModel() { String text = "A text

    that should have 49 chars at most, no more”; FactAboutNumber fact = FactAboutNumber.of("1", text); FactViewModel model = mapper.translate(fact); assertThat(model).isInstanceOf(NumberAndFact.class); } @Test public void shouldMap_Above50Chars_AsSingleLabelModel() { String text = "Another text that is much,” + "much larger than 50 characters”; FactAboutNumber fact = FactAboutNumber.of("17", text); FactViewModel model = mapper.translate(fact); assertThat(model).isInstanceOf(ComposedWithSpannedStyles.class); }
  89. COMO GARANTIR QUE O AS VIEWS SEGUEM O COMPORTAMENTO ESPERADO

    ?
  90. @RunWith(RobolectricTestRunner.class) @Config( constants = BuildConfig.class, application = MainApplication.class, sdk =

    25 ) public class FactsAboutNumbersActivityTest { FactsAboutNumbersActivity activity; @Before public void beforeEachTest() { activity = buildActivity(FactsAboutNumbersActivity.class) .create() .get(); }
  91. @RunWith(RobolectricTestRunner.class) @Config( constants = BuildConfig.class, application = MainApplication.class, sdk =

    25 ) public class FactsAboutNumbersActivityTest { FactsAboutNumbersActivity activity; @Before public void beforeEachTest() { activity = buildActivity(FactsAboutNumbersActivity.class) .create() .get(); }
  92. @Test public void shoulIntegrateActions_ForLoadingVisibility() throws Exception { ProgressBar loading =

    findById(activity, R.id.progressBar); activity.showLoading().run(); assertThat(loading.getVisibility()).isEqualTo(View.VISIBLE); activity.hideLoading().run(); assertThat(loading.getVisibility()).isEqualTo(View.GONE); }
  93. @Test public void shoulIntegrateActions_ForLoadingVisibility() throws Exception { ProgressBar loading =

    findById(activity, R.id.progressBar); activity.showLoading().run(); assertThat(loading.getVisibility()).isEqualTo(View.VISIBLE); activity.hideLoading().run(); assertThat(loading.getVisibility()).isEqualTo(View.GONE); }
  94. @Test public void shoulIntegrateAction_ForNetworkingErrorFeedback() throws Exception { activity.reportNetworkingError().run(); // From

    design_layout_snackbar_include.xml, since Snackbar // does not have an id assigned at his own container View snackText = findById(activity, R.id.snackbar_text); assertThat(snackText).isNotNull(); assertThat(snackText.getVisibility()).isEqualTo(View.VISIBLE); }
  95. @Test public void shoulIntegrateAction_ForNetworkingErrorFeedback() throws Exception { activity.reportNetworkingError().run(); // From

    design_layout_snackbar_include.xml, since Snackbar // does not have an id assigned at his own container View snackText = findById(activity, R.id.snackbar_text); assertThat(snackText).isNotNull(); assertThat(snackText.getVisibility()).isEqualTo(View.VISIBLE); }
  96. CONCLUSÕES

  97. SEM DESCULPAS PARA NÃO ESCREVER TESTES

  98. https://github.com/ubiratansoares/reactive-architectures-playground Sample usando numbersapi.com 100% RxJava2 + MVP, ilustrando conceitos

    vistos aqui Dagger 2.11, Full Android Support APIs Testes de unidade que importam Mais outras coisinhas (WIP)
  99. UBIRATAN SOARES Computer Scientist by ICMC/USP Software Engineer, curious guy

    Google Developer Expert for Android Teacher, speaker, etc, etc
  100. OBRIGADO @ubiratanfsoares ubiratansoares.github.io https://br.linkedin.com/in/ubiratanfsoares