Instrumented Android tests need a device or an emulator. An emulator without hardware acceleration is too slow to be useful in CI, and acceleration on Linux needs /dev/kvm.
GitHub-hosted Linux runners expose /dev/kvm. With one extra step to make it usable, a small app’s instrumented test ran green in about three minutes from a cold start. This post has the workflow I used, the numbers from four runs, and the “optimisation” that turned a three-minute job into one that hung for more than twenty.
The test project
I kept the app as small as possible so that the CI setup was the only thing being tested:
- A Kotlin/Gradle app with one module.
- One
androidTesttest using AndroidJUnit4 that asserts the app’s package name. - Android Gradle Plugin 8.5.2, Kotlin 1.9.24,
compileSdk34,minSdk24. - No Gradle wrapper committed. CI installs Gradle 8.7 itself, so the repo is text files only.
The workflow
This is .github/workflows/android-ci.yml:
name: Android CI
on:
push:
workflow_dispatch:
jobs:
instrumented-test:
name: Instrumented test on emulator
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
with:
gradle-version: "8.7"
- name: Enable KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Run instrumented tests on emulator
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 29
arch: x86_64
target: default
disable-animations: true
emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim
script: gradle connectedCheck
What each step is for
Triggers. push runs it on every commit. workflow_dispatch adds a “Run workflow” button, which I used to check that a re-run with no code change gives the same result.
Set up Gradle. gradle/actions/setup-gradle with gradle-version: '8.7' puts a gradle binary on the path. That is why the script calls gradle connectedCheck and not ./gradlew. If your repo has a wrapper, use the wrapper.
Enable KVM. This is the step people miss. The device node exists on the runner, but the runner user cannot open it. The udev rule sets the mode on /dev/kvm to 0666, then udev is reloaded and re-triggered for that device so the rule applies immediately. Without this step the emulator cannot use hardware acceleration.
The emulator step. android-emulator-runner downloads the system image, creates the AVD, boots it, waits for boot, runs the script, and shuts the emulator down. My options:
api-level: 29,arch: x86_64,target: default: a plain x86_64 image without Google APIs. It matches the runner’s CPU architecture, which is what lets KVM help.-no-window: there is no display on a runner.-gpu swiftshader_indirect: software rendering for graphics, since there is no GPU.-no-snapshot: start cold and do not save state on exit.-noaudio -no-boot-anim: skip things a test does not need.disable-animations: true: turns off system animations, which removes a common source of flaky UI tests.
gradle connectedCheck builds the app and test APKs, installs them on the running emulator, and runs every instrumented test.
Results
The first run passed in about 204 seconds for the whole job. That figure is the evidence that acceleration was working. An emulator falling back to software emulation would not boot and finish a test run in that time.
The two clean runs of this workflow took between 2 minutes 36 seconds and 3 minutes 27 seconds. The faster one was a manual workflow_dispatch re-run with no changes, so the result is repeatable and not a lucky first try.
Most of that time is not the emulator. It is setting up Java and Gradle, downloading SDK pieces, and building. That matters for the next section.
The trap: AVD snapshot caching
The usual advice for speeding this up is to cache the AVD. You boot the emulator once, let it save a snapshot, store ~/.android/avd with actions/cache, and have later runs restore from the snapshot instead of cold-booting. I added it:
- name: AVD cache
uses: actions/cache@v4
id: avd-cache
with:
path: |
~/.android/avd/*
~/.android/adb*
key: avd-29
- name: Create AVD and generate snapshot for caching
if: steps.avd-cache.outputs.cache-hit != 'true'
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 29
arch: x86_64
target: default
force-avd-creation: false
emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
disable-animations: false
script: echo "Generated AVD snapshot for caching."
- name: Run instrumented tests on emulator
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 29
arch: x86_64
target: default
force-avd-creation: false
emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
disable-animations: true
script: gradle connectedCheck
On the run where the cache was restored (run #4), the good parts worked:
- The cache restored.
- The emulator booted from the snapshot.
- The instrumented test passed in about two minutes, with
BUILD SUCCESSFUL.
Then the job did not end. It sat in the action’s Terminate Emulator step for more than twenty minutes, with this in the log:
ERROR | stop: Not implemented
The emulator that had been restored from a snapshot would not shut down cleanly on the runner, and the teardown step has no timeout of its own. So a job whose tests had already passed kept the runner busy until I stopped it.
Why I removed the cache instead of fixing it
I went back to the simple workflow. The reasoning:
- The saving was small. On API 29, a cold boot with KVM is a small slice of the run. The AVD cache does nothing for the Gradle setup, SDK download and build, which are the bulk of the time.
- The cost was large. A hang in teardown is worse than a slow boot. It burns minutes, blocks the queue, and makes a passing run look broken.
- The simple version was already reliable. Two clean green runs, both under three and a half minutes.
For a small app on API 29, AVD caching was a bad trade. It may pay off for newer and heavier system images where cold boot is much slower. I have not measured that, so I am not claiming it.
One thing I would add to any emulator job, and have not tested on this one, is a timeout-minutes value on the job. It would not fix the hang, but it would stop a stuck teardown from running on.
Public or private repo
Emulator jobs use more minutes than a lint job, so the billing rule matters. Standard GitHub-hosted runners are free for public repositories. Private repositories on the free plan get 2,000 minutes a month. I kept this sample public for that reason. At around three minutes a run, a private repo on the free plan would still fit several hundred runs a month.
Running the same thing locally
If you want the same check before pushing, any Linux environment with /dev/kvm works. WSL2 on my Windows laptop exposes it too, so the emulator is accelerated there as well. The CI job is still worth having, because it starts from a clean machine every time.
Summary
- GitHub-hosted Linux runners expose
/dev/kvm. Add the udev rule step, or the emulator cannot use it. - Use an
x86_64image and software GPU rendering. - A cold-boot run of a small app took 2m36s to 3m27s in my runs.
- AVD snapshot caching restored and booted fine, then hung more than twenty minutes in
Terminate EmulatorwithERROR | stop: Not implemented. I removed it. - Measure where the time goes before caching anything. Here it was the build, not the boot.