Android Emulator Tests on GitHub Actions

Android emulator tests need /dev/kvm. Append nested-virtualization.enabled=true to a Linux x86-64 warp- label, then add the documented KVM udev step.

Last verified:

An Android emulator test job on GitHub Actions needs hardware acceleration through /dev/kvm, and on WarpBuild you get it by appending nested-virtualization.enabled=true to a Linux x86-64 warp- label. The device then needs a udev permission rule before the emulator starts, which is standard Android emulator setup on all GitHub and GitHub-compatible runners rather than a WarpBuild-specific workaround.

This page covers the runner classes that expose /dev/kvm and the ones that ignore the label, a complete sharded instrumented test workflow, how to size a runner so the emulator has headroom above the test process, the log signature that tells you acceleration is missing, and the per-minute arithmetic against GitHub-hosted list prices.

Overview

An instrumented Android test boots a full Android system image. On a Linux x86-64 runner that image runs under QEMU, and QEMU reaches useful speed only when the kernel exposes /dev/kvm to it. Without the device the emulator still starts. It switches to software emulation, and a suite that normally finishes well inside the job timeout starts running long enough to hit it.

Two conditions have to hold before an emulator job is accelerated. The runner has to sit on hardware that passes the virtualization extensions into the guest, and the runner user has to be allowed to open /dev/kvm. The first is a runner class question and the second is a permissions question. A job fails quietly when either one is missing.

WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners. Nested virtualization is a Linux x86-64 feature on cloud runners, requested per job through a dynamic label. This is the support matrix from the nested virtualization reference:

Runner classNested virtualizationHow it is enabled
Cloud: Linux x86-64Yesnested-virtualization.enabled=true appended to the warp- label
Cloud: Linux ARM64NoLabel is silently ignored
Cloud: WindowsNoLabel is silently ignored
Cloud: macOSNoLabel is silently ignored
BYOC: AWS (Linux x86-64)YesAuto-enabled when all instance types support it
BYOC: GCP (Linux x86-64)YesAuto-enabled when all instance types support it
BYOC: Azure (Linux x86-64)YesAuto-enabled on supported VM sizes
BYOC: ARM64 and WindowsNoNot available

The rows that cost teams time are the silent ones. Putting nested-virtualization.enabled=true on an ARM64, Windows, or macOS label leaves the job running as usual, with no warning anywhere in the log. The label is ignored, the job runs without /dev/kvm, and the workflow file looks correct while every emulator run falls back to software emulation.

BYOC behaves differently. BYOC runs on AWS, GCP, and Azure, and nested virtualization is enabled automatically when every instance type selected for the runner set supports it. Mixing supporting and non-supporting instance types for availability turns the feature off for the whole set, which keeps behavior consistent across jobs. Keep emulator runner sets on a uniform instance family, and pin that family in code where you can: Terraform support exists for BYOC on AWS.

Every label, size, and per-minute rate lives in the cloud runner catalog, and the full rate card is on the pricing page.

For the rest of an Android pipeline, including assemble and unit test jobs, see Android builds on GitHub Actions. For the short version of the support question, see can GitHub Actions run an Android emulator. Teams running Detox or Espresso inside a JavaScript app should read React Native builds on GitHub Actions for the two-platform split.

Configuration

Dynamic labels attach to the runs-on value with a semicolon:

runs-on: warp-ubuntu-latest-x64-16x;nested-virtualization.enabled=true

WarpBuild then provisions that runner on hardware that supports nested virtualization and exposes the virtualization extensions to the guest, which makes /dev/kvm available.

The permission step comes next. Default device permissions are crw-rw---- root:kvm, which stops the runner user from opening the device, so the workflow adds a udev rule before the emulator starts:

- name: Enable KVM group perms
  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

That step is required on all GitHub and GitHub-compatible runners, and the same snippet appears in the common issues guide as the first thing to check on a slow Android job.

Here is a complete instrumented test workflow with two shards, AVD caching, and a device-creation step that only runs on a cache miss:

name: android-instrumented-tests

on:
  pull_request:
  push:
    branches: [main]

concurrency:
  group: android-emulator-${{ github.ref }}
  cancel-in-progress: true

jobs:
  instrumented-tests:
    runs-on: warp-ubuntu-latest-x64-16x;nested-virtualization.enabled=true
    strategy:
      fail-fast: false
      matrix:
        shard: [0, 1]
    steps:
      - uses: actions/checkout@v4

      - uses: WarpBuilds/setup-java@v5
        with:
          distribution: temurin
          java-version: '17'

      - uses: WarpBuilds/gradle-actions/setup-gradle@v5

      - name: Enable KVM group perms
        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: Confirm the device is writable
        run: |
          ls -l /dev/kvm
          test -w /dev/kvm

      - name: Restore the AVD
        id: avd-cache
        uses: WarpBuilds/cache@v1
        with:
          path: |
            ~/.android/avd/*
            ~/.android/adb*
          key: avd-api34-google_apis-x86_64

      - name: Create the AVD and save a snapshot
        if: steps.avd-cache.outputs.cache-hit != 'true'
        uses: reactivecircus/android-emulator-runner@v2
        with:
          api-level: 34
          target: google_apis
          arch: x86_64
          force-avd-creation: false
          emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
          disable-animations: false
          script: echo "AVD created"

      - name: Run instrumented tests
        uses: reactivecircus/android-emulator-runner@v2
        with:
          api-level: 34
          target: google_apis
          arch: x86_64
          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: >
            ./gradlew connectedDebugAndroidTest --build-cache
            -Pandroid.testInstrumentationRunnerArguments.numShards=2
            -Pandroid.testInstrumentationRunnerArguments.shardIndex=${{ matrix.shard }}

Five details in that file matter.

The label goes on the job that boots the emulator. Assemble and unit test jobs have no KVM requirement, so they run on plain warp- labels. Adding the dynamic label to jobs that do not need it constrains scheduling for nothing.

The device is created once and restored after that. The creation step is guarded by the cache-hit output, so it runs on the first execution of a new key and stays skipped afterwards. The key names the API level, the target, and the architecture, because changing any of the three makes the saved device unusable.

-no-snapshot-save on the test run keeps the cached snapshot stable. Without it every shard writes back a snapshot carrying whatever state the tests left behind, and the cache entry drifts away from the clean device you meant to save.

Cache writes go to WarpBuild cache with no extra configuration. WarpBuild cache is enabled by default on Linux runners, and WarpBuilds/cache@v1 plus WarpBuilds/gradle-actions/setup-gradle@v5 route through it while accepting the same inputs as their upstream counterparts.

The test -w /dev/kvm check converts a silent fallback into a failed step. It costs nothing and it turns the most common misconfiguration on this page into a clear error at the top of the log instead of a long test run at the bottom of it.

Sizing

These are the Linux x86-64 rows from the cloud runner catalog. Ubuntu 22.04 and 26.04 labels exist at the same rates.

Runner labelvCPUMemoryStoragePrice per minute
warp-ubuntu-latest-x64-2x28GB150GB SSD$0.004
warp-ubuntu-latest-x64-4x416GB150GB SSD$0.008
warp-ubuntu-latest-x64-8x832GB150GB SSD$0.016
warp-ubuntu-latest-x64-16x1664GB150GB SSD$0.032
warp-ubuntu-latest-x64-32x32128GB150GB SSD$0.064

An emulator job runs two workloads on one machine. The emulator is a virtual machine holding its own vCPUs and RAM for the guest Android system, while Gradle, adb, and the instrumentation runner work beside it on the host. Sizing that counts only the test process leaves the emulator competing with the compiler for the same cores, and the symptom is usually a timeout inside the test harness rather than a build that reads as slow.

AVD boot is the other fixed cost. A job that creates a device from a cold system image pays for the unpack and the first full boot before the first assertion runs, and that cost repeats on every shard and every matrix leg. Caching the AVD directory turns creation into a restore and lets the emulator come up from a saved snapshot, which is what the two-step pattern in the workflow above buys you. Boot time also sets the floor on how far sharding pays: each extra shard adds another restore and another boot, so splitting a short suite into many shards adds fixed cost per job faster than it removes wall clock.

Emulator layoutLabelReasoning
One emulator, single-module appwarp-ubuntu-latest-x64-4xThe shape used in the docs example. It runs one device, and it leaves little room for the compile and package work that happens before installation.
One emulator beside a Gradle daemonwarp-ubuntu-latest-x64-8x8 vCPU and 32GB cover the device plus connectedDebugAndroidTest, which still assembles the app and the test APK before it installs anything.
Two emulators, or one emulator and a wide module graphwarp-ubuntu-latest-x64-16xTwo devices double the memory floor and the vCPU claim, and a wide module graph wants cores the emulator is not holding.
Four emulators or several API levels in one jobwarp-ubuntu-latest-x64-32x32 vCPU and 128GB keep four devices and the host tooling apart. Price this against a matrix of four 8x jobs before choosing it.

The 2x label has no headroom for this workload and belongs on lint or small library jobs.

Against GitHub-hosted list prices

GitHub publishes per-minute rates on the GitHub Actions minute multipliers reference and runner shapes on its hosted runner reference. Both were checked on 2026-08-13. These are the rows an emulator pipeline touches:

ShapeWarpBuild labelWarpBuild rateGitHub-hosted equivalentGitHub rateLower list price
4 vCPU, 16GB Linuxwarp-ubuntu-latest-x64-4x$0.0084-core Linux larger runner$0.01233 percent
8 vCPU, 32GB Linuxwarp-ubuntu-latest-x64-8x$0.0168-core Linux larger runner$0.02227 percent
16 vCPU, 64GB Linuxwarp-ubuntu-latest-x64-16x$0.03216-core Linux larger runner$0.04224 percent
32 vCPU, 128GB Linuxwarp-ubuntu-latest-x64-32x$0.06432-core Linux larger runner$0.08222 percent

Take a repository that runs the workflow above on every pull request, 500 times a month, with two shards of 18 minutes each on the 16 vCPU label. Durations are held equal on both platforms so the table isolates the rate:

LineMinutesWarpBuild rateWarpBuild costGitHub-hosted rateGitHub-hosted cost
Shard 0 (16 vCPU)18$0.032/min$0.576$0.042/min$0.756
Shard 1 (16 vCPU)18$0.032/min$0.576$0.042/min$0.756
Per pipeline run36$1.152$1.512
Per month at 500 runs18,000$576.00$756.00

The monthly gap is $180.00 with durations held equal. Full rates are on the pricing page.

Bottlenecks

1. Missing hardware acceleration. This is the first thing to rule out, and it has an exact log signature. The emulator action's ProbeKVM check fails, then the action launches the emulator with -accel off, which means pure software emulation. In the job log you will see:

ProbeKVM: This user doesn't have permissions to use KVM (/dev/kvm).
Disabling Linux hardware acceleration.

followed by an emulator command line containing -accel off. Two causes produce that signature. Either the job is on a runner class that has no /dev/kvm, which means the dynamic label was placed on an ARM64, Windows, or macOS label and ignored, or the device exists and the permissions step is missing. Check the runner class first, then the udev step. After both are in place those messages disappear and tests run with hardware acceleration.

2. Cold AVD creation. Creating a device from a downloaded system image, unpacking it, and taking it through a first boot happens before any test runs. Cache ~/.android/avd/* and ~/.android/adb*, keep the key pinned to the API level, target, and architecture, and run the dedicated creation step only on a cache miss.

3. Emulator contention with the build. connectedDebugAndroidTest compiles the app and the test APK, packages both, installs them over adb, and only then runs tests. All of that shares a machine with the running device. When a job is sized for the compile alone, installation and instrumentation slow down together, so size for both workloads and confirm the choice against measured utilization.

4. Boot completion and adb behavior. A device that reports ready before the package manager has settled produces install failures that read as flaky tests. Run the emulator with animations disabled, no window, no audio, and no camera, as in the workflow above, and let the emulator action own the boot wait rather than adding a manual sleep. Failures that survive that treatment belong in the flake triage flow described in finding and fixing flaky GitHub Actions jobs.

When a job is still slow after those four, measure instead of guessing. WarpBuild's CI observability correlates OpenTelemetry system metrics from the runner agent with GitHub Actions job logs, which shows whether an emulator job is CPU-bound, memory-bound, or waiting on the network. The Action Debugger pauses a workflow and opens an SSH session on the runner, which is the fastest way to check /dev/kvm permissions, read the live emulator command line, and inspect adb state on the machine that is actually running the job.

Proof

The migration cost is the label plus the dynamic suffix. Linux x86-64 runner images carry the same tooling as GitHub-hosted images, so the Android SDK, platform tools, Gradle, and reactivecircus/android-emulator-runner behave as they do today.

FAQ

Which WarpBuild runners support nested virtualization?

Cloud Linux x86-64 runners support it through the nested-virtualization.enabled=true label. Cloud ARM64, Windows, and macOS runners do not. On BYOC, AWS, GCP, and Azure enable it automatically when every instance type selected for the runner set supports it.

My emulator job has the nested virtualization label and is still slow. What do I check?

Read the job log for ProbeKVM. The signature of a missing device is ProbeKVM reporting that the user has no permission to use /dev/kvm, followed by a line disabling Linux hardware acceleration and an emulator command line containing -accel off. Check the runner class first, then the KVM permissions step.

What size runner should an Android emulator job use?

Start at warp-ubuntu-latest-x64-8x for one emulator beside a Gradle daemon, and move to warp-ubuntu-latest-x64-16x when the module graph is wide or two devices share the runner. The emulator claims its own vCPUs and RAM, so size above what the test process alone needs.

What does an emulator test job cost on WarpBuild runners?

warp-ubuntu-latest-x64-8x is $0.016 per minute and warp-ubuntu-latest-x64-16x is $0.032 per minute.

Start with $10 in free credits

Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.