Android CI/CD Pipeline Using GitHub Actions and Firebase Test Labs

Set up your pipeline for testing UI and Unit Tests

As a Software Engineer, knowing how to set up a solid CI/CD Pipeline for your project is a vital skill. Whether you’re starting a side project or working on a well established project, having a solid CI/CD pipeline is going to enable you or your team to move faster.

The end goal is to protect your branches and ensure only quality code makes it in that passes your status checks.

For me, I want to ensure new code merging into my main branch is passing ALL Unit and UI Tests, and prevent code smell creep using tools such as CheckStyle (for legacy java files), Detekt, and Lint to block code with violations from auto-merging in.

The desired end state will look something like below. When a pull request is opened into my main branch (for me that’s develop), it will automatically kick off the below checks.

I’m not one to belabor a point, and let’s be honest, you’re not really even going to read this. You’re going to skip to the meat and potatoes of this post. So let’s just do that.

Requirements

  • Android project on GitHub
  • Firebase hooked up with your project (for things like Crashlytics/Analytics)
    • We will need to leverage the same google-services.json file used for these services later on.

At the root level of your project, add the following directory and files:

.github/workflow/android.yml

.github/workflow/launch_gradle_commands.yml

Below is what you will paste inside of the respective .yml files. Don’t worry, you can trust me :)

Also, I’ll go over what these files are doing in the following steps in case you DON’T trust me for whatever reason.

android.yml

name: CI

on:
  push:
    branches: [ develop ]
  pull_request:
    branches: [ develop ]

jobs:
  codesmell:
    name: Code Smell (Detekt, CheckStyle, Lint)
    uses: ./.github/workflows/launch_gradle_commands.yml
    secrets:
      GOOGLE_SERVICES: ${{ secrets.GOOGLE_SERVICES }}
    with:
      commands: lint detekt checkstyle

  unitTest:
    name: Unit Tests
    uses: ./.github/workflows/launch_gradle_commands.yml
    secrets:
      GOOGLE_SERVICES: ${{ secrets.GOOGLE_SERVICES }}
    with:
      commands: testReleaseUnitTest

  generateApks:
    name: Generate APKs
    uses: ./.github/workflows/launch_gradle_commands.yml
    secrets:
      GOOGLE_SERVICES: ${{ secrets.GOOGLE_SERVICES }}
    with:
      commands: assembleMockClientDebug assembleMockClientDebugAndroidTest
      uploadArtifacts: true

  uiTests:
    name: UI Tests on Firebase Test Labs
    needs: generateApks
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v2

      - name: Download App Debug APK
        id: app-debug-download
        uses: actions/download-artifact@v2
        with:
          name: app-debug

      - name: Download Android Test APK
        id: android-test-download
        uses: actions/download-artifact@v2
        with:
          name: android-test

      - name: Login to Google Cloud
        uses: google-github-actions/setup-gcloud@v0
        with:
          service_account_key: ${{ secrets.GCLOUD_AUTH }}

      - name: Set current project
        run: gcloud config set project ${{ secrets.FIREBASE_PROJECT_ID }}

      - name: Run Instrumentation Tests in Firebase Test Lab
        env:
          APP_DEBUG_LOCATION: app-mock-client-debug.apk
          ANDROID_TEST_LOCATION: app-mock-client-debug-androidTest.apk
          DEVICE_MODEL: Nexus9,version=24,locale=en,orientation=landscape
        run: gcloud firebase test android run --type instrumentation --app $APP_DEBUG_LOCATION --test $ANDROID_TEST_LOCATION --device model=$DEVICE_MODEL --use-orchestrator --num-flaky-test-attempts 2

launch_gradle_commands.yml

name: Gradle Executable Environment

on:
  workflow_call:
    inputs:
      commands:
        required: true
        type: string
      uploadArtifacts:
        required: false
        type: boolean
    secrets:
      GOOGLE_SERVICES:
        description: 'google-services.json for building app'
        required: true

jobs:
  launch_gradle_commands:
    name: Gradle
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-java@v2
        with:
          distribution: temurin
          java-version: 11

      - name: Write compile time google-services.json file
        env:
          GOOGLE_SERVICES: ${{ secrets.GOOGLE_SERVICES }}
        run: echo $GOOGLE_SERVICES > app/google-services.json

      - uses: gradle/gradle-build-action@v2
        with:
          gradle-version: current
          arguments: ${{ inputs.commands }}

      - name: Uploading App Debug Build
        if: ${{ inputs.uploadArtifacts }}
        uses: actions/upload-artifact@v2
        with:
          name: app-debug
          path: app/build/outputs/apk/mockClient/debug/app-mock-client-debug.apk
          if-no-files-found: error

      - name: Uploading Android Test Build
        if: ${{ inputs.uploadArtifacts }}
        uses: actions/upload-artifact@v2
        with:
          name: android-test
          path: app/build/outputs/apk/androidTest/mockClient/debug/app-mock-client-debug-androidTest.apk
          if-no-files-found: error

Boom!

At this point, you could just copy and paste the above two .yml files and 90% of the work is complete in regards to having a working CI/CD Pipeline. The rest of these steps really are just toggling a few things on to make sure the UI Tests work as well as ensuring that our pipeline “blocks” PR’s until all of these checks pass, which really is the whole point of a CICD Pipeline… to ensure only code that meets your standards makes it into the code base.

So let’s quickly breakdown these files, starting with the android.yml file.

Android.yml File Breakdown

Starting from the top of the android.yml file, here we go.

name: CI

on:
  push:
    branches: [ develop ]
  pull_request:
    branches: [ develop ]

These first few lines are standard GitHub Action lines and are telling it WHEN to kick off the following list of jobs. Here we are saying on Push or PR’s to our develop branch, but you can replace this with w/e you want.

jobs:
  codesmell:
    name: Code Smell (Detekt, CheckStyle, Lint)
    uses: ./.github/workflows/launch_gradle_commands.yml
    secrets:
      GOOGLE_SERVICES: ${{ secrets.GOOGLE_SERVICES }}
    with:
      commands: lint detekt checkstyle

So this is where we start to see some interesting items.

As you can see, this is where we start telling GitHub actions WHAT to actually do. Our first job is codesmell, which will run the submitted PR through our various codesmell checks to ensure it meets common styleguide.

If you want to read more about these individual static analysis tools, see below:

  • Lint: Ensure code has no structural problems
  • Detekt: Enforce kotlin code to adhere to a coding standard
  • CheckStyle: Enforce Java code to adhere to a coding standard

** Note: You will need to configure each of these checks individually to “fail” on violations in order to actually fail the CICD pipeline. See the documentation above for further guidance, or reach out :)

You may be asking, “But how exactly am I kicking off these checks?” With that, we will need to do a little detour over to our other .yml file.

-=-=-==-=-=-=-=-=-=-=-=-=-=-=-=-=-=-

Launch Gradle Commands .yml File Breakdown

Let’s take a look at our launch_gradle_commands.yml file to understand how it works:

name: Gradle Executable Environment

on:
  workflow_call:
    inputs:
      commands:         # String representing the Gradle commands to launch. 
        required: true  #   What you would pass in when calling './gradlew', for example './gradlew lint'
        type: string
      uploadArtifacts:  # Boolean value indicating whether or not we should upload artifacts generated from this command
        required: false #  Only used when actually building '.apk' files
        type: boolean
    secrets:
      GOOGLE_SERVICES:  # Our google-services.json file, which we have loaded into GitHub Secrets
        description: 'google-services.json for building app'
        required: true

Much like our previous android.yml file, this portion of the file establishes our WHEN. Unlike the previous file which was launched on a push or a pull request to a certain branch, this one specifically only launches when the workflow itself is called, hence the on: workflow_call:.

But unlike our previous command, this one actually establishes a few inputs, or arguments which need to be passed in with the call to this workflow. You can read the comments above next to the inputs to see what each is used for.

Now let’s start breaking down the steps for the launch_gradle_commands.yml file.

steps:
      - uses: actions/checkout@v2    # Dependencies for setting up other actions
      - uses: actions/setup-java@v2  # Sets up Java for this environment
        with:
          distribution: temurin
          java-version: 11

This one is pretty straight forward. Onward!

-=-=-==-=-=-=-=-=-=-=-=-=-=-=-=-=-=-

Write compile time google-services.json step

- name: Write compile time google-services.json file
        env:
          GOOGLE_SERVICES: ${{ secrets.GOOGLE_SERVICES }}      # Uses the input provided from this job
        run: echo $GOOGLE_SERVICES > app/google-services.json  # Compile time google-services.json file provided

As an Android developer, you should never commit your google-services.json file to your repo, since that contains some of the keys to your kingdom which should be kept secret.

As a result, when trying to build/run actions against your project that has any Google service as a dependency, it will most likely fail with the following error:

To get around this issue, you need to add the contents of your google-services.json file to your projects Secrets so that you can access them during the build process.

To access your projects secrets in GitHub, just go to your repository, and as long as you have the access, you should be able to go to Settings -> Secrets -> New Repository Secret

The secrets for this workflow that are required are listed below:

What are these individual secrets?

  • GOOGLE_SERVICES: This is the google-services.json file previously mentioned

  • GCLOUD_AUTH: This is an authorization token required by Firebase Test Labs in order for us to launch the tests. How would you obtain such a token? For this one, I actually had to refer to a VERY similar blog post to this one that I’m writing. The author, Wojciech Krzywiec, does a great job at breaking down these steps in the “Step 3. Run tests on Firebase Test Lab Job” that are going to be better detail than I can go into. Github Actions Firebase Test Lab. Basically you need to acquire this token, just base64 encoding on the file, and then uploading that resulting file to GitHub to use during the authentication step of the process.

  • FIREBASE_PROJECT_ID: This is the name of the project inside of Firebase. You should be able to pull that from the FireBase dashboard.

Now with these secrets loaded into GitHub, our GitHub Actions can leverage those at compile time, which is pretty awesome.

Running Gradle Commands

To actually run the commands, which one could argue is the sole purpose of the launch_gradle_commands.yml file, I am leveraging a separate GitHub action to do so, gradle-build-action. This GitHub action is built by the gradle team themselves, so you can’t really top that.

- uses: gradle/gradle-build-action@v2
        with:
          gradle-version: current
          arguments: ${{ inputs.commands }}

Essentially all this GitHub Action requires is that the “commands” be passed in, which is one of the inputs to this yml file that are required. You can see that when I call this command, I pass in a string which points to the command you want to use.

Optional Upload of File

The first two gradle calls I make in my calls are pretty similar. They are the lint detekt checkstyle call and the testReleaseUnitTest call.

assembleMockClientDebug assembleMockClientDebugAndroidTest are the first calls that leverage the uploadArtifacts boolean variable. Since we are building .apks during this step to be used for testing later on, we need to be sure to retain those artifacts. By “uploading” them, this is actually going to store those artifacts inside of that particular pipeline job. The nice thing about this is if you ever need to go back and inspect or test out locally those APK’s, they are available here for you to use.

Bouncing back to android.yml file

So now let us start to tie all of this back together. At a high level, the android.yml file is accomplishing the following:

  • Run Code Smell Checks
  • Run Unit Tests
  • Build APK’s used for UI tests
  • Setup GCloud Environment used for FireBase Test Labs
  • Send APK’s to FireBase Test Labs

The first three steps I described above in the previous file (for the most part).

Now let’s take a look at the steps inside of the uiTests job.

The first thing it is doing is downloading the apk’s that we uploaded in the generatedApks job.

- name: Download App Debug APK
        id: app-debug-download
        uses: actions/download-artifact@v2
        with:
          name: app-debug

      - name: Download Android Test APK
        id: android-test-download
        uses: actions/download-artifact@v2
        with:
          name: android-test

The reason I personally took this approach was to have a way to retain those artifacts for a local build if, for whatever reason, the tests came back with something I was not expecting. Another reason is, frankly, I could not figure out how to retain the artifacts built in a previous step to pass to the next :P.

For small projects, this is not a huge deal. For larger organizations with hundreds of contributing developers, this method might not scale well, so feel free to throttle this as you see fit.

Authorizations Required in Firebase and Google Cloud

The next step is to get our gcloud environment setup. I ran into a couple errors related to roles that needed to be modified to have “Edit” access as well as enabling of the Cloud Tool Results API.

One error stated that Cloud Tool Results API has not been used in project ######## before or it is disabled It will provide a link for you to enable it.

Just click the button :)

The only other error I ran into was around permissions for a specific role.

That was also a relatively easy fix after a quick google search.

Just locate that specific role, and change it to the following:

Now you’re off to the races.

Setting Required Steps in CICD Pipeline

This is the fun part. This is what all your hard work was for. Now you can actually start to tie this up to where you can block PR’s that violate one of your checks from being able to automatically merge in.

First, let’s navigate to your Branch Protection rules on GitHub. To get there, click on Settings -> Branches -> then click the Edit button for the Branch Protection Rules.

Inside of here are a ton of cool settings that I will let you tinker with. For now, I’m just concerned with the “Require status checks before merging” protection.

This is where you can enter in the custom GitHub Actions that you created in order to actually REQUIRE them to pass before allowing any changes to merge.

This is a really nice feature if you are in a large or small organization and want to ensure you have quality control of code going into the code base.

If you don’t mark this as “required”, the CI/CD pipeline can kick off but as long as there are no merge conflicts, the developer could still merge it in.

For instance, if I left off the UI checks like so:

It woud look like so:

That’s no bueno.

Check out your tests!

A post wouldn’t be complete without being able to see your beauty in action.

Witness the beauty that is my flaky tests being launched in a continuous, autonomous fashion.

Hey, don’t judge… we are all works in progress.

Was it some recent code that broke my tests? Probably, but for now, all I care about is that I have a reliable way to run my tests. And that is worth celebrating.

Finally

Well hopefully that was clear as mud and you learned something along the way. If you didn’t, leave a comment. If you did, leave a comment. If you have a question, leave a comment.

Either way, thank you for reading, and happy CI/CD’ing!


comments powered by Disqus