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. By Prashant Barahi.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
Continuous Integration for Android
30 mins
- Getting Started
- Understanding Continuous Integration
- Workings of a Continuous Integration System
- Working With a Docker Container
- GitHub Actions
- Understanding the GitHub Actions Workflow
- Setting up GitHub Actions for Android
- Defining Workflows and Jobs
- Running Unit Tests
- Running Instrumentation Tests
- Generating the APK File
- Code Coverage
- Setting up the JaCoCo Plugin
- Making the Build Fail
- Where to Go From Here?
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.
Now, take a look at the starter project. It contains the following files:
- 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 commonView.OnClickListener
and a tag associated with it. When the user clicks a button, it invokes theonClick()
, 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.
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.
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.
Great! You now have an Ubuntu OS running on top of your OS, ready to run your commands.
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.
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.
- Here you give Simple Workflow Example as a name to the workflow using the
name
key. - 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. - Define jobs using the
jobs
key. build and time are the two jobs in this workflow. - Inside a job, give it a name using the
name
key. - 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.
- 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 therun
key. Optionally, you can usename
key to give a name or a description to the step. - 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.
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.
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:
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.
./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.
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.
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.
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!
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.
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.
- 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.
- 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.
Now, comment out some test cases to see the task fail with corresponding reasons.
./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.
All videos. All books.
One low price.
A Kodeco subscription is the best way to learn and master mobile development — plans start at just $19.99/month! Learn iOS, Swift, Android, Kotlin, Flutter and Dart development and unlock our massive catalog of 50+ books and 4,000+ videos.
Learn more