Home · Android & Kotlin Tutorials

Continuous Integration for Android

Learn how to use Continuous Integration for Android to be sure you have fully-verified and battle-tested code on the master branch.

4.7/5 6 Ratings

Version

  • Kotlin 1.3, Android 5.1, Android Studio 4.0

The master branch in a repository is generally the most stable of all the branches. Developers should be able to make a production-ready (or equivalent) build out of it — which means the master branch has to have fully-verified and battle-tested code. But how can you be sure that’s the case?

That’s where CI comes in! CI, short for continuous integration, is a development practice in which each member of a team frequently merges their codes into the main repository branch. Each integration triggers an automated build and test workflow, allowing the team to detect and fix problems as early as possible.

In this tutorial, you’ll learn how to implement continuous integration for Android in an app called Simple Calculator.

In the process, you’ll learn to:

  • Implement CI in your workflow.
  • Use GitHub Actions.
  • Integrate testing frameworks to compliment the CI workflow.
  • Use code coverage tools like JaCoCo — and learn why they’re important.

Getting Started

Click the Download Materials button at the top or bottom of the page to download the starter project. Launch Android Studio 4.0 or later and select Open an existing Android Studio project, then navigate to and open the starter project’s folder.

Build and run the app, then take some time to use it and then familiarize yourself with its code.

How Simple Calculator works

Now, take a look at the starter project. It contains the following files:

List of files in the project structure

  • Calculate.kt is an interface that all the singleton classes in the operators package implement.
  • MainActivity.kt handles the UI interactions. Each button inside the ConstraintLayout has a common View.OnClickListener and a tag associated with it. When the user clicks a button, it invokes the onClick(), which uses the tag of the clicked view to decide how to handle it.

    For example, clicking on any numerical or decimal button appends a tag that holds the value that corresponds to the input field of the calculator.

  • CalculatorEngine.kt is the core of the app, driving the calculation. Based on the input operator, it delegates the calculation to corresponding classes and updates the result with the output of the calculation.
Note: The project also contains some unit tests and instrumentation tests. The instrumentation tests are written using Kaspresso. Tests are vital — continuous integration is ineffective without them.

Start the emulator and run both unit and instrumentation tests. Don’t forget to turn off device animations before running instrumentation tests since these often depend on the UI to work properly.

Take some time to explore the files inside app/build, which will be important in the upcoming sections.

Now, it’s time to start implementing continuous integration for Android.

Understanding Continuous Integration

Since the master branch has to be stable at any given time, no developer should push their commits directly to that branch. When working on a new feature, you must create a new branch from the master then work on that branch.

When you’re done, you pull changes from the main branch into yours, resolve any merge conflicts then push that branch to the project repository. Multiple developers working on the same repository should all follow that same pattern.

To keep your branch updated, you need to pull changes from the main branch frequently. This also saves you from huge merge conflicts.

When you push your changes to your repository, you trigger the CI workflow that runs the test cases. You can only merge your commit to the master after the code passes the tests.

If the tests fail, you have to hunt for the errors using the CI logs, fix them and repeat those steps. This ensures that only tested and working code gets to the master branch.

Continuous integration workflow

So now that you understand the theory, it’s time to look at how to put CI into practice.

Workings of a Continuous Integration System

Most CI providers use virtual machines and/or lightweight abstractions called containers. Containers allow a developer to package an application and all its required dependencies and deploy it as one package. For example, you could package a web application that runs on Java with the Java Runtime Environment.

A popular containerization tool is Docker. When you use Docker, you distribute the containers as a Docker image.

For this tutorial, you’ll use a Docker image to spawn the Ubuntu OS without an user interface. Inside the container, you’ll use apt-get to set up necessary tools like Git and Gradle. With them installed, you’ll be able to clone your Android project from the repository, build the project and a lot more. The caveat is that since there is no interface, you’ll have to use the CLI (Command Line Interface).

In the next step, you’ll go behind-the-scenes of what happens inside a CI machine.

Working With a Docker Container

Follow the official guides to set Docker up on your system. Once you install it, you’ll be able to run Ubuntu on top of your OS. Just open a terminal window and enter the command below:

docker run -it ubuntu:latest

With this command, you download the ubuntu image with the latest tag from DockerHub, if it’s not available locally. You then run it in interactive mode — that is, you get access to the container’s terminal.

Note: Docker isn’t only useful for running OSes. You can also use it to run databases like MySQL, PostgreSQL and web applications. You can even create and distribute your own images.

Great! You now have an Ubuntu OS running on top of your OS, ready to run your commands.

Running Docker

Note: Use the docker images command to list all the downloaded images and docker ps to list the running containers. You can find other useful Docker commands in our tutorial, Getting Started with Docker.

Next, you’ll enter the following command:

apt-get update
apt-get install git-all

The first command makes sure all your packages are currently updated and the second installs Git in your docker container.

To install Gradle, follow Linuxize’s directions on installing Gradle on Ubuntu.

The bottom line is that you’ll be presented with a lightweight OS and all you need to do is provide the commands for it to execute.

However, since you’ll be using some third-party providers for this tutorial, you won’t need to deal with Docker directly and most likely won’t need to install standard tools like Git. But the tools like Gradle and JDK (Java Development Kit) are unlikely to be provided. If so, you need to manually set these up in the container.

In the next section, you’ll learn how to use GitHub’s automation tool: GitHub Actions.

GitHub Actions

GitHub recently started providing a workflow automation feature named GitHub Actions. You’ll find it under the Actions tab of your repository.

You’ll require a GitHub account to be able to use GitHub Actions. Open GitHub and sign in to your account.

Then create a new repository – name it SimpleCalculator and follow the instructions provided by GitHub to push the starter project to the newly created GitHub repository.

GitHub Instructions

Great! You have your GitHub repository ready.

There should be several tabs at the top of your repo, the Actions tab is where the progress of workflow for the current repository is displayed. If you click on it you will see that it’s empty right now as you haven’t defined any workflows yet. You’ll learn to do so in the upcoming sections.

But before you can dive into using GitHub Actions, here are some important terms you should know:

  • Events are specific activities that trigger the workflow. Define them using the on key.
  • Jobs are a set of steps that execute on a fresh instance of a virtual environment. You can have multiple jobs and run them sequentially or in parallel by defining their dependency rules.

    For example, you might have a rule that says: Before running a job to generate an APK file, first run the job that runs test cases.

    Unit tests and instrumentation tests can run in parallel.

  • Runners are machines that execute jobs defined in the workflow file. GitHub hosts Linux, Windows and macOS runners with commonly-used software pre-installed, but you can create custom runners as well. Basically, these are equivalent to the containers or virtual machines mentioned in the section above.
  • Actions are the smallest portable building blocks of a workflow, which you include as a step. The popular one is actions/checkout@v2, which you use to check out the current repository into the runner’s file system. Use actions/setup-java@v1 to set up a specified version of Java in the runner.
  • Artifacts are files like APKs, screenshots, test reports, logs and so on, which the workflow generates. You can upload and download artifacts to the current workflow using actions/upload-artifact@v2 and actions/download-artifact@v2 respectively.

These terms should be enough to get you through this tutorial. If you’d like to learn more important concepts, visit the GitHub Actions official documentation.

Understanding the GitHub Actions Workflow

As mentioned earlier, you’re limited to CLI when working with any automated tools — otherwise, they wouldn’t be “automated”.

In GitHub Actions, you specify these CLI commands using a YAML file. YAML is a human-friendly data serialization language like JSON, but cleaner, more readable and more expressive.

Here’s an example of a GitHub Actions workflow configuration file:

# 1
name: Simple Workflow Example
# 2
on: [push]

# 3
jobs:
  build:
    # 4
    name: Greet
    # 5
    runs-on: ubuntu-latest
    # 6
    steps:
      - name: Hello world step
        run: echo Hello, World!

  time:
    name: Print date
    # 7
    needs: 
     - build
    runs-on: ubuntu-latest
    steps:
     - run: echo "It is $(date)"

So, what does this YAML snippet do? Let’s go over this step by step.

  1. Here you give Simple Workflow Example as a name to the workflow using the name key.
  2. Using the on key, list all the events that will trigger the workflow. Current workflow is triggered only on push events i.e. whenever you push the changes to GitHub repository.
  3. Define jobs using the jobs key. build and time are the two jobs in this workflow.
  4. Inside a job, give it a name using the name key.
  5. Define the runners on which the current job will be executed on. The greet and the time jobs both run in different instance of ubuntu-latest runner.
  6. In previous section, you learned that commands are only way to instruct the runners what they need to do. These are defined in steps key using the run key. Optionally, you can use name key to give a name or a description to the step.
  7. The needs key specifies that the time job will run only after the build job completes. By default, the independent jobs run in parallel to each other.

Create a directory .github in the root of the Git repository. Inside it, create another directory called workflows. This is where all the GitHub Actions configuration files go.

Now, save the YAML file mentioned above as simple-workflow.yaml and put it inside .github/workflows and add, commit, then push it to GitHub. GitHub Actions will then start this workflow. Go to GitHub and navigate to current project’s Actions tab to see the workflow in action.

Simple Workflow Example executing in GitHub Actions

As you can see, the jobs both executed successfully. Click on one of the jobs to see the logs of its execution. Take your time to understand how workflows, jobs and steps are laid out by GitHub Actions.

Simple Workflow Example's logs

Setting up GitHub Actions for Android

As mentioned in above section, GitHub Actions requires that you must have the workflow YAML files in .github/workflows. You can have multiple workflow files that are triggered by events defined in those files.

For Android development, you’ll need to set up a JDK in the runner, then check out your source code in the runner’s file system. As for Gradle, Android Studio projects, by default, has a Gradle wrapper shell script gradlew and a Windows batch script gradlew.bat – which can be invoked using ./gradlew and ./gradlew.bat respectively.

Next, you’ll see how to design the workflows for your project.

Defining Workflows and Jobs

To define a workflow, start by creating android-workflow.yaml inside .github/workflows. Add the following code to the file:

name: CI Workflow
on: [push]

Here, you name the workflow CI Workflow and make it trigger when a user pushes a commit to this repository.

Next, you’ll define the jobs you need in the current workflow. Remember that the runner’s file system doesn’t have the code yet. So, you need to check out the code from the current repository and set up the Java environment in the runner. You’ll use actions to accomplish this. Recall that the actions are written as a part of job’s steps. After the on key, add the following lines:

jobs:
  build-and-test:
    name: Build and run tests
    runs-on: ubuntu-latest
    steps:
      - name: Checkout current repository in ubuntu's file system 
        uses: actions/checkout@v1
      - name: Setup JDK 1.8
        uses: actions/setup-java@v1
        with:
          java-version: 1.8
      - name: Print contents in current directory
        run: ls -la

Here you’ve used actions/checkout@v1 and actions/setup-java@v1 to checkout the current GitHub repository and setup Java Development Kit in your runner respectively. The third step, as its name says, prints the contents of current working directory.

Add, commit and push the changes to trigger the workflow. The current runner contains the following files:

File structure inside the current runner

Running Unit Tests

Now that you’ve added your code to the file system, use ./gradlew testDebugUnitTest to run the unit test with the Gradle wrapper.

Note: You can use ./gradlew tasks to list all the Gradle tasks available.

Add below lines as a step of build-and-test job.

- name: Unit tests
  run: ./gradlew testDebugUnitTest

Add, commit and push the commits and you’ll see the workflow has executed the unit tests.

Unit test results after a successful completion

Running Instrumentation Tests

To execute the instrumentation tests, you need an Android device, preferably a virtual one. You’ll also need a custom runner. Luckily, there’s an Android emulator available. To benefit from the hardware acceleration, however, you have to use a macOS runner.

Amend the build-and-test job as shown below:

jobs:
  build-and-test:
    name: Build and run tests
    # runs-on: ubuntu-latest
    runs-on: macos-latest # Switched to macOS
    steps:
        ...
      - name: Run unit tests
        run: ./gradlew testDebugUnitTest

      - name: Run instrumentation tests
        uses: reactivecircus/android-emulator-runner@v2
        with:
          api-level: 29
          arch: x86
          profile: Nexus 6
          avd-name: test
          emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim -camera-back none
          disable-animations: true
          script: ./gradlew connectedCheck

Here, you’ve changed the runs-on key of build-and-test job to macos-latest and used reactivecircus/android-emulator-runner@v2 to spawn an Android emulator inside the macOS runner.

You can read more about usage and configurations of the emulator in its documentation.

Now, add, commit and push the changes. After the test cases get executed, you’ll find .html and .xml files inside app/build/reports. These contain the test results and summaries. They also come in handy when you need to analyze what went wrong during the test’s execution.

To upload these files as artifacts, right after the Run instrumentation tests step, add another step to define an action actions/upload-artifact@v1. It requires the path where the files to be uploaded are located and the name of the output artifact. Provide this using path and name key inside the with key as shown in snippet below:

- name: Upload Reports
  uses: actions/upload-artifact@v1
  with:
     name: reports
     path: app/build/reports

Again, add, commit and push your changes. After the job finishes, it generates an artifact with the name reports and associates it with the current workflow.

Reports now shows up under Current Artifacts

Generating the APK File

After the build succeeds, you’ll generate the APK file and upload it as an artifact.

While you could do this in the same build-and-test job, in this tutorial you’ll create another job and use the needs key to execute it after the build-and-test job completes.

Remember that each job gets a fresh instance of the runner. This means you have to check out the repository and set Java up, again!

Add this line right after the build-and-test job:

generate-apk:
  name: Generate apk
  runs-on: ubuntu-latest
  needs:
    - build-and-test
  steps:
    - uses: actions/checkout@v1

    - name: Setup JDK 1.8
      uses: actions/setup-java@v1
      with:
        java-version: 1.8

    - name: Generate apk
      run: ./gradlew assembleDebug

    - name: Upload APK
      uses: actions/upload-artifact@v1
      with:
        name: build-output
        path: app/build/outputs/apk/debug/app-debug.apk

The above snippet is similar to the build-and-test. It defines a job – generate-apk and uses needs key to declare its dependency on the former build-and-test job. Since no emulators are involved, this can be executed in ubuntu-latest runner. ./gradlew assembleDebug command generates an APK app-debug.apk at app/build/outputs/apk/debug/ directory. In the final step, provide this path to actions/upload-artifact@v1 action to upload it as an artifact.

Finally, add, commit and push. After the final step in the job completes, you’ll see that the APK you generated has been uploaded as an artifact named build-output.

Build-output artifact added to the list of current artifacts

Congrats! You’ve successfully implemented continuous integration for Android in your project. There’s just one more step to take until you can be confident that the code you merge to master is production-ready.

Code Coverage

Wait! As things stand, you could add 100 more features with no test cases to cover them and the build will still pass. That’s less than ideal — this loophole defeats the purpose of continuous integration.

To fix this, you need to add something that will fail the workflow if the test cases aren’t enough. This is where code coverage comes in.

A code coverage tool like JaCoCo, which stands for Java Code Coverage, uses the output of the tests to analyze the lines of codes touched by your test cases. If the coverage is below a specified threshold, it’ll fail your task, thus failing your workflow. Just what you need!

Note: There’s another issue to think about as well. 100% coverage means that your test cases have touched every line of the code… but that doesn’t mean that your app is free of bugs. In addition to code coverage, state coverage is another parameter that needs to be considered.

A function with one Bool argument can have two states: when the argument is true and when the argument is false. Similarly, a function with two Bool arguments will have 22 states. Now, imagine the number of states a function with an Int argument have. A lot!

But don’t worry, you don’t have to cover all those states. Covering just the boundary conditions is generally enough. However, this is something to keep in mind while writing tests.

Now, it’s time to see how JaCoCo works.

Setting up the JaCoCo Plugin

To set up the JaCoCo plugin in the Simple Calculator project, start by importing its Gradle plugin. In the project-level build.gradle file, add the JaCoCo Gradle plugin to the classpath:

buildscript {
  dependencies {
    // ...
    classpath "org.jacoco:org.jacoco.core:0.8.5"
  }
}

Next, apply and configure the plugin in the app-level build.gradle:

apply plugin: 'jacoco'

android {
   // ...

   buildTypes {
    debug {
        testCoverageEnabled true
      }
   }
}

jacoco {
  toolVersion = "0.8.5"
}

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

This code tells Gradle that we will be using the JaCoCo plugin version 0.8.5.

Since this project contains Kotlin code, the generated classes will be inside app/build/tmp/kotlin-classes/debug.

Next, think back to the structure of app/build; this is similar. The unit tests generate an .exec file inside the jacoco directory, while instrumentation tests generate an .ec file in outputs/code_coverage/debugAndroidTest/connected. You need to explicitly tell the JaCoCo task to take them both into account when generating the coverage report.

Add following code to the end of app-level build.gradle file:

// Files with such regex patterns are to be excluded 
def fileFilter = ['**/R.class', '**/R$*.class', '**/BuildConfig.*',
                  '**/Manifest*.*', '**/*Test*.*', 'android/**/*.*']

// Location of generated output classes 
def debugTree = fileTree(dir: "$project.buildDir/tmp/kotlin-classes/debug", 
   excludes: fileFilter)

// Source code directory
def mainSrc = "$project.projectDir/src/main/java"

// Task declaration
task jacocoTestReport(type: JacocoReport) {
  // Runs only after the dependencies are executed 
  dependsOn = ['testDebugUnitTest', 'createDebugCoverageReport']
  // Export formats
  reports {
    xml.enabled = true
    html.enabled = true
  }
 
  sourceDirectories.setFrom(files([mainSrc]))
  classDirectories.setFrom(files([debugTree]))
  
  // Inform Gradle where the files generated by test cases - are located
  executionData.from = fileTree(dir: project.buildDir, includes: [
      'jacoco/testDebugUnitTest.exec',
      'outputs/code_coverage/debugAndroidTest/connected/*.ec'
  ])
}

Execute the task using ./gradlew jacocoTestReport. After completion, you’ll see that the reports are generated in app/build/reports/jacoco/jacocoTestReport/html. Navigate to the mentioned path and open the index.html using a web browser to analyze the coverage reports.

Results of the test coverage

The lines with the green highlights are covered by the test, the yellow ones are partially covered and the red ones aren’t covered at all. You should add more tests to increase the test coverage.

Your next step is to add the ability to fail the build when not enough code goes through testing.

Making the Build Fail

To make the build fail due to inadequate test cases, you need to create rules that define a threshold that JaCoCo verifies. If your test cases don’t meet the criteria, the build will fail.

In the app-level build.gradle file, add the following code:

// Task declaration
task jacocoTestCoverageVerification(type: JacocoCoverageVerification) {
    // Run only after the test reports are generated
    dependsOn = ['jacocoTestReport']
    enabled = true
    sourceDirectories.from = files([mainSrc])
    classDirectories.from = files([debugTree])
    executionData.from = fileTree(dir: project.buildDir, includes: [
            'jacoco/testDebugUnitTest.exec',
            'outputs/code_coverage/debugAndroidTest/connected/*.ec'
    ])

    violationRules {
        failOnViolation = true 
        // 1
        rule {
            enabled = true
            element = 'PACKAGE'
            includes = ['com.raywenderlich.android.simplecalculator.operators']
            limit {
                counter = 'CLASS'
                value = 'MISSEDCOUNT'
                maximum = 0
            }
        }
        // 2
        rule {
            element = 'PACKAGE'
            includes = ['com.raywenderlich.android.simplecalculator']
            limit {
                value = 'COVEREDRATIO'
                counter = 'INSTRUCTION'
                minimum = 0.8
            }
        }

    }
}
// Make the check gradle task depend on the above task so that failure of above task will fail the check task
check.dependsOn jacocoTestCoverageVerification

Similarly to the jacocoTestReport task, you’ve created and configured another task named jacocoTestCoverageVerification that gets executed after jacocoTestReport completes. Inside the violationRules block, there are two rules that the JaCoCo checks the test results against.

  1. Since adding features means adding a class in operators package in current case, make sure no class is left uncovered. Set the maximum missed count value to 0, which will fail the task if you add a class inside operators but don’t cover it with test cases.
  2. This defines rule that fails the build if the covered ratio of the Java byte-code instructions is less than 80%.

Currently, this is a successful task because the test cases fulfill the criteria set in the JaCoCo verification rules.

Passing test results

Now, comment out some test cases to see the task fail with corresponding reasons.

Failed task

Note: To run the verification task, use ./gradlew clean jacocoTestCoverageVerification.

Finally, add, commit and push the changes — the workflow executes successfully.

Great! You’ve integrated JaCoCo in the workflow. For more configuration options, check JaCoCo’s documentation.

Where to Go From Here?

Congrats! You’ve learned how to implement continuous integration for Android. You can download the complete project using the Download Materials button at the top or bottom of the tutorial.

But CI is only half of a whole. CD, short for Continuous Delivery is the practice of automating the delivery of the app to a location like the Google Play Store.

As a whole, the practice is called CI/CD, and it’s commonly used in Agile teams to increase software quality, shorten delivery cycles and optimize the feedback loop.

Leverage third-party actions to ease the workflow configuration. Actions like r0adkll/upload-google-play help you with CD.

Another helpful action lets you post the link to the artifacts in your Slack channel to inform your team about the new build.

Check out GitHub’s Marketplace to discover more.

Also, remember to check out our Continuous Integration course here.

If you have any comments or questions, feel free to join in the forum discussion below.

Average Rating

4.7/5

Add a rating for this content

6 ratings

More like this

Contributors

Comments