Optimizing Android Test Runs: Parsing Test Names and Implementing Orchestration
  Intro
Today I’d like to talk about test orchestration on Android. And about parsing test names in particular, as one of the main pitfalls along the way.
Imagine you have hundreds or thousands of tests that take more than an hour in a raw. You better start thinking how to speed them up or run them in parallel.
There are multiple options to parallel the tests:
- Parallelization at the CI level: when you split the tests and run them on different jobs (e.g. via GitHub Actions matrix)
 - Parallelization at the test runner level: when you split the tests and run them on different devices as part of a single CI job (some test runners are smart and offer this out of the box, some are dumb and should be executed in different threads)
 - Parallelization at the test framework level: really depends on the framework, but usually this means splitting the tests into suites
 - <…>
 
Thus, most options require a list of test names or test classes in order to run them in parallel. But how can we parse the tests, get their names, and not miss a single one? On Android, Dex Test Parser comes to the rescue.
All the code examples below are presented using fastlane lanes for the simplicity. Feel free to extract the main idea and translate it to your favourite language/DSL if required.
Precondition
I assume you already have an apk with your tests and an apk with your test target. If not, you can do something like this:
./gradlew assembleDebugAndroidTest assembleDebug
or using fastlane:
lane :build do
  gradle(tasks: ['assembleDebugAndroidTest', 'assembleDebug'])
end
Install test parser
First of all, let’s install dex-test-parser. According to the docs, this can be done using Maven, Gradle or manually. I choose the latter option:
wget -O test-parser.jar 'https://linkedin.jfrog.io/artifactory/open-source/com/linkedin/dextestparser/parser/2.2.1/parser-2.2.1-all.jar'
or using fastlane:
lane :install_test_parser do
  version = '2.2.1'
  output = 'test-parser.jar'
  url = "https://linkedin.jfrog.io/artifactory/open-source/com/linkedin/dextestparser/parser/#{version}/parser-#{version}-all.jar"
  sh("wget -O #{output} '#{url}' 2>/dev/null") unless File.exist?(output)
end
Parse the tests
Now let’s parse the tests using dex-test-parser:
java -jar test-parser.jar "${path_to_test_apk_path}" ./
This produces an AllTests.txt file that contains all absolute test paths.
Batch the tests
To batch the tests we need to know the total amount of batches and what is the current batch number:
lane :batch_tests do |options|
  current_batch = []
  if options[:batch_number] && options[:batch_count]
    sh("java -jar test-parser.jar #{options[:test_apk_path]} ./")
    test_names = File.read('AllTests.txt').split
    current_batch = test_names.each_slice((test_names.size.to_f / options[:batch_count].to_i).ceil).to_a[options[:batch_number].to_i]
    UI.success("Contents of the current batch: #{current_batch}")
  end
  current_batch
end
For instance, we have 3 batches in total and we want to get the tests for the first batch:
bundle exec fastlane batch_tests batch_count:3 batch_number:0 test_apk_path:"path/to/test.apk"
Keep in mind that the batches will always be identical and equally divided. If new tests are added, they will be distributed proportionally between the batches at the upcoming run.
Service the tests
To be able to run the tests with pre-built apks we have to install test services:
lane :install_test_services do
  device_api_level = sh('adb shell getprop ro.build.version.sdk').strip.to_i
  force_queryable = device_api_level >= 30 ? '--force-queryable' : ''
  version = '1.5.0'
  output = 'test-services.apk'
  url = "https://dl.google.com/dl/android/maven2/androidx/test/services/test-services/#{version}/test-services-#{version}.apk"
  sh("wget -O #{output} '#{url}' 2>/dev/null") unless File.exist?(output)
  sh("adb install #{force_queryable} -r #{output}")
  output = 'orchestrator.apk'
  url = "https://dl.google.com/dl/android/maven2/androidx/test/services/orchestrator/#{version}/orchestrator-#{version}.apk"
  sh("wget -O #{output} '#{url}' 2>/dev/null") unless File.exist?(output)
  sh("adb install #{force_queryable} -r #{output}")
end
Run the tests
Let’s sum it up and add the cherry on top:
lane :test do
  install_test_services
  install_test_parser
  apk_path = "path/to/app.apk"
  test_apk_path = "path/to/test.apk"
  sh("adb install -r #{apk_path}")
  sh("adb install -r #{test_apk_path}")
  current_test_batch = batch_tests(
    batch_number: options[:batch_number],
    batch_count: options[:batch_count],
    test_apk_path: test_apk_path
  )
  app_package_name = 'com.example'
  test_package_name = "#{app_package_name}.test"
  test_runner_package_name = 'androidx.test.runner.AndroidJUnitRunner'
  test_orchestrator_package_name = 'androidx.test.orchestrator/.AndroidTestOrchestrator'
  androidx_test_services_path = sh('adb shell pm path androidx.test.services').strip
  result = sh(
    "adb shell 'CLASSPATH=#{androidx_test_services_path}' " \
    'app_process / androidx.test.services.shellexecutor.ShellMain am instrument -w '\
    '-e clearPackageData true ' \
    "-e targetInstrumentation #{test_package_name}/#{test_runner_package_name} " \
    "-e class #{current_test_batch.join(',')} #{test_orchestrator_package_name}"
  )
  UI.user_error!('Tests have failed!') if result.include?('Failures')
end
Showcase
On GitHub Actions it would look like this:
name: CI
on:
  push:
jobs:
  build:
    name: Build
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/[email protected]
      - uses: ruby/setup-ruby@v1
        with:
          ruby-version: 3.1
          bundler-cache: true
      - uses: actions/setup-java@v4
        with:
          distribution: adopt
          java-version: 17
      - name: Enable KVM group permissions
        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
      - run: bundle exec fastlane build
      - uses: actions/[email protected]
        with:
          name: apks
          path: |
            ./**/path/to/app.apk
            ./**/path/to/test.apk
  test:
    name: Test
    runs-on: ubuntu-24.04
    needs: build
    strategy:
      matrix:
        include:
          - batch_number: 0
          - batch_number: 1
          - batch_number: 2
    steps:
      - uses: actions/[email protected]
      - uses: ruby/setup-ruby@v1
        with:
          ruby-version: 3.1
          bundler-cache: true
      - uses: actions/setup-java@v4
        with:
          distribution: adopt
          java-version: 17
      - uses: actions/[email protected]
        with:
          name: apks
      - name: Enable KVM group permissions
        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
      - uses: reactivecircus/android-emulator-runner@v2
        with:
          api-level: 33
          disable-animations: true
          profile: pixel
          arch : x86_64
          emulator-options: -no-snapshot-save -no-window -no-audio -no-boot-anim -gpu swiftshader_indirect -camera-back none -camera-front none
          script: bundle exec fastlane test batch_count:$ batch_number:$
      - uses: actions/[email protected]
        if: failure()
        with:
          name: report
          path: ./**/build/reports/androidTests/*
If you want to randomize things a bit, consider:
- moving the tests’ parsing step to the 
Buildjob - shuffling test names in 
AllTests.txtfile - uploading it as an artifact alongside the apks
 - downloading it as part of the matrix step
 
After that, it should be pretty much the same as the initial plan.