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
Build
job - shuffling test names in
AllTests.txt
file - 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.