Guides

Appium in GitHub Actions without an emulator

Run an existing Appium or WebdriverIO suite on a hosted Android runner from GitHub Actions, with secrets, JUnit reporting and guaranteed teardown.

A GitHub-hosted runner has no Android emulator and no hardware acceleration. SpawnDroid supplies the device and the Appium server; the workflow supplies the build and the test command.

The short version

Use the official action when the workflow owns the session:

- uses: actions/checkout@v4
- uses: actions/setup-node@v4
  with: { node-version: 22, cache: pnpm }
- run: pnpm install --frozen-lockfile

- name: Run UI tests on Android
  uses: kl3jvi/spawndroid/actions/android-test@v1
  with:
    api-key: ${{ secrets.SPAWNDROID_API_KEY }}
    app-path: app/build/outputs/apk/debug/app-debug.apk
    profile: android-14-phone
    command: pnpm test
    report-path: reports/junit.xml

The action uploads the APK, starts a runner, waits for the device, runs your command with SPAWNDROID_WD_URL, SPAWNDROID_UDID, SPAWNDROID_APP_PACKAGE and SPAWNDROID_SESSION_ID in the environment, attaches the JUnit XML, reports pass or fail, and always tears the device down.

The explicit four-call flow

Use this when you already manage the Appium session yourself:

name: UI tests
on: pull_request
jobs:
  android:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Build APK
        run: ./gradlew assembleDebug
      - name: Start device
        id: session
        env:
          SPAWNDROID_API_KEY: ${{ secrets.SPAWNDROID_API_KEY }}
        run: |
          APK_ID=$(curl -sS -X POST https://app.spawndroid.io/api/apps \
            -H "Authorization: Bearer $SPAWNDROID_API_KEY" \
            -F file=@app/build/outputs/apk/debug/app-debug.apk | jq -r '.app.slug')
          RESPONSE=$(curl -sS -X POST https://app.spawndroid.io/api/v1/sessions \
            -H "Authorization: Bearer $SPAWNDROID_API_KEY" \
            -H "Content-Type: application/json" \
            -d "{\"artifactId\":\"$APK_ID\"}")
          echo "session_id=$(echo "$RESPONSE" | jq -r '.sessionId')" >> "$GITHUB_OUTPUT"
          echo "wd_url=$(echo "$RESPONSE" | jq -r '.webdriverUrl')" >> "$GITHUB_OUTPUT"
          echo "udid=$(echo "$RESPONSE" | jq -r '.device.serial')" >> "$GITHUB_OUTPUT"
          echo "package=$(echo "$RESPONSE" | jq -r '.app.package')" >> "$GITHUB_OUTPUT"
      - name: Wait for ready
        env:
          SPAWNDROID_API_KEY: ${{ secrets.SPAWNDROID_API_KEY }}
        run: |
          for attempt in $(seq 1 60); do
            STATE=$(curl -sS https://app.spawndroid.io/api/v1/sessions/${{ steps.session.outputs.session_id }} \
              -H "Authorization: Bearer $SPAWNDROID_API_KEY" | jq -r '.state')
            [ "$STATE" = "ready" ] && exit 0
            [ "$STATE" = "failed" ] && exit 1
            sleep 5
          done
          exit 1
      - name: Run tests
        env:
          SPAWNDROID_WD_URL: ${{ steps.session.outputs.wd_url }}
          SPAWNDROID_UDID: ${{ steps.session.outputs.udid }}
          SPAWNDROID_APP_PACKAGE: ${{ steps.session.outputs.package }}
        run: pnpm test
      - name: Report result
        if: always()
        env:
          SPAWNDROID_API_KEY: ${{ secrets.SPAWNDROID_API_KEY }}
        run: |
          RESULT=${{ job.status == 'success' && 'passed' || 'failed' }}
          curl -sS -X DELETE https://app.spawndroid.io/api/v1/sessions/${{ steps.session.outputs.session_id }} \
            -H "Authorization: Bearer $SPAWNDROID_API_KEY" \
            -H "Content-Type: application/json" \
            -d "{\"result\":\"$RESULT\"}"

Secrets and keys

Store the key as a GitHub Actions secret. Give the pipeline its own key with upload scope: keys are workspace-scoped, not per project, so a key shared between repositories gives every workflow the same access. Revoke it independently when a pipeline is retired. Never echo the key; GitHub masks it, but a set -x shell can still leak it into a log.

Reporting and artifacts

report-path accepts JUnit XML from Jest (jest-junit), pytest (--junitxml), Gradle/Espresso, Maestro (--format junit) and most other runners. The run page shows total/failed tests and keeps the raw XML. Personal and above also retain the final logcat and the screen recording.

Run it on SpawnDroid

Hobby includes one parallel runner and 200 device-minutes a month, no card. Start free or read the CI tests reference for the full API.

Was this page helpful?

On this page