The Social App: Integrando dados de Cobertura de Código

Padrão

Se você ainda não conhece as motivações deste projeto, leia o primeiro artigo aqui.

Olá pessoal! No último post realizamos uma configuração inicial do tema do nosso aplicativo de forma a diferenciar as builds de debug e release. No post de hoje, vamos configurar uma métrica importante para o desenvolvimento de qualquer projeto de software, a cobertura de código.

Simplificando ao máximo o conceito (já que a explicação dele foge um pouco ao escopo deste post), a métrica diz respeito a quanto do seu código é “exercitado” durante a execução dos testes. Os relatórios de cobertura geralmente fornecem formas de mensurar não só percentualmente quanto do código foi coberto pelos testes, mas também pode dar insights sobre quais partes do código poderiam receber mais testes.

O primeiro passo é habilitarmos a geração desse dado no projeto. Porém, neste momento não é possível gerar este dado, já que nosso projeto não possui nenhum teste. Na verdade, ele praticamente não possui código, somente uma Activity vazia que ainda não faz nada. Poderíamos criar um teste simples de Espresso, para validar a inicialização da Activity, porém ainda não resolvemos o problema da execução de testes instrumentados no nosso CI (inclusive, se você quer ler mais sobre a diferença entre testes instrumentados e locais, tem um post excelente do Victor Nascimento no Medium). Dado esse cenário, vou utilizar o Robolectric para criar um teste que, apesar de utilizar classes do Android, roda localmente na JVM.

Para integrarmos o Robolectric em nosso projeto, é necessário adicionar a sua dependência, dentro do escopo de testes (testImplementation). Além disso, como o Robolectric utiliza resources do Android, precisamos habilitar a disponibilização dos resources para os testes unitários. Caso contrário, nossos testes falharão por não encontrar qualquer referência a resources adicionados por nós no projeto (como layouts, strings, drawables e outros).

android {
  ...
  testOptions {
    unitTests.includeAndroidResources true
  }
}

dependencies {
  ...
  testImplementation 'org.robolectric:robolectric:3.8'
}

Dentro da pasta src/test/kotlin, vamos criar uma classe de teste bem simples, chamada MainActivityTest, que simplesmente fará uma validação se a Activity foi criada com sucesso pelo Robolectric. Apesar de ser um teste sem muito valor para o app (afinal, estamos testando se o Robolectric funciona), esse teste gerará alguma cobertura para o nosso projeto.

package net.rafaeltoledo.social

import org.junit.Assert.assertNotNull
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner

@RunWith(RobolectricTestRunner::class)
class MainActivityTest {

    @Test
    fun checkIfActivityIsSuccessfullyCreated() {
        assertNotNull(Robolectric.setupActivity(MainActivity::class.java))
    }
}

Com o teste criado (e passando!), vamos configurar a geração dos dados de cobertura. Eu já escrevi dois posts sobre o assunto, então não vou me alongar muito sobre os detalhes de implementação aqui para não deixar o este post muito longo 🙂

Criei um script chamado coverage.gradle, que coloquei na pasta gradle/ do projeto (inicialmente, tinha colocado numa pasta chamada tools/, mas após algumas discussões a respeito, pareceu fazer mais sentido a primeira opção).

apply plugin: 'jacoco'

jacoco.toolVersion versions.jacoco

tasks.withType(Test) {
    jacoco.includeNoLocationClasses = true
}

def classes = fileTree(dir: "$buildDir/tmp/kotlin-classes/debug")
def sources = files("$projectDir/src/main/kotlin")
def report = "$buildDir/reports/jacoco/report.xml"

task createCombinedCoverageReport(type: JacocoReport,
        dependsOn: ['testDebugUnitTest', 'createDebugCoverageReport']) {

    sourceDirectories = sources
    classDirectories = files(classes)
    executionData = fileTree(dir: buildDir, includes: [
            'jacoco/testDebugUnitTest.exec',
            'outputs/code-coverage/connected/*coverage.ec'
    ])

    reports {
        xml.enabled = true
        xml.destination file(report)

        html.enabled = true
    }
}

Bom, a parte de configuração do relatório em si é muito próxima do que expliquei nos dois posts sobre o assunto. Algumas coisas estão isoladas em variáveis (sourcesreport), pois vamos reutilizá-las já já.

Com isso, vamos incluir o arquivo no build.gradle do módulo app e habilitar a geração de cobertura de código para os testes instrumentados:

apply plugin: 'com.android.application'
apply plugin: 'org.jetbrains.kotlin.android'

apply from: "$rootDir/gradle/coverage.gradle"

android {
  ...
  buildTypes {
    debug {
      testCoverageEnabled true
      ...
    }
    ...
  }
}
...

Para finalizar, vamos setar a versão do jacoco no classpath para a última versão, modificando o build.gradle na raiz do nosso projeto:

buildscript {

  ext.versions = [
      'kotlin': '1.2.31',
      'supportLibrary': '27.1.1',
      'googleServices': '12.0.1',
      'jacoco': '0.8.1'
  ]
    
  repositories {
    google()
    jcenter()
  }
  
  dependencies {
    classpath 'com.android.tools.build:gradle:3.1.1'
    classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$versions.kotlin"
    classpath 'com.google.gms:google-services:3.2.1'
    classpath "org.jacoco:org.jacoco.core:$versions.jacoco"
  }
}

Feito isso, já podemos executar a task createCombinedCoverageReport e deveremos obter o relatório com incríveis 100% de cobertura, disponível dentro da pasta app/build/reports/jacoco

Perceba que ele foi gerado pela versão correta do Jacoco que configuramos, 0.8.1.

Com a métrica sendo gerada, é importante que ela esteja visível dentro do nosso fluxo de trabalho, para que possamos acompanhar a sua evolução ao longo do desenvolvimento do projeto. Para isso, precisamos “publicar” essa informação em algum lugar.

O Jenkins, por exemplo, oferece formas de acompanhar isso, caso que não ocorre com o CircleCI. Para isso, vamos utilizar um serviço externo, no caso o Coveralls. Durante o desenvolvimento, validei também o Codecov, porém ele não se comportou muito bem com o Kotlin. O Codecov irá se plugar ao nosso fluxo, validando a cobertura atual em cada Pull Request, e exibirá um histórico de como essa métrica evolui a cada commit.

Para que essa integração funcione, precisamos de enviar essa métrica para o Coveralls. Faremos isso através de um plugin. A configuração dele é bem simples:

// build.gradle
buildscript {
  ...
  repositories {
    google()
    jcenter()
    gradlePluginPortal()
  }
  dependencies {
    classpath 'com.android.tools.build:gradle:3.1.1'
    classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$versions.kotlin"
    classpath 'com.google.gms:google-services:3.2.1'
    classpath "org.jacoco:org.jacoco.core:$versions.jacoco"
    classpath 'org.kt3k.gradle.plugin:coveralls-gradle-plugin:2.8.2'
  }
}

No arquivo coverage.gradle, faremos a aplicação do plugin, bem como a sua configuração:

// coverage.gradle
apply plugin: 'jacoco'
apply plugin: 'com.github.kt3k.coveralls'

...

def classes = fileTree(dir: "$buildDir/tmp/kotlin-classes/debug")
def sources = files("$projectDir/src/main/kotlin")
def report = "$buildDir/reports/jacoco/report.xml"

...

coveralls {
    sourceDirs = sources.flatten()
    jacocoReportPath = report
}

Com isso, o último passo é configurar a chave de upload dos relatórios no CI. Para isso, basta criarmos uma variável de ambiente chamada COVERALLS_REPO_TOKEN e colocar o valor fornecido no painel do Coveralls.

Feito isso, o último passo é editar o nosso arquivo de configuração do CI para incluir algumas coisas:

version: 2
jobs:
  build:
    docker:
      - image: circleci/android:api-27-alpha

    working_directory: ~/social-app

    environment:
      JVM_OPTS: -Xmx3200m
      CIRCLE_JDK_VERSION: oraclejdk8

    steps:
      - checkout

      - restore_cache:
          key: jars-{{ checksum "build.gradle" }}-{{ checksum "app/build.gradle" }}

      - run:
          name: Accept licenses
          command: yes | sdkmanager --licenses || true

      - run:
          name: Decrypt release key
          command: openssl aes-256-cbc -d -in distribution/release.keystore-cipher -out distribution/release.keystore -md sha256 -k $CIPHER_DECRYPT_KEY

      - run:
          name: Setup Google Services JSON
          command: |
            mkdir -p app/src/debug/ && touch app/src/debug/google-services.json
            echo "${JSON_FIREBASE_DEVELOPMENT}" >> "app/src/debug/google-services.json"
            mkdir -p app/src/release/ && touch app/src/release/google-services.json
            echo "${JSON_FIREBASE_RELEASE}" >> "app/src/release/google-services.json"

      - run:
          name: Run Linters
          command: ./gradlew check

      - run:
          name: Run Tests and generate Code Coverage
          command: ./gradlew createCombinedCoverageReport

      - run:
          name: Upload code coverage data
          command: ./gradlew coveralls

      - run:
          name: Build
          command: ./gradlew assemble assembleAndroidTest

      - store_artifacts:
          path: app/build/reports/jacoco/createCombinedCoverageReport
          destination: coverage-report

      - store_artifacts:
          path: app/build/reports/tests/testDebugUnitTest
          destination: local-test-report

      - save_cache:
          paths:
            - ~/.gradle
          key: jars-{{ checksum "build.gradle" }}-{{ checksum "app/build.gradle" }}

Aqui eu fiz algumas modificações. Primeiramente, separei as tasks de linters, testes e build (a partir da linha 35), adicionando a task que vai enviar os dados para o Coveralls (Upload code coverage data). Além disso, comecei a disponibilizar no CircleCI os relatórios e de cobertura e execução de testes (utilizando a configuração store_artifacts). Com isso, após a execução de uma build com sucesso, é possível navegar por esses artefatos, desde que esteja logado.

Ufa! É isso! Você pode conferir no repositório o resultado dessas mudanças. A partir de agora, a cobertura de código é exibida também no README do projeto e como uma etapa de verificação nos PRs abertos!

Gerando o Relatório de Cobertura de Testes Unificado com Jacoco, Robolectric e Espresso

Padrão

Olá, pessoal! No post de hoje, veremos como é possível gerar um relatório de cobertura de testes em um projeto Android incluindo tanto os testes unitários (geralmente escritos com JUnit, Mockito, Robolectric, etc.) quanto os instrumentados (geralmente escritos utilizando o Espresso).

Relatórios de cobertura de testes são uma ferramenta muito importante para mensurar o quanto nossos testes realmente exercitam nosso código. Apesar de não serem a garantia de um software sem bugs, ter uma porcentagem alta de cobertura pode evitar muitas dores de cabeça no desenrolar do projeto.

No Android, para gerar esse tipo de relatório utilizamos o Jacoco (Java Code Coverage), uma das ferramentas mais utilizadas dentro do Java para esse propósito. No ambiente de desenvolvimento do Android, temos um fator dificultador que é o fato de possuirmos dois artefatos de teste diferentes, geralmente representados pelas pastas test (unitários) eandroidTest (instrumentados).

Primeiramente, vamos gerar o relatório de cobertura dos testes instrumentados do Espresso. Para este exemplo, teremos uma Activity bem simples:

public class MainActivity extends AppCompatActivity implements View.OnClickListener {

    private TextView text;

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        findViewById(R.id.button).setOnClickListener(this);
        findViewById(R.id.hide).setOnClickListener(this);
        text = (TextView) findViewById(R.id.text);
    }

    @Override
    public void onClick(View v) {
        if (v.getId() == R.id.button) {
            text.setText("Hello World!");
        } else {
            v.setVisibility(View.GONE);
        }
    }
}

O layout dessa Activity é o seguinte:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <TextView
        android:id="@+id/text"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Hello" />

    <Button
        android:id="@+id/button"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Click Me!" />

    <Button
        android:id="@+id/hide"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Don't Click Me!" />

</LinearLayout>

Vamos então criar um teste no Espresso para garantir que o texto do TextView text seja modificado para Hello World! ao clicarmos no Button button:

@RunWith(AndroidJUnit4.class)
@LargeTest
public class MainActivityTest {

    @Rule
    public ActivityTestRule<MainActivity> rule = new ActivityTestRule<>(MainActivity.class);

    @Test
    public void shouldUpdateTextAfterButtonClick() {
        onView(withId(R.id.button)).perform(click());

        onView(withId(R.id.text)).check(matches(withText("Hello World!")));
    }
}

Após a execução desse teste, temos o relatório de execução:

Captura de tela de 2015-12-22 13-58-54

Porém, o relatório de cobertura ainda não é gerado. Para habilitarmos essa opção, precisamos adicionar uma propriedade para a nossa build variant de debug. Na DSL do plugin do Android, habilite a cobertura por meio da propriedade testCoverageEnabled:

android {
    ...
    buildTypes {
        debug {
            testCoverageEnabled true
        }
        ...
    }
}

Agora, basta executar a task createDebugCoverageReport para que os testes sejam executados e o relatório seja gerado.

Captura de tela de 2015-12-22 14-12-19

Perfeito! Já temos o relatório de cobertura do nosso projeto.

Vamos agora criar um teste usando o Robolectric para testar o else da lógica da nossa Activity. Porém, um aviso: particularmente, eu não recomendo testar a Activity e componentes do Android diretamente pelo Robolectric. Prefira testes que, de fato, testem unidades de código.

Um teste com Robolectric que testa o comportamento do botão hide, cuja visibilidade é alterada quando clicado, ficaria assim:

@RunWith(RobolectricGradleTestRunner.class)
@Config(constants = BuildConfig.class, sdk = 21)
public class MainActivityTest {

    @Test
    public void shouldHideButtonAfterClick() {
        MainActivity activity = Robolectric.setupActivity(MainActivity.class);

        Button button = (Button) activity.findViewById(R.id.hide);
        button.performClick();

        assertThat(button.getVisibility(), is(View.GONE));
    }
}

Por padrão, o plugin do Android só gera o relatório de cobertura dos testes instrumentados. Para que seja possível gerar a cobertura dos testes unitários, é necessário criar uma task de execução do relatório manualmente:

apply plugin: 'jacoco'

task jacocoTestReport(type: JacocoReport, dependsOn: 'testDebugUnitTest') {

    reports {
        xml.enabled = true
        html.enabled = true
    }

    jacocoClasspath = configurations['androidJacocoAnt']

    def fileFilter = ['**/R.class', '**/R$*.class', '**/BuildConfig.*', '**/Manifest*.*', '**/*Test*.*', 'android/**/*.*']
    def debugTree = fileTree(dir: "${buildDir}/intermediates/classes/debug", excludes: fileFilter)
    def mainSrc = "${project.projectDir}/src/main/java"

    sourceDirectories = files([mainSrc])
    classDirectories = files([debugTree])
    executionData = files("${buildDir}/jacoco/testDebugUnitTest.exec")
}

Com a criação da task jacocoTestReport, temos agora a geração do relatório de cobertura também para os testes unitários.

Captura de tela de 2015-12-22 14-26-25

Porém, permanece o problema: como ter a cobertura unificada do resultado dos dois grupos de testes?

A instrumentação realizada pelo Jacoco produz arquivos de execução que contêm os dados necessários para a criação do relatório (HTML, XML, etc). O grande problema é que o Espresso gera o arquivo .ec, enquanto que a execução dos testes unitários gera o arquivo .exec, ou seja, temos formatos de arquivos diferentes!

E agora? Como converter de um formato para o outro? A resposta, obtida depois de muita pesquisa, é: simplesmente não é necessário converter!

Como não temos acesso à task que configura a execução dos testes com o Espresso, precisamos garantir que ela seja executada primeiro, para que, na execução do relatório dos testes unitários, o arquivo coverage.ec já esteja disponível.

Na task do relatório dos testes unitários, o que vamos fazer é adicionar o arquivo coverage.ec também como parâmetro na propriedade executionData:

apply plugin: 'jacoco'

task jacocoTestReport(type: JacocoReport, dependsOn: 'testDebugUnitTest') {

    reports {
        xml.enabled = true
        html.enabled = true
    }

    jacocoClasspath = configurations['androidJacocoAnt']

    def fileFilter = ['**/R.class', '**/R$*.class', '**/BuildConfig.*', '**/Manifest*.*', '**/*Test*.*', 'android/**/*.*']
    def debugTree = fileTree(dir: "${buildDir}/intermediates/classes/debug", excludes: fileFilter)
    def mainSrc = "${project.projectDir}/src/main/java"

    sourceDirectories = files([mainSrc])
    classDirectories = files([debugTree])
    executionData = files(["${buildDir}/jacoco/testDebugUnitTest.exec",
                           "${buildDir}/outputs/code-coverage/connected/coverage.ec"
    ])
}

Por fim, ao executarmos a task completa, com os testes instrumentados pelo Espresso primeiro, teremos o relatório unificado de cobertura.

gradle clean createDebugCoverageReport jacocoTestReport

Captura de tela de 2015-12-22 14-39-29

E é isso! O projeto de exemplo pode ser encontrado neste repositório do Github! #KeepCoding #KeepTesting