
Qa Testing Android
- 291 installs
- 73 repo stars
- Updated July 13, 2026
- vasilyu1983/ai-agents-public
qa-testing-android is a Claude Code skill that helps developers plan and execute Android application testing and QA workflows when validating mobile builds before release.
About
qa-testing-android is a mobile QA skill from vasilyu1983/ai-agents-public that steers coding agents through Android testing tasks such as structuring test plans, choosing appropriate test layers, and applying QA practices for Kotlin or Java Android projects. Developers reach for it when they need agent assistance writing or organizing instrumentation tests, UI tests, regression checks, or release verification steps without ad hoc guesswork about Android-specific tooling and conventions. The skill fits teams shipping Android apps who want consistent QA guidance embedded in their agent workflow rather than scattered checklist notes. Invoke it when Android build verification, test case design, or mobile defect reproduction appears in the task, especially alongside CI pipelines that must catch regressions before Play Store submission.
- qa-testing-android
- Testing & QA
- AI-coding skill
Qa Testing Android by the numbers
- 291 all-time installs (skills.sh)
- +12 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #717 of 2,153 Testing & QA skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/vasilyu1983/ai-agents-public --skill qa-testing-androidAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 291 |
|---|---|
| repo stars | ★ 73 |
| Last updated | July 13, 2026 |
| Repository | vasilyu1983/ai-agents-public ↗ |
How do you test Android apps before release?
Helps with testing & qa tasks.
Who is it for?
Android developers and QA engineers validating mobile builds who want agent-guided test planning and Android-specific QA workflows.
Skip if: iOS-only projects, backend API testing with no mobile surface, or teams already standardized on a single proprietary test framework without Android context.
When should I use this skill?
The user asks about Android testing, instrumentation tests, Espresso/UI Automator, mobile regression QA, or pre-release Android verification.
What you get
Android test plans, instrumentation or UI test scaffolding, and QA checklists aligned to mobile release gates.
- Android QA test plan
- instrumentation or UI test scaffolding
Files
QA Testing (Android)
Android testing automation with Espresso, UIAutomator, and Compose Testing.
Core References: Android Testing Docs, Espresso, Compose Testing
Quick Reference
| Task | Command |
|---|---|
| List emulators | emulator -list-avds |
| Start emulator | emulator @<avd_name> |
| List devices | adb devices |
| Install APK | adb install -r <path-to-apk> |
| Run unit tests | ./gradlew test |
| Run instrumented tests (connected) | ./gradlew connectedAndroidTest |
| Run instrumented tests (GMD) | ./gradlew <device><variant>AndroidTest |
| List GMD tasks | `./gradlew tasks --all |
| Clear app data | adb shell pm clear <applicationId> |
Quick Start (2026 Defaults)
- Prefer Gradle Managed Devices (GMD) + ATD images for CI; use
connectedAndroidTestfor local ad-hoc runs. - Enable test isolation via AndroidX Test Orchestrator for instrumented tests.
- Disable animations via Gradle
testOptions(preferred) instead of per-runner ADB steps. - Keep selectors stable:
withId()(Views),testTag(Compose), resource-id/content-desc (UIAutomator).
Recommended Gradle defaults for stable instrumented tests (version catalog names vary by project):
android {
testOptions {
animationsDisabled = true
execution = "ANDROIDX_TEST_ORCHESTRATOR"
}
}
dependencies {
androidTestUtil(libs.androidx.test.orchestrator)
}When to Use
- Debug or stabilize flaky Android UI tests
- Add Espresso tests for View-based UIs
- Add Compose UI tests for composables
- Add UIAutomator tests for system UI or cross-app flows
- Set up an Android test gate in CI
Inputs to Gather
- UI stack: Views, Compose, or mixed
- Test layer: unit, Robolectric, instrumented UI, UIAutomator/system
- CI target: PR gate vs nightly vs release; emulator vs device farm
- Device matrix: min/target API, form factors, locales (if relevant)
- Flake symptoms: timeouts, missing nodes, idling/sync, device-only issues
- App seams: DI hooks for fakes, feature flags, test accounts/test data
Testing Layers
| Layer | Framework | Scope |
|---|---|---|
| Unit | JUnit + Mockito | JVM, no Android |
| Unit (Android) | Robolectric | JVM, simulated |
| UI (Views) | Espresso | Instrumented |
| UI (Compose) | Compose Testing | Instrumented |
| System | UIAutomator | Cross-app |
Core Principles (Stability)
Device Matrix
- Default: emulators for PR gates; real devices for release
- Cover: min supported API level, target API level, plus tablet/foldable if supported
Flake Control
- Prefer Gradle
testOptions { animationsDisabled = true }for instrumented tests - Use AndroidX Test Orchestrator to isolate state and recover from crashes
- Use IdlingResources / Compose idling +
waitUntilinstead of sleeps - Mock network with
MockWebServer(or your DI fake) and avoid live backends - Reset app state per test (test account/data, storage, feature flags)
Writing Tests
- Espresso (Views): open
references/espresso-patterns.md - Compose: open
references/compose-testing.md - UIAutomator (system/cross-app): open
references/uiautomator.md
Workflows
Add a New UI Test (Instrumented)
- Pick framework: Espresso (Views) vs Compose Testing vs UIAutomator boundary.
- Add stable selectors: View
id, ComposeModifier.testTag, systemresource-id/content-desc. - Control externals: fake/mock network + deterministic test data.
- Add waits: IdlingResources / Compose idling +
waitUntil(avoid sleeps). - Run locally:
./gradlew connectedAndroidTest(or a single test via runner args).
Diagnose a Flaky Instrumented Test
- Confirm reproduces: run the test 10x; isolate to one device/API if needed.
- Remove nondeterminism: network, clock/timezone, locale, feature flags, animations.
- Replace sleeps with idling/explicit waits; validate your IdlingResource actually idles.
- Capture artifacts: logcat + screenshot + screen recording for failures.
- If still flaky, isolate app state (orchestrator + clear data) and bisect the interaction steps.
Add a CI Gate (Preferred: GMD)
- Configure GMD + ATD images (see
references/gradle-managed-devices.md). - Run PR gate on a small matrix; expand via groups for nightly/release.
- Ensure artifacts upload on failure:
**/build/reports/androidTests/, screenshots/logcat.
ADB Commands (Triage)
# Screenshot
adb exec-out screencap -p > screenshot.png
# Screen recording
adb shell screenrecord /sdcard/demo.mp4CI Integration
Preferred: Gradle Managed Devices (GMD). See references/gradle-managed-devices.md.
# .github/workflows/android.yml
name: Android CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- uses: gradle/actions/setup-gradle@v3
- run: ./gradlew test pixel6api34DebugAndroidTestNavigating References
The reference guides are intentionally large; search within them instead of loading everything:
rg -n \"^## \" frameworks/shared-skills/skills/qa-testing-android/references/compose-testing.mdrg -n \"Idling|waitUntil|Synchronization\" frameworks/shared-skills/skills/qa-testing-android/references/compose-testing.mdrg -n \"RecyclerView|Intents\" frameworks/shared-skills/skills/qa-testing-android/references/espresso-patterns.md
Do / Avoid
Do
- Prefer orchestrator + per-test isolation for instrumented tests
- Use IdlingResources /
waitUntilfor async waits - Use Robot/Page Object patterns for readability and reuse
- Run a small device matrix on PRs; expand on nightly/release
Avoid
Thread.sleep()for synchronization- Tests depending on live network/backends
- Flaky selectors (localized text, position-only selectors)
Resources
| Resource | Purpose |
|---|---|
| references/espresso-patterns.md | Espresso matchers, actions |
| references/compose-testing.md | Compose testing guide |
| references/uiautomator.md | UIAutomator patterns (system UI) |
| references/gradle-managed-devices.md | Managed Devices for CI |
| references/screenshot-testing.md | Visual regression for Android |
| references/test-orchestrator-patterns.md | AndroidX Test Orchestrator patterns |
| references/android-ci-optimization.md | CI pipeline optimization |
| data/sources.json | Documentation links |
Templates
| Template | Purpose |
|---|---|
| assets/template-android-test-checklist.md | Stability checklist |
Related Skills
| Skill | Purpose |
|---|---|
| software-mobile | Android development |
| qa-testing-strategy | Test strategy |
| qa-testing-mobile | Cross-platform mobile |
Fact-Checking
- Use web search/web fetch to verify current external facts, versions, pricing, deadlines, regulations, or platform behavior before final answers.
- Prefer primary sources; report source links and dates for volatile information.
- If web access is unavailable, state the limitation and mark guidance as unverified.
Android UI Test Stability Checklist
Pre-flight checklist for stable, deterministic Android UI tests.
Environment Setup
- REQUIRED: animations disabled (prefer Gradle
testOptions { animationsDisabled = true }) - REQUIRED: Test Orchestrator enabled for isolation (instrumented tests)
- REQUIRED: sufficient emulator resources (RAM, storage)
- REQUIRED: network controlled or mocked for deterministic tests
- CONSIDER: emulator GPU mode
swiftshader_indirecton CI if rendering issues
Test Structure
- REQUIRED: each test is independent (no ordering dependencies)
- REQUIRED: app state reset per test (account, storage, flags)
- REQUIRED: test data created fresh per test
- REQUIRED: no shared mutable state between tests
- REQUIRED: teardown/cleanup in
@After
Synchronization
- AVOID:
Thread.sleep() - REQUIRED: IdlingResources (Espresso) or
waitUntil(Compose) for async operations - REQUIRED: network mocked (MockWebServer / DI fake) or synchronized via idling
- REQUIRED: database operations complete before assertions
- REQUIRED: animations disabled or deterministically awaited
Element Selection
- REQUIRED: Views use resource IDs (
withId()), not text - REQUIRED: Compose uses
testTagfor stable selection - CONSIDER: content descriptions for accessibility and some system UI elements
- AVOID: locale-dependent text matching for primary selectors
- REQUIRED: selectors remain stable across UI refactors
Assertions
- REQUIRED: wait for element existence before interaction
- REQUIRED: use
waitUntilfor dynamic content - REQUIRED: check visibility before click actions
- REQUIRED: verify state, not just existence
- REQUIRED: meaningful failure messages
Device Matrix
- REQUIRED: tested on min supported API level
- REQUIRED: tested on target API level
- CONSIDER: small screen phone + large screen tablet/foldable (if supported)
- CONSIDER: different locales (if localization is user-facing)
CI Configuration
- REQUIRED: emulator boot completion gate before tests run
- REQUIRED: screenshots captured on failure (where feasible)
- REQUIRED: logcat captured on failure
- CONSIDER: retry policy for known transient failures (max 1-2)
Flake Prevention
- REQUIRED: test runs 10x locally without failure before merging
- AVOID: time-dependent assertions
- REQUIRED: no live network-dependent data (use mocks)
- REQUIRED: no reliance on device state outside the test
- REQUIRED: scroll to element before interaction
Quick Commands
# Clear app data before test
adb shell pm clear com.example.app
# Run with orchestrator
./gradlew connectedAndroidTest \
-Pandroid.testInstrumentationRunnerArguments.clearPackageData=true
# Capture logcat on failure
adb logcat -d > test_failure.log
# Screenshot on failure
adb exec-out screencap -p > failure.pngCommon Flake Causes
| Symptom | Likely Cause | Fix |
|---|---|---|
| Element not found | Animation in progress | Disable animations or use IdlingResource |
| Timeout | Network delay | Mock network with MockWebServer |
| Wrong element clicked | Scroll position | Call scrollTo() before click |
| State from previous test | Shared state | Reset app data in @Before |
| Works locally, fails on CI | Animation timing | Add explicit waits |
Sign-Off
| Check | Owner | Date |
|---|---|---|
| 10x local runs pass | ||
| CI pipeline verified | ||
| Device matrix covered | ||
| Flake rate <5% |
{
"metadata": {
"skill": "qa-testing-android",
"updated": "2026-01-26",
"version": "1.1",
"total_sources": 13,
"description": "Primary references for Android testing with Espresso, UIAutomator, and Compose Testing."
},
"categories": {
"google_official_docs": [
{
"name": "Android Testing Documentation",
"url": "https://developer.android.com/training/testing",
"description": "Official Android testing fundamentals and best practices.",
"add_as_web_search": true,
"optional": false
},
{
"name": "Espresso Documentation",
"url": "https://developer.android.com/training/testing/espresso",
"description": "Official Espresso UI testing framework docs.",
"add_as_web_search": true,
"optional": false
},
{
"name": "Espresso Cheat Sheet",
"url": "https://developer.android.com/training/testing/espresso/cheat-sheet",
"description": "Quick reference for Espresso matchers, actions, and assertions.",
"add_as_web_search": true,
"optional": false
},
{
"name": "UIAutomator Documentation",
"url": "https://developer.android.com/training/testing/other-components/ui-automator",
"description": "Cross-app and system UI testing framework.",
"add_as_web_search": true,
"optional": false
},
{
"name": "Compose Testing Documentation",
"url": "https://developer.android.com/develop/ui/compose/testing",
"description": "Official Jetpack Compose testing guide.",
"add_as_web_search": true,
"optional": false
},
{
"name": "Compose Testing Codelab",
"url": "https://developer.android.com/codelabs/jetpack-compose-testing",
"description": "Hands-on tutorial for Compose UI testing.",
"add_as_web_search": true,
"optional": false
},
{
"name": "Android Test Orchestrator",
"url": "https://developer.android.com/training/testing/instrumented-tests/androidx-test-libraries/runner#orchestrator",
"description": "Test isolation and crash recovery for instrumented tests.",
"add_as_web_search": true,
"optional": false
},
{
"name": "Gradle Managed Devices",
"url": "https://developer.android.com/studio/test/gradle-managed-devices",
"description": "Provision and run emulator-based tests via Gradle for CI/CD.",
"add_as_web_search": true,
"optional": false
},
{
"name": "Macrobenchmark",
"url": "https://developer.android.com/studio/profile/macrobenchmark",
"description": "Performance testing for startup and critical user journeys.",
"add_as_web_search": true,
"optional": true
},
{
"name": "Baseline Profiles",
"url": "https://developer.android.com/topic/performance/baselineprofiles",
"description": "Improve runtime performance and startup with baseline profiles.",
"add_as_web_search": true,
"optional": true
}
],
"community_resources": [
{
"name": "Test Automation University: Espresso",
"url": "https://testautomationu.applitools.com/espresso-mobile-testing-tutorial/",
"description": "Free Espresso testing course with hands-on exercises.",
"add_as_web_search": true,
"optional": true
},
{
"name": "BrowserStack Espresso Guide",
"url": "https://www.browserstack.com/guide/espresso-android-testing",
"description": "Comprehensive Espresso tutorial with real device testing.",
"add_as_web_search": true,
"optional": true
},
{
"name": "HeadSpin Espresso Guide",
"url": "https://www.headspin.io/blog/a-comprehensive-guide-to-android-ui-testing-with-espresso",
"description": "In-depth Espresso patterns and best practices.",
"add_as_web_search": true,
"optional": true
}
]
}
}
Android CI Optimization
Build and test pipeline optimization for Android projects in continuous integration.
Contents
- Build Caching Strategies
- Test Sharding Across CI Nodes
- ATD vs Full Emulator Images
- Gradle Managed Devices in CI
- Emulator Snapshot Caching
- Parallel Test Execution
- Flaky Test Quarantine
- Test Impact Analysis
- CI Provider Comparison
- Artifact Management
- Build Time Budgets
- Related Resources
---
Build Caching Strategies
Gradle Build Cache
// settings.gradle.kts
buildCache {
local {
isEnabled = true
directory = File(rootDir, ".gradle/build-cache")
}
remote<HttpBuildCache> {
url = uri("https://cache.example.com/cache/")
isPush = System.getenv("CI") != null
credentials {
username = System.getenv("CACHE_USER") ?: ""
password = System.getenv("CACHE_PASS") ?: ""
}
}
}Dependency Caching
# GitHub Actions: cache Gradle dependencies
- name: Cache Gradle
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
~/.android/build-cache
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/libs.versions.toml') }}
restore-keys: |
gradle-${{ runner.os }}-Configuration Cache
# gradle.properties
org.gradle.configuration-cache=true
org.gradle.configuration-cache.problems=warn
# Parallel execution
org.gradle.parallel=true
org.gradle.workers.max=4
# Daemon tuning
org.gradle.jvmargs=-Xmx4g -XX:+HeapDumpOnOutOfMemoryError
org.gradle.daemon=trueCache Hit Rate Monitoring
# Check cache statistics after build
./gradlew assembleDebug --scan
# Build scan shows cache hit/miss per task
# Quick local check
./gradlew assembleDebug --build-cache --info 2>&1 | grep -c "FROM-CACHE"---
Test Sharding Across CI Nodes
Strategy Comparison
| Strategy | Distribution Method | Pros | Cons |
|---|---|---|---|
| Count-based | Equal test count per shard | Simple | Uneven execution time |
| Time-based | Balance by historical time | Optimal parallelism | Needs timing data |
| Module-based | One module per shard | Natural boundaries | Uneven if modules differ |
| Annotation-based | By test category | Logical grouping | Manual maintenance |
GitHub Actions Matrix Sharding
jobs:
instrumented-tests:
strategy:
fail-fast: false
matrix:
shard: [0, 1, 2, 3]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run shard ${{ matrix.shard }}
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 34
target: aosp_atd
arch: x86_64
script: |
adb shell am instrument -w \
-e numShards 4 \
-e shardIndex ${{ matrix.shard }} \
-e clearPackageData true \
androidx.test.orchestrator/androidx.test.orchestrator.AndroidTestOrchestrator
- name: Upload results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results-shard-${{ matrix.shard }}
path: '**/build/outputs/androidTest-results/'
merge-results:
needs: instrumented-tests
runs-on: ubuntu-latest
steps:
- name: Download all shard results
uses: actions/download-artifact@v4
with:
pattern: test-results-shard-*
merge-multiple: true
- name: Merge and publish
run: |
# Merge JUnit XML files
npx junit-merge -d ./test-results -o merged-results.xml---
ATD vs Full Emulator Images
Automated Test Devices (ATD) are stripped-down emulator images optimized for testing.
| Feature | ATD (aosp-atd) | Full (google_apis) |
|---|---|---|
| Boot time | ~15 seconds | ~45-90 seconds |
| Image size | ~600 MB | ~1.2 GB |
| Google Play Services | No | Yes (google_apis_playstore) |
| System apps | Minimal | Full set |
| Camera, sensors | Stubbed | Emulated |
| Best for | Unit + integration tests | E2E, Google Sign-In |
Selecting ATD in Gradle
android {
testOptions {
managedDevices {
localDevices {
create("pixel6api34atd") {
device = "Pixel 6"
apiLevel = 34
systemImageSource = "aosp-atd" // ATD image
}
create("pixel6api34full") {
device = "Pixel 6"
apiLevel = 34
systemImageSource = "google_apis" // Full image
}
}
}
}
}---
Gradle Managed Devices in CI
Gradle Managed Devices (GMD) automate emulator lifecycle -- download, create, boot, test, shutdown.
CI Configuration
# GitHub Actions with GMD
jobs:
android-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: '17'
- 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: Cache emulator images
uses: actions/cache@v4
with:
path: ~/.android/avd
key: avd-${{ hashFiles('**/build.gradle.kts') }}
- name: Run managed device tests
run: ./gradlew pixel6api34atdDebugAndroidTest
- name: Upload results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results
path: '**/build/outputs/managed_device_android_test_additional_output/'GMD Groups for Multi-Device Testing
android {
testOptions {
managedDevices {
localDevices {
create("pixel6api34") {
device = "Pixel 6"
apiLevel = 34
systemImageSource = "aosp-atd"
}
create("pixelTabletApi34") {
device = "Pixel Tablet"
apiLevel = 34
systemImageSource = "aosp-atd"
}
}
groups {
create("allDevices") {
targetDevices.addAll(
devices["pixel6api34"],
devices["pixelTabletApi34"],
)
}
}
}
}
}# Run on all devices in the group
./gradlew allDevicesGroupDebugAndroidTest---
Emulator Snapshot Caching
Snapshot Strategy
# 1. Boot emulator and wait for ready
emulator -avd Pixel_6_API_34 -no-window -no-audio -gpu swiftshader_indirect &
adb wait-for-device
adb shell getprop sys.boot_completed | grep 1
# 2. Disable animations
adb shell settings put global window_animation_scale 0
adb shell settings put global transition_animation_scale 0
adb shell settings put global animator_duration_scale 0
# 3. Save snapshot
adb emu avd snapshot save ci-ready
# 4. Kill emulator
adb emu kill
# 5. On subsequent CI runs, load snapshot
emulator -avd Pixel_6_API_34 -no-window -no-audio -snapshot ci-ready -no-snapshot-save &CI Cache for Snapshots
- name: Cache AVD snapshot
uses: actions/cache@v4
with:
path: |
~/.android/avd/Pixel_6_API_34.avd/snapshots/ci-ready
key: avd-snapshot-pixel6-api34-${{ hashFiles('scripts/setup-emulator.sh') }}---
Parallel Test Execution
Gradle Parallel Testing
android {
testOptions {
// Run test classes in parallel within a single device
execution = "ANDROIDX_TEST_ORCHESTRATOR"
// For unit tests
unitTests.all {
it.maxParallelForks = Runtime.getRuntime().availableProcessors()
}
}
}Multi-Emulator Parallel Execution
#!/bin/bash
# Launch multiple emulators and run shards in parallel
SHARD_COUNT=4
for i in $(seq 0 $((SHARD_COUNT - 1))); do
PORT=$((5554 + i * 2))
emulator -avd Pixel_6_API_34 -port "$PORT" -no-window -no-audio &
done
# Wait for all emulators
for i in $(seq 0 $((SHARD_COUNT - 1))); do
PORT=$((5554 + i * 2))
adb -s "emulator-$PORT" wait-for-device
adb -s "emulator-$PORT" shell 'while [ "$(getprop sys.boot_completed)" != "1" ]; do sleep 1; done'
done
# Run shards in parallel
for i in $(seq 0 $((SHARD_COUNT - 1))); do
PORT=$((5554 + i * 2))
adb -s "emulator-$PORT" shell am instrument -w \
-e numShards "$SHARD_COUNT" \
-e shardIndex "$i" \
com.example.app.test/androidx.test.runner.AndroidJUnitRunner &
done
wait---
Flaky Test Quarantine
Detection
// Custom test rule that retries flaky tests
class RetryRule(private val maxRetries: Int = 2) : TestRule {
override fun apply(base: Statement, description: Description): Statement {
return object : Statement() {
override fun evaluate() {
var lastException: Throwable? = null
for (attempt in 0..maxRetries) {
try {
base.evaluate()
return // success
} catch (e: Throwable) {
lastException = e
if (attempt < maxRetries) {
println("Test ${description.methodName} failed, retrying (${attempt + 1}/$maxRetries)")
}
}
}
throw lastException!!
}
}
}
}Quarantine Strategy
// Mark flaky tests for quarantine
@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class Quarantined(val reason: String, val ticket: String)
// Usage
@Quarantined(reason = "Flaky on API 34", ticket = "JIRA-1234")
@Test
fun intermittent_animation_test() { /* ... */ }# Run excluding quarantined tests (main CI)
adb shell am instrument -w \
-e notAnnotation com.example.app.Quarantined \
com.example.app.test/androidx.test.runner.AndroidJUnitRunner
# Separate job: run only quarantined tests (non-blocking)
adb shell am instrument -w \
-e annotation com.example.app.Quarantined \
com.example.app.test/androidx.test.runner.AndroidJUnitRunner---
Test Impact Analysis
Run only tests affected by code changes to reduce CI time.
Module-Level Impact
#!/bin/bash
# Detect changed modules and run only their tests
CHANGED_FILES=$(git diff --name-only origin/main...HEAD)
MODULES_TO_TEST=()
for file in $CHANGED_FILES; do
module=$(echo "$file" | cut -d'/' -f1-2)
if [[ -f "$module/build.gradle.kts" ]]; then
MODULES_TO_TEST+=("$module")
fi
done
# Deduplicate
UNIQUE_MODULES=($(echo "${MODULES_TO_TEST[@]}" | tr ' ' '\n' | sort -u))
for module in "${UNIQUE_MODULES[@]}"; do
echo "Running tests for $module"
./gradlew ":${module//\//:}:connectedDebugAndroidTest"
doneClass-Level Impact with Affected Module Detection
# GitHub Actions: conditional test execution
- name: Detect changes
id: changes
uses: dorny/paths-filter@v3
with:
filters: |
feature-auth:
- 'feature/auth/**'
feature-payments:
- 'feature/payments/**'
core:
- 'core/**'
- name: Run auth tests
if: steps.changes.outputs.feature-auth == 'true' || steps.changes.outputs.core == 'true'
run: ./gradlew :feature:auth:connectedDebugAndroidTest
- name: Run payments tests
if: steps.changes.outputs.feature-payments == 'true' || steps.changes.outputs.core == 'true'
run: ./gradlew :feature:payments:connectedDebugAndroidTest---
CI Provider Comparison
| Feature | GitHub Actions | CircleCI | Bitrise |
|---|---|---|---|
| KVM support | Linux runners | Machine exec | Dedicated stacks |
| Emulator caching | actions/cache | Docker layer | Built-in cache |
| Managed devices | Yes (KVM req) | Yes (KVM req) | Yes |
| Android-specific tools | Community actions | Android orb | Native steps |
| Max parallel jobs | 20 (default) | Depends on plan | Depends on plan |
| macOS runners | Available | Available | Available |
| Free tier | 2000 min/month | 6000 min/month | 150 builds/month |
| Setup complexity | Low | Medium | Low |
GitHub Actions Recommended Config
jobs:
unit-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with: { distribution: 'zulu', java-version: '17' }
- uses: gradle/actions/setup-gradle@v4
- run: ./gradlew testDebugUnitTest
- uses: dorny/test-reporter@v1
if: always()
with:
name: Unit Tests
path: '**/build/test-results/**/*.xml'
reporter: java-junit
instrumented-test:
runs-on: ubuntu-latest
needs: unit-test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with: { distribution: 'zulu', java-version: '17' }
- 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
- uses: gradle/actions/setup-gradle@v4
- run: ./gradlew pixel6api34atdDebugAndroidTest---
Artifact Management
Test Reports
# Upload test results and screenshots
- name: Upload test reports
if: always()
uses: actions/upload-artifact@v4
with:
name: test-reports
path: |
**/build/reports/androidTests/
**/build/outputs/androidTest-results/
**/build/outputs/managed_device_android_test_additional_output/
retention-days: 14Screenshot Artifacts on Failure
// Custom rule to capture screenshots on test failure
class ScreenshotOnFailureRule : TestWatcher() {
override fun failed(e: Throwable?, description: Description) {
val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
val dir = File(
InstrumentationRegistry.getInstrumentation().targetContext.filesDir,
"test-screenshots"
)
dir.mkdirs()
device.takeScreenshot(File(dir, "${description.methodName}.png"))
}
}---
Build Time Budgets
| Phase | Target | Optimization Lever |
|---|---|---|
| Checkout + setup | < 1 min | Shallow clone, cached deps |
| Compilation | < 3 min | Build cache, incremental builds |
| Unit tests | < 5 min | Parallel forks, skip unchanged |
| Emulator boot | < 30 sec | ATD images, snapshot restore |
| Instrumented tests | < 10 min | Sharding (4+ shards) |
| Artifact upload | < 1 min | Compress, selective upload |
| Total pipeline | < 20 min | All of the above |
Checklist -- CI Optimization:
- [ ] Gradle build cache enabled (local + remote)
- [ ] Dependencies cached between CI runs
- [ ] Configuration cache enabled
- [ ] ATD emulator images used for non-Google-API tests
- [ ] Tests sharded across 4+ parallel nodes
- [ ] Emulator snapshots cached
- [ ] Flaky tests quarantined to non-blocking job
- [ ] Test impact analysis skips unaffected modules
- [ ] Build time budget defined and tracked
- [ ] Test reports and failure screenshots uploaded as artifacts
---
Related Resources
- test-orchestrator-patterns.md -- Test isolation with Orchestrator
- gradle-managed-devices.md -- Managed device configuration
- screenshot-testing.md -- Visual regression in CI
- espresso-patterns.md -- Espresso test patterns
- compose-testing.md -- Compose testing setup
Jetpack Compose Testing Guide
UI testing patterns for Jetpack Compose applications.
Official docs: Compose Testing
Setup
Dependencies
// app/build.gradle.kts
dependencies {
// Preferred: version catalogs + Compose BOM alignment (names may vary in your project).
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
debugImplementation(libs.androidx.compose.ui.test.manifest)
}Notes:
- Compose UI tests are typically instrumented (
androidTest/) and run on an emulator/real device. - If you do not use version catalogs, apply the Compose BOM in
androidTestImplementation(...)and keep versions aligned with your app module. - Robolectric is great for JVM unit tests, but Compose UI test support is limited and stack-dependent; validate before adopting for UI.
Test Rules
// Compose-only test (no Activity)
@get:Rule
val composeTestRule = createComposeRule()
// With Activity (for integration tests)
@get:Rule
val composeTestRule = createAndroidComposeRule<MainActivity>()Finding Elements
By TestTag (Recommended)
// In Composable
Button(
onClick = { },
modifier = Modifier.testTag("loginButton")
) {
Text("Login")
}
// In Test
composeTestRule.onNodeWithTag("loginButton")
.performClick()By Text
composeTestRule.onNodeWithText("Login")
.assertIsDisplayed()
// Substring match
composeTestRule.onNodeWithText("Log", substring = true)
.assertExists()
// Ignore case
composeTestRule.onNodeWithText("login", ignoreCase = true)
.assertExists()By Content Description
// In Composable
Icon(
imageVector = Icons.Default.Search,
contentDescription = "Search",
modifier = Modifier.semantics {
contentDescription = "Search button"
}
)
// In Test
composeTestRule.onNodeWithContentDescription("Search button")
.performClick()Multiple Nodes
// Find all matching nodes
composeTestRule.onAllNodesWithTag("listItem")
.assertCountEquals(5)
// Get specific node from list
composeTestRule.onAllNodesWithTag("listItem")[0]
.performClick()
// Filter nodes
composeTestRule.onAllNodesWithTag("listItem")
.filter(hasText("Important"))
.assertCountEquals(2)Hierarchical Finders
// Child of specific node
composeTestRule.onNodeWithTag("card")
.onChild()
.assertTextEquals("Title")
// Children
composeTestRule.onNodeWithTag("list")
.onChildren()
.assertCountEquals(10)
// Ancestor
composeTestRule.onNodeWithText("Submit")
.onAncestors()
.filter(hasTestTag("form"))
.assertCountEquals(1)
// Sibling
composeTestRule.onNodeWithTag("label")
.onSibling()
.assertHasClickAction()---
Actions
Click and Touch
// Click
composeTestRule.onNodeWithTag("button")
.performClick()
// Double click
composeTestRule.onNodeWithTag("item")
.performTouchInput { doubleClick() }
// Long click
composeTestRule.onNodeWithTag("item")
.performTouchInput { longClick() }
// Click at specific position
composeTestRule.onNodeWithTag("canvas")
.performTouchInput { click(center) }Text Input
// Type text
composeTestRule.onNodeWithTag("emailField")
.performTextInput("user@example.com")
// Replace text
composeTestRule.onNodeWithTag("emailField")
.performTextReplacement("new@example.com")
// Clear text
composeTestRule.onNodeWithTag("emailField")
.performTextClearance()
// IME action
composeTestRule.onNodeWithTag("searchField")
.performImeAction()Scrolling
// Scroll to node
composeTestRule.onNodeWithTag("bottomItem")
.performScrollTo()
// Scroll in LazyColumn
composeTestRule.onNodeWithTag("lazyList")
.performScrollToIndex(50)
// Scroll to key
composeTestRule.onNodeWithTag("lazyList")
.performScrollToKey("item_key")
// Swipe gestures
composeTestRule.onNodeWithTag("pager")
.performTouchInput { swipeLeft() }Gestures
composeTestRule.onNodeWithTag("slider")
.performTouchInput {
swipeRight(
startX = centerX - 100f,
endX = centerX + 100f
)
}
composeTestRule.onNodeWithTag("zoomable")
.performTouchInput {
pinch(
start0 = center - Offset(100f, 0f),
end0 = center - Offset(200f, 0f),
start1 = center + Offset(100f, 0f),
end1 = center + Offset(200f, 0f)
)
}---
Assertions
Existence and Visibility
// Exists in tree
composeTestRule.onNodeWithTag("element")
.assertExists()
// Does not exist
composeTestRule.onNodeWithTag("element")
.assertDoesNotExist()
// Is displayed (visible on screen)
composeTestRule.onNodeWithTag("element")
.assertIsDisplayed()
// Is not displayed
composeTestRule.onNodeWithTag("element")
.assertIsNotDisplayed()State Assertions
// Enabled/Disabled
composeTestRule.onNodeWithTag("button")
.assertIsEnabled()
composeTestRule.onNodeWithTag("button")
.assertIsNotEnabled()
// Selected
composeTestRule.onNodeWithTag("tab")
.assertIsSelected()
// Focused
composeTestRule.onNodeWithTag("field")
.assertIsFocused()
// Checked (for toggles)
composeTestRule.onNodeWithTag("checkbox")
.assertIsOn()
composeTestRule.onNodeWithTag("checkbox")
.assertIsOff()Content Assertions
// Text content
composeTestRule.onNodeWithTag("title")
.assertTextEquals("Welcome")
// Contains text
composeTestRule.onNodeWithTag("paragraph")
.assertTextContains("important")
// Content description
composeTestRule.onNodeWithTag("image")
.assertContentDescriptionEquals("Profile photo")Custom Assertions
composeTestRule.onNodeWithTag("customView")
.assert(hasText("Expected"))
.assert(isEnabled())
.assert(hasClickAction())
// Using SemanticsMatcher
composeTestRule.onNode(
hasText("Label") and hasClickAction()
).assertExists()---
Synchronization
Wait for Idle
// Wait for all pending compositions
composeTestRule.waitForIdle()
// Advance time (for animations)
composeTestRule.mainClock.advanceTimeBy(1000)
// Auto-advance disabled (manual control)
composeTestRule.mainClock.autoAdvance = false
composeTestRule.mainClock.advanceTimeByFrame()Wait Until
// Wait for condition
composeTestRule.waitUntil(timeoutMillis = 5000) {
composeTestRule.onAllNodesWithTag("item")
.fetchSemanticsNodes().size >= 10
}
// Wait for node to exist
composeTestRule.waitUntil {
composeTestRule.onNodeWithTag("loaded")
.fetchSemanticsNodes().isNotEmpty()
}IdlingResource Integration
// Register IdlingResource for Espresso interop
@Before
fun setUp() {
composeTestRule.registerIdlingResource(networkIdlingResource)
}
@After
fun tearDown() {
composeTestRule.unregisterIdlingResource(networkIdlingResource)
}---
Testing Patterns
Test in Isolation
@Test
fun loginButton_whenCredentialsEntered_isEnabled() {
var isEnabled = false
composeTestRule.setContent {
LoginButton(
email = "user@example.com",
password = "password",
onEnabledChange = { isEnabled = it }
)
}
composeTestRule.onNodeWithTag("loginButton")
.assertIsEnabled()
}Test with ViewModel
@Test
fun loginScreen_showsLoadingState() {
val viewModel = LoginViewModel(FakeAuthRepository())
composeTestRule.setContent {
LoginScreen(viewModel = viewModel)
}
// Trigger loading
composeTestRule.onNodeWithTag("loginButton")
.performClick()
// Verify loading indicator
composeTestRule.onNodeWithTag("loadingIndicator")
.assertIsDisplayed()
}Test Navigation
@Test
fun loginSuccess_navigatesToHome() {
val navController = TestNavHostController(
ApplicationProvider.getApplicationContext()
)
navController.setGraph(R.navigation.nav_graph)
composeTestRule.setContent {
AppNavigation(navController = navController)
}
// Perform login
composeTestRule.onNodeWithTag("emailField")
.performTextInput("user@example.com")
composeTestRule.onNodeWithTag("passwordField")
.performTextInput("password")
composeTestRule.onNodeWithTag("loginButton")
.performClick()
// Verify navigation
composeTestRule.waitUntil {
navController.currentDestination?.route == "home"
}
}Test State Restoration
@Test
fun textField_preservesStateOnRecreation() {
val restorationTester = StateRestorationTester(composeTestRule)
restorationTester.setContent {
var text by rememberSaveable { mutableStateOf("") }
TextField(
value = text,
onValueChange = { text = it },
modifier = Modifier.testTag("textField")
)
}
// Enter text
composeTestRule.onNodeWithTag("textField")
.performTextInput("Hello")
// Simulate recreation
restorationTester.emulateSavedInstanceStateRestore()
// Verify text preserved
composeTestRule.onNodeWithTag("textField")
.assertTextEquals("Hello")
}---
LazyColumn/LazyRow Testing
Scroll to Item
@Test
fun lazyColumn_scrollsToItem() {
composeTestRule.setContent {
LazyColumn(Modifier.testTag("list")) {
items(100) { index ->
Text(
text = "Item $index",
modifier = Modifier.testTag("item_$index")
)
}
}
}
// Scroll to item 50
composeTestRule.onNodeWithTag("list")
.performScrollToIndex(50)
// Verify item is visible
composeTestRule.onNodeWithTag("item_50")
.assertIsDisplayed()
}Test Dynamic Content
@Test
fun lazyColumn_displaysAllItems() {
val items = List(20) { "Item $it" }
composeTestRule.setContent {
LazyColumn(Modifier.testTag("list")) {
items(items) { item ->
Text(item, Modifier.testTag(item))
}
}
}
// Check first items visible
composeTestRule.onNodeWithTag("Item 0")
.assertIsDisplayed()
// Scroll and check last item
composeTestRule.onNodeWithTag("list")
.performScrollToIndex(19)
composeTestRule.onNodeWithTag("Item 19")
.assertIsDisplayed()
}---
Screenshot Testing
Basic Screenshot
@Test
fun loginScreen_matchesSnapshot() {
composeTestRule.setContent {
LoginScreen()
}
val image = composeTestRule.onRoot().captureToImage()
// Persist/compare `image` using your snapshot tool (Shot/Roborazzi/Paparazzi/etc).
}With Paparazzi (JVM)
// No device needed
class LoginScreenshotTest {
@get:Rule
val paparazzi = Paparazzi()
@Test
fun loginScreen() {
paparazzi.snapshot {
LoginScreen()
}
}
@Test
fun loginScreen_error() {
paparazzi.snapshot {
LoginScreen(error = "Invalid credentials")
}
}
}---
Debugging
Print Semantics Tree
@Test
fun debug_printTree() {
composeTestRule.setContent {
LoginScreen()
}
// Print full tree
composeTestRule.onRoot().printToLog("COMPOSE_TREE")
// Print unmerged tree (more detail)
composeTestRule.onRoot(useUnmergedTree = true)
.printToLog("UNMERGED_TREE")
}Use Unmerged Tree
// When merged semantics hide nodes
composeTestRule.onNodeWithTag("innerElement", useUnmergedTree = true)
.assertExists()---
Best Practices
Do
- Use
testTagfor stable element identification - Test composables in isolation when possible
- Use
waitUntilfor async operations - Test state restoration with
StateRestorationTester - Keep tests focused on behavior, not implementation
Avoid
- Relying on text that changes with locale
- Using
Thread.sleep()for timing - Testing implementation details
- Over-specifying assertions
- Ignoring flaky test warnings
---
Resources
Espresso Patterns and Best Practices
Advanced patterns for Espresso UI testing on Android.
Official docs: Espresso
---
Core Concepts
ViewMatchers (Find Elements)
// By ID
onView(withId(R.id.emailField))
// By text
onView(withText("Login"))
onView(withText(R.string.login_button))
// By content description (accessibility)
onView(withContentDescription("Submit button"))
// By hint
onView(withHint("Enter email"))
// Combining matchers
onView(allOf(
withId(R.id.button),
withText("Submit"),
isDisplayed()
))
// Negation
onView(allOf(
withId(R.id.button),
not(isEnabled())
))
// Parent/child relationships
onView(withParent(withId(R.id.container)))
onView(isDescendantOfA(withId(R.id.form)))
onView(hasSibling(withText("Username")))
// Position in list
onData(anything())
.inAdapterView(withId(R.id.listView))
.atPosition(0)ViewActions (Interact)
// Click actions
onView(withId(R.id.button)).perform(click())
onView(withId(R.id.button)).perform(doubleClick())
onView(withId(R.id.button)).perform(longClick())
// Text input
onView(withId(R.id.field)).perform(typeText("hello"))
onView(withId(R.id.field)).perform(replaceText("new text"))
onView(withId(R.id.field)).perform(clearText())
// Keyboard
onView(withId(R.id.field)).perform(closeSoftKeyboard())
onView(withId(R.id.field)).perform(pressImeActionButton())
onView(withId(R.id.field)).perform(pressKey(KeyEvent.KEYCODE_ENTER))
// Scrolling
onView(withId(R.id.scrollView)).perform(scrollTo())
onView(withId(R.id.recyclerView)).perform(
RecyclerViewActions.scrollToPosition<androidx.recyclerview.widget.RecyclerView.ViewHolder>(10)
)
// Swiping
onView(withId(R.id.pager)).perform(swipeLeft())
onView(withId(R.id.pager)).perform(swipeRight())
onView(withId(R.id.list)).perform(swipeUp())ViewAssertions (Verify)
// Visibility
onView(withId(R.id.view)).check(matches(isDisplayed()))
onView(withId(R.id.view)).check(matches(not(isDisplayed())))
onView(withId(R.id.view)).check(doesNotExist())
// State
onView(withId(R.id.button)).check(matches(isEnabled()))
onView(withId(R.id.checkbox)).check(matches(isChecked()))
onView(withId(R.id.field)).check(matches(isFocused()))
onView(withId(R.id.field)).check(matches(hasFocus()))
// Content
onView(withId(R.id.text)).check(matches(withText("Expected")))
onView(withId(R.id.text)).check(matches(withText(containsString("part"))))
onView(withId(R.id.text)).check(matches(withText(startsWith("Hello"))))
// List assertions
onView(withId(R.id.recyclerView))
.check(matches(hasDescendant(withText("Item 1"))))---
RecyclerView Testing
Scroll and Click
import androidx.test.espresso.contrib.RecyclerViewActions
import androidx.recyclerview.widget.RecyclerView
// Scroll to position
onView(withId(R.id.recyclerView))
.perform(RecyclerViewActions.scrollToPosition<RecyclerView.ViewHolder>(5))
// Click item at position
onView(withId(R.id.recyclerView))
.perform(RecyclerViewActions.actionOnItemAtPosition<RecyclerView.ViewHolder>(
3, click()
))
// Scroll to item with text
onView(withId(R.id.recyclerView))
.perform(RecyclerViewActions.scrollTo<RecyclerView.ViewHolder>(
hasDescendant(withText("Target Item"))
))
// Action on item with matcher
onView(withId(R.id.recyclerView))
.perform(RecyclerViewActions.actionOnItem<RecyclerView.ViewHolder>(
hasDescendant(withText("Target Item")),
click()
))Click Child View in Item
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import androidx.test.espresso.UiController
import androidx.test.espresso.ViewAction
import androidx.test.espresso.contrib.RecyclerViewActions
import androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom
import org.hamcrest.Matcher
fun clickChildViewWithId(id: Int): ViewAction {
return object : ViewAction {
override fun getConstraints(): Matcher<View> = isAssignableFrom(View::class.java)
override fun getDescription() = "Click on child view with id $id"
override fun perform(uiController: UiController, view: View) {
val child = view.findViewById<View>(id)
checkNotNull(child) { "No view with id $id found under ${view.javaClass.simpleName}" }
child.performClick()
}
}
}
// Usage
onView(withId(R.id.recyclerView))
.perform(RecyclerViewActions.actionOnItemAtPosition<RecyclerView.ViewHolder>(
0, clickChildViewWithId(R.id.deleteButton)
))Assert Item Count
fun hasItemCount(count: Int): Matcher<View> {
return object : BoundedMatcher<View, RecyclerView>(RecyclerView::class.java) {
override fun describeTo(description: Description) {
description.appendText("has $count items")
}
override fun matchesSafely(view: RecyclerView): Boolean {
return view.adapter?.itemCount == count
}
}
}
// Usage
onView(withId(R.id.recyclerView))
.check(matches(hasItemCount(10)))---
Intents Testing
Stub External Intents
import androidx.test.espresso.intent.Intents
import androidx.test.espresso.intent.matcher.IntentMatchers.*
import androidx.test.espresso.intent.Intents.intending
import android.app.Activity
import android.app.Instrumentation
@Before
fun setUp() {
Intents.init()
}
@After
fun tearDown() {
Intents.release()
}
@Test
fun pickImage_returnsSelectedImage() {
// Stub the camera intent
val resultData = Intent().apply {
putExtra("data", testBitmap)
}
intending(hasAction(MediaStore.ACTION_IMAGE_CAPTURE))
.respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, resultData))
// Trigger the intent
onView(withId(R.id.cameraButton)).perform(click())
// Verify the result is displayed
onView(withId(R.id.imagePreview))
.check(matches(isDisplayed()))
}Verify Intent Was Sent
@Test
fun shareButton_sendsShareIntent() {
onView(withId(R.id.shareButton)).perform(click())
Intents.intended(allOf(
hasAction(Intent.ACTION_SEND),
hasType("text/plain"),
hasExtra(Intent.EXTRA_TEXT, "Share content")
))
}---
Custom Matchers
Text Color Matcher
fun withTextColor(expectedColor: Int): Matcher<View> {
return object : BoundedMatcher<View, TextView>(TextView::class.java) {
override fun describeTo(description: Description) {
description.appendText("with text color: $expectedColor")
}
override fun matchesSafely(textView: TextView): Boolean {
return textView.currentTextColor == expectedColor
}
}
}
// Usage
onView(withId(R.id.errorText))
.check(matches(withTextColor(Color.RED)))Drawable Matcher
fun withDrawable(@DrawableRes id: Int): Matcher<View> {
return object : BoundedMatcher<View, ImageView>(ImageView::class.java) {
override fun describeTo(description: Description) {
description.appendText("with drawable resource: $id")
}
override fun matchesSafely(imageView: ImageView): Boolean {
val expectedDrawable = ContextCompat.getDrawable(
imageView.context, id
) ?: return false
return imageView.drawable.constantState == expectedDrawable.constantState
}
}
}EditText Error Matcher
fun hasErrorText(expectedError: String): Matcher<View> {
return object : BoundedMatcher<View, EditText>(EditText::class.java) {
override fun describeTo(description: Description) {
description.appendText("has error text: $expectedError")
}
override fun matchesSafely(editText: EditText): Boolean {
return editText.error?.toString() == expectedError
}
}
}
// Usage
onView(withId(R.id.emailField))
.check(matches(hasErrorText("Invalid email")))---
Handling Async Operations
IdlingResource Pattern
class OkHttp3IdlingResource private constructor(
private val name: String,
private val dispatcher: Dispatcher
) : IdlingResource {
@Volatile private var callback: IdlingResource.ResourceCallback? = null
override fun getName() = name
override fun isIdleNow(): Boolean {
val idle = dispatcher.runningCallsCount() == 0
if (idle) callback?.onTransitionToIdle()
return idle
}
override fun registerIdleTransitionCallback(callback: ResourceCallback) {
this.callback = callback
}
companion object {
fun create(name: String, client: OkHttpClient): OkHttp3IdlingResource {
return OkHttp3IdlingResource(name, client.dispatcher)
}
}
}CountingIdlingResource
object EspressoIdlingResource {
private const val RESOURCE = "GLOBAL"
@JvmField
val countingIdlingResource = CountingIdlingResource(RESOURCE)
fun increment() = countingIdlingResource.increment()
fun decrement() {
if (!countingIdlingResource.isIdleNow) {
countingIdlingResource.decrement()
}
}
}
// In production code (Repository)
fun fetchData(): Flow<Data> = flow {
EspressoIdlingResource.increment()
try {
val data = api.getData()
emit(data)
} finally {
EspressoIdlingResource.decrement()
}
}
// In test
@Before
fun registerIdlingResource() {
IdlingRegistry.getInstance().register(EspressoIdlingResource.countingIdlingResource)
}
@After
fun unregisterIdlingResource() {
IdlingRegistry.getInstance().unregister(EspressoIdlingResource.countingIdlingResource)
}---
Robot Pattern (Full Example)
// robots/LoginRobot.kt
class LoginRobot {
fun enterEmail(email: String) = apply {
onView(withId(R.id.emailField))
.perform(replaceText(email), closeSoftKeyboard())
}
fun enterPassword(password: String) = apply {
onView(withId(R.id.passwordField))
.perform(replaceText(password), closeSoftKeyboard())
}
fun clickLogin() = apply {
onView(withId(R.id.loginButton)).perform(click())
}
fun clickForgotPassword() = apply {
onView(withId(R.id.forgotPasswordLink)).perform(click())
}
infix fun verify(func: LoginVerification.() -> Unit): LoginVerification {
return LoginVerification().apply(func)
}
}
class LoginVerification {
fun dashboardIsDisplayed() {
onView(withId(R.id.dashboardContainer))
.check(matches(isDisplayed()))
}
fun errorIsDisplayed(message: String) {
onView(withText(message))
.check(matches(isDisplayed()))
}
fun emailErrorIsDisplayed() {
onView(withId(R.id.emailError))
.check(matches(isDisplayed()))
}
}
// Helper function
fun login(func: LoginRobot.() -> Unit) = LoginRobot().apply(func)
// Test usage
@Test
fun successfulLogin() {
login {
enterEmail("user@example.com")
enterPassword("password123")
clickLogin()
} verify {
dashboardIsDisplayed()
}
}
@Test
fun invalidEmail_showsError() {
login {
enterEmail("invalid")
clickLogin()
} verify {
emailErrorIsDisplayed()
}
}---
Test Annotations and Rules
Disable Animations Rule
class DisableAnimationsRule : TestRule {
override fun apply(base: Statement, description: Description): Statement {
return object : Statement() {
override fun evaluate() {
// Disable animations
InstrumentationRegistry.getInstrumentation().uiAutomation.executeShellCommand(
"settings put global window_animation_scale 0"
)
InstrumentationRegistry.getInstrumentation().uiAutomation.executeShellCommand(
"settings put global transition_animation_scale 0"
)
InstrumentationRegistry.getInstrumentation().uiAutomation.executeShellCommand(
"settings put global animator_duration_scale 0"
)
try {
base.evaluate()
} finally {
// Re-enable animations
InstrumentationRegistry.getInstrumentation().uiAutomation.executeShellCommand(
"settings put global window_animation_scale 1"
)
InstrumentationRegistry.getInstrumentation().uiAutomation.executeShellCommand(
"settings put global transition_animation_scale 1"
)
InstrumentationRegistry.getInstrumentation().uiAutomation.executeShellCommand(
"settings put global animator_duration_scale 1"
)
}
}
}
}
}
// Usage
@get:Rule
val disableAnimationsRule = DisableAnimationsRule()Grant Permissions Rule
import androidx.test.rule.GrantPermissionRule
@get:Rule
val permissionRule: GrantPermissionRule = GrantPermissionRule.grant(
android.Manifest.permission.CAMERA,
android.Manifest.permission.ACCESS_FINE_LOCATION
)---
Debugging Tips
Print View Hierarchy
// In test, when matcher fails
onView(isRoot()).check { view, _ ->
val hierarchy = TreePrinter(view).print()
Log.d("ViewHierarchy", hierarchy)
}
// Or use Espresso's built-in
onView(withId(R.id.nonExistent)) // Will print hierarchy on failureScreenshot on Failure
@get:Rule
val screenshotRule = ScreenshotRule()
class ScreenshotRule : TestWatcher() {
override fun failed(e: Throwable?, description: Description) {
val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
val filename = "${description.methodName}_failure.png"
device.takeScreenshot(File(
InstrumentationRegistry.getInstrumentation().targetContext.filesDir,
filename
))
}
}---
Resources
Gradle Managed Devices (GMD)
Gradle Managed Devices automate emulator provisioning for CI/CD Android testing. No pre-configured emulator images required.
Official Documentation: developer.android.com/studio/test/gradle-managed-devices
Quick Start
Define Devices in build.gradle.kts
android {
testOptions {
managedDevices {
localDevices {
create("pixel6api34") {
device = "Pixel 6"
apiLevel = 34
systemImageSource = "aosp-atd" // Android Test Device image
}
create("pixel4api30") {
device = "Pixel 4"
apiLevel = 30
systemImageSource = "aosp"
}
}
}
}
}Run Tests
# Discover generated tasks (names vary by AGP + variants)
./gradlew tasks --all | rg "AndroidTest|managedDevice|Group"
# Typical patterns (Debug variant)
./gradlew pixel6api34DebugAndroidTest
./gradlew phoneMatrixGroupDebugAndroidTest
./gradlew allDevicesDebugAndroidTestDevice Groups
Test across multiple configurations simultaneously:
android {
testOptions {
managedDevices {
localDevices {
create("pixel6api34") {
device = "Pixel 6"
apiLevel = 34
systemImageSource = "aosp-atd"
}
create("pixel4api30") {
device = "Pixel 4"
apiLevel = 30
systemImageSource = "aosp-atd"
}
create("smallPhone") {
device = "Nexus 5"
apiLevel = 30
systemImageSource = "aosp-atd"
}
}
groups {
create("phoneMatrix") {
targetDevices.addAll(
devices["pixel6api34"],
devices["pixel4api30"],
devices["smallPhone"]
)
}
}
}
}
}# Run on all devices in group
./gradlew phoneMatrixGroupDebugAndroidTestSystem Image Sources
| Source | Description | Use Case |
|---|---|---|
aosp-atd | Android Test Device (headless, fast) | CI/CD, instrumentation tests |
aosp | Standard AOSP image | General testing |
google | Google APIs included | Tests requiring Google services |
google-atd | Google ATD image | CI/CD with Google APIs |
ATD images are optimized for testing:
- Faster boot times
- Lower resource usage
- No UI rendering overhead
CI/CD Integration
GitHub Actions
name: Android Tests
on: [push, pull_request]
jobs:
instrumented-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- 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: Gradle cache
uses: gradle/actions/setup-gradle@v3
- name: Run instrumented tests
run: ./gradlew pixel6api34DebugAndroidTest
- name: Upload test results
uses: actions/upload-artifact@v4
if: always()
with:
name: test-results
path: '**/build/reports/androidTests/'Key CI Requirements
1. KVM acceleration - Required for acceptable performance on Linux runners 2. Sufficient RAM - 8GB+ recommended for emulator 3. Disk space - System images cached between runs
Advanced Configuration
Hardware Profiles
create("tabletApi34") {
device = "Pixel Tablet"
apiLevel = 34
systemImageSource = "google-atd"
}
create("foldableApi34") {
device = "7.6in Foldable"
apiLevel = 34
systemImageSource = "google-atd"
}Test Sharding
Prefer device groups for parallel coverage; keep per-device runs small and deterministic.
android {
testOptions {
execution = "ANDROIDX_TEST_ORCHESTRATOR"
managedDevices {
localDevices {
create("pixel6api34") {
device = "Pixel 6"
apiLevel = 34
systemImageSource = "aosp-atd"
}
}
}
}
}# Run with Gradle parallelism (device tasks may still serialize depending on AGP setup)
./gradlew pixel6api34DebugAndroidTest --parallel -Dorg.gradle.workers.max=4Emulator Settings
create("pixel6api34") {
device = "Pixel 6"
apiLevel = 34
systemImageSource = "aosp-atd"
require64Bit = true
}---
Troubleshooting
Common Issues
| Issue | Solution |
|---|---|
| Emulator won't start | Enable KVM: sudo usermod -aG kvm $USER |
| Slow boot on CI | Use ATD images (aosp-atd or google-atd) |
| Out of disk space | Clean managed devices cache (search tasks: `./gradlew tasks --all |
| Tests timeout | Increase timeout in test runner config |
| API level not available | Check available images: sdkmanager --list |
Cache Management
# Search available cache/cleanup tasks (names vary by AGP)
./gradlew tasks --all | rg -n "ManagedDevices|cleanManagedDevices"
# Location of cached images
# ~/.android/avd/gradle-managed/---
Device Matrix Strategy
Recommended Matrix for Production Apps
managedDevices {
localDevices {
// Latest API
create("pixel8api35") {
device = "Pixel 8"
apiLevel = 35
systemImageSource = "google-atd"
}
// Popular mid-range API
create("pixel6api33") {
device = "Pixel 6"
apiLevel = 33
systemImageSource = "google-atd"
}
// Min supported API
create("nexus5api24") {
device = "Nexus 5"
apiLevel = 24
systemImageSource = "aosp-atd"
}
// Tablet
create("tabletApi34") {
device = "Pixel Tablet"
apiLevel = 34
systemImageSource = "google-atd"
}
}
groups {
create("ciMatrix") {
targetDevices.addAll(
devices["pixel8api35"],
devices["pixel6api33"],
devices["nexus5api24"]
)
}
create("fullMatrix") {
targetDevices.addAll(
devices["pixel8api35"],
devices["pixel6api33"],
devices["nexus5api24"],
devices["tabletApi34"]
)
}
}
}Usage
# Quick CI check (3 devices)
./gradlew ciMatrixGroupDebugAndroidTest
# Full release validation (4 devices)
./gradlew fullMatrixGroupDebugAndroidTestFirebase Test Lab (Optional)
For cloud-based testing with real devices, use the Firebase Test Lab CLI:
gcloud firebase test android run --type instrumentation \
--app app/build/outputs/apk/debug/app-debug.apk \
--test app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk \
--device model=Pixel6,version=34,locale=en,orientation=portraitRelated
- espresso-patterns.md - Espresso test patterns
- compose-testing.md - Compose UI testing
- SKILL.md - Main Android testing skill
Screenshot Testing for Android
Visual regression testing tools and patterns for Android UI validation.
Official docs: Compose Preview Screenshot Testing
Contents
- Tool Comparison
- Compose Preview Screenshot Testing
- Paparazzi
- Roborazzi
- Shot by Karumi
- Comparison Strategies
- Recording and Updating Baselines
- CI Integration
- Handling Dynamic Content
- Theme and Display Testing
- Flake Reduction
- Related Resources
---
Tool Comparison
| Tool | Runs On | Compose | Views | Device Required | Speed |
|---|---|---|---|---|---|
| Compose Preview Screenshot | JVM (Gradle) | Yes | No | No | Fast |
| Paparazzi | JVM | Yes | Yes | No | Fast |
| Roborazzi | JVM (Robolectric) | Yes | Yes | No | Fast |
| Shot (Karumi) | Device/Emulator | Yes | Yes | Yes | Slow |
| Dropshots (Dropbox) | Device/Emulator | Yes | Yes | Yes | Slow |
Recommendation: Start with Paparazzi or Roborazzi for JVM-based speed. Use device-based tools only when you need real rendering fidelity (shadows, hardware-accelerated drawing).
---
Compose Preview Screenshot Testing
The official Jetpack tool generates screenshots from @Preview composables on the JVM.
Setup
// build.gradle.kts (app module)
plugins {
id("com.android.compose.screenshot") version "0.0.1-alpha07"
}
android {
experimentalProperties["android.experimental.enableScreenshotTest"] = true
}
dependencies {
screenshotTestImplementation(libs.androidx.compose.ui.tooling)
}Writing Preview Screenshot Tests
// src/screenshotTest/kotlin/LoginScreenshots.kt
package com.example.app
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import com.example.app.ui.theme.AppTheme
@Preview(showBackground = true)
@Composable
fun LoginScreen_Default() {
AppTheme {
LoginScreen(
state = LoginState(email = "", password = "", isLoading = false)
)
}
}
@Preview(showBackground = true)
@Composable
fun LoginScreen_Loading() {
AppTheme {
LoginScreen(
state = LoginState(email = "user@test.com", password = "****", isLoading = true)
)
}
}
@Preview(showBackground = true)
@Composable
fun LoginScreen_Error() {
AppTheme {
LoginScreen(
state = LoginState(
email = "user@test.com",
password = "",
error = "Invalid credentials"
)
)
}
}Commands
# Record golden images
./gradlew updateDebugScreenshotTest
# Verify against goldens
./gradlew validateDebugScreenshotTest---
Paparazzi
JVM-based screenshot testing from Cash App. No device or emulator needed.
Setup
// build.gradle.kts (module)
plugins {
id("app.cash.paparazzi") version "1.3.4"
}Compose Screenshot Test
import app.cash.paparazzi.DeviceConfig
import app.cash.paparazzi.Paparazzi
import org.junit.Rule
import org.junit.Test
class ProfileScreenTest {
@get:Rule
val paparazzi = Paparazzi(
deviceConfig = DeviceConfig.PIXEL_6,
theme = "android:Theme.Material3.Light.NoActionBar",
)
@Test
fun default_state() {
paparazzi.snapshot {
AppTheme {
ProfileScreen(
user = User(name = "Jane Doe", email = "jane@example.com"),
isEditing = false,
)
}
}
}
@Test
fun editing_state() {
paparazzi.snapshot {
AppTheme {
ProfileScreen(
user = User(name = "Jane Doe", email = "jane@example.com"),
isEditing = true,
)
}
}
}
}View-Based Screenshot Test
class LegacyViewTest {
@get:Rule
val paparazzi = Paparazzi()
@Test
fun custom_card_view() {
val view = paparazzi.inflate<CustomCardView>(R.layout.card_item)
view.setTitle("Product Name")
view.setPrice("$29.99")
view.setRating(4.5f)
paparazzi.snapshot(view)
}
}Commands
# Record golden images
./gradlew :app:recordPaparazziDebug
# Verify against goldens
./gradlew :app:verifyPaparazziDebug---
Roborazzi
Robolectric-based screenshot testing. Captures full Activity/Fragment rendering.
Setup
// build.gradle.kts
plugins {
id("io.github.takahirom.roborazzi") version "1.26.0"
}
dependencies {
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.26.0")
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.26.0")
testImplementation("io.github.takahirom.roborazzi:roborazzi-junit-rule:1.26.0")
}
android {
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
}Compose Test with Roborazzi
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onRoot
import com.github.takahirom.roborazzi.captureRoboImage
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class SettingsScreenTest {
@get:Rule
val composeRule = createComposeRule()
@Test
fun settings_default() {
composeRule.setContent {
AppTheme {
SettingsScreen(darkMode = false, notifications = true)
}
}
composeRule.onRoot().captureRoboImage()
}
@Test
fun settings_dark_mode() {
composeRule.setContent {
AppTheme(darkTheme = true) {
SettingsScreen(darkMode = true, notifications = true)
}
}
composeRule.onRoot().captureRoboImage()
}
}Commands
# Record
./gradlew recordRoborazziDebug
# Verify
./gradlew verifyRoborazziDebug
# Compare (generates diff images)
./gradlew compareRoborazziDebug---
Shot by Karumi
Device-based screenshot testing with pixel-perfect accuracy.
Setup
// build.gradle.kts
plugins {
id("com.karumi.shot") version "6.1.0"
}
dependencies {
androidTestImplementation("com.karumi:shot-android:6.1.0")
}Writing Shot Tests
import com.karumi.shot.ScreenshotTest
class LoginActivityTest : ScreenshotTest {
@Test
fun login_screen_default() {
val activity = launchActivity<LoginActivity>()
compareScreenshot(activity)
}
@Test
fun login_screen_with_error() {
val activity = launchActivity<LoginActivity>()
activity.runOnUiThread {
activity.showError("Invalid credentials")
}
compareScreenshot(activity, name = "login_error")
}
}Commands
# Record on connected device/emulator
./gradlew executeScreenshotTests -Precord
# Verify
./gradlew executeScreenshotTests---
Comparison Strategies
| Strategy | Tolerance | Speed | Use Case |
|---|---|---|---|
| Exact pixel match | 0% | Fast | Deterministic rendering, JVM tools |
| Pixel diff + threshold | 0.1-1% | Fast | Account for anti-aliasing differences |
| Perceptual diff (SSIM) | Configurable | Medium | Human-like similarity detection |
| Region-based | Per-region | Medium | Ignore dynamic areas |
Configuring Tolerance in Paparazzi
@get:Rule
val paparazzi = Paparazzi(
deviceConfig = DeviceConfig.PIXEL_6,
renderingMode = SessionParams.RenderingMode.SHRINK,
// Paparazzi uses exact match by default
// For tolerance, use a custom image comparator:
)
// Custom comparator example
class ThresholdComparator(private val maxDiffPercent: Double = 0.5) {
fun compare(golden: BufferedImage, actual: BufferedImage): Boolean {
val totalPixels = golden.width * golden.height
var diffPixels = 0
for (x in 0 until golden.width) {
for (y in 0 until golden.height) {
if (golden.getRGB(x, y) != actual.getRGB(x, y)) {
diffPixels++
}
}
}
val diffPercent = (diffPixels.toDouble() / totalPixels) * 100
return diffPercent <= maxDiffPercent
}
}---
Recording and Updating Baselines
Workflow
1. Developer makes UI change
2. Run verification: ./gradlew verifyPaparazziDebug
3. Test fails with diff images
4. Review diff images manually
5. If intentional: ./gradlew recordPaparazziDebug
6. Commit updated golden images
7. PR review includes visual diff reviewGolden Image Storage
| Strategy | Pros | Cons |
|---|---|---|
| In-repo (Git) | Simple, versioned with code | Increases repo size |
| Git LFS | Versioned, smaller repo | Requires LFS setup |
| Cloud storage (S3) | No repo bloat | Separate versioning needed |
| Artifact registry | Integrates with CI | More complex setup |
Recommendation: Use Git LFS for teams with many screenshots. Keep in-repo for small projects (< 200 images).
---
CI Integration
GitHub Actions with Paparazzi
name: Screenshot Tests
on: [pull_request]
jobs:
screenshot-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
lfs: true
- uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: '17'
- uses: gradle/actions/setup-gradle@v4
- name: Verify screenshots
run: ./gradlew verifyPaparazziDebug
- name: Upload diff images on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: screenshot-diffs
path: '**/build/paparazzi/failures/'PR Comment with Diff Images
- name: Comment PR with diffs
if: failure()
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const glob = require('glob');
const diffs = glob.sync('**/failures/*.png');
if (diffs.length > 0) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
body: `## Screenshot diffs detected\n${diffs.length} screenshot(s) differ from baselines.\nCheck the uploaded artifacts for diff images.`
});
}---
Handling Dynamic Content
Freeze Time
@Test
fun order_confirmation_screen() {
val fixedClock = Clock.fixed(
Instant.parse("2025-01-15T10:30:00Z"),
ZoneId.of("UTC")
)
paparazzi.snapshot {
AppTheme {
OrderConfirmation(
order = Order(id = "ORD-001", total = 99.99),
clock = fixedClock,
)
}
}
}Replace Animations
// Disable animations in composables under test
@Test
fun animated_component() {
paparazzi.snapshot {
CompositionLocalProvider(
LocalInspectionMode provides true // disables animations
) {
AnimatedStatusBadge(status = Status.SUCCESS)
}
}
}Placeholder for Network Images
// Replace Coil/Glide image loading with static placeholder
@Test
fun user_avatar() {
val testImageLoader = FakeImageLoader.Builder(paparazzi.context)
.default(ColorDrawable(Color.GRAY))
.build()
paparazzi.snapshot {
CompositionLocalProvider(
LocalImageLoader provides testImageLoader
) {
UserAvatar(url = "https://example.com/avatar.jpg")
}
}
}---
Theme and Display Testing
Dark Mode Testing
class ThemeScreenshotTest {
@get:Rule
val paparazzi = Paparazzi(deviceConfig = DeviceConfig.PIXEL_6)
@Test
fun home_screen_light() {
paparazzi.snapshot {
AppTheme(darkTheme = false) {
HomeScreen(state = HomeState.preview())
}
}
}
@Test
fun home_screen_dark() {
paparazzi.snapshot {
AppTheme(darkTheme = true) {
HomeScreen(state = HomeState.preview())
}
}
}
}Multi-Density Testing
@Test
fun component_mdpi() {
val paparazzi = Paparazzi(deviceConfig = DeviceConfig.NEXUS_5.copy(density = Density.MEDIUM))
paparazzi.snapshot { MyComponent() }
}
@Test
fun component_xxhdpi() {
val paparazzi = Paparazzi(deviceConfig = DeviceConfig.PIXEL_6) // xxhdpi by default
paparazzi.snapshot { MyComponent() }
}Multi-Device Parameterized Test
@RunWith(TestParameterInjector::class)
class DeviceScreenshotTest {
@get:Rule
val paparazzi = Paparazzi()
enum class Device(val config: DeviceConfig) {
PHONE(DeviceConfig.PIXEL_6),
FOLDABLE(DeviceConfig.PIXEL_FOLD),
TABLET(DeviceConfig.PIXEL_C),
}
@TestParameter
lateinit var device: Device
@Test
fun dashboard_screen() {
paparazzi.unsafeUpdateConfig(device.config)
paparazzi.snapshot {
AppTheme {
DashboardScreen(state = DashboardState.preview())
}
}
}
}---
Flake Reduction
| Flake Source | Mitigation |
|---|---|
| Font rendering diffs | Use JVM tools (Paparazzi/Roborazzi) for consistency |
| Animation frames | Disable animations or use LocalInspectionMode |
| System UI (status bar) | Exclude system chrome from capture area |
| Network images | Use fake image loaders with static placeholders |
| Date/time display | Inject fixed clocks |
| Random content | Use seeded random or fixed test data |
| Floating point rounding | Set pixel diff threshold of 0.1-0.5% |
Checklist -- Flake Prevention:
- [ ] All tests use deterministic data (no random, no real timestamps)
- [ ] Network image loading is stubbed
- [ ] Animations are disabled or frozen at target frame
- [ ] JVM-based tool used where device fidelity is not required
- [ ] Pixel diff threshold is configured for anti-aliasing tolerance
- [ ] CI uses consistent JDK version and OS for rendering
---
Related Resources
- compose-testing.md -- Jetpack Compose testing patterns
- espresso-patterns.md -- Espresso UI testing
- android-ci-optimization.md -- CI pipeline setup for screenshot tests
- gradle-managed-devices.md -- Managed device configuration
AndroidX Test Orchestrator Patterns
Test isolation and crash recovery using AndroidX Test Orchestrator.
Official docs: AndroidX Test Orchestrator
Contents
- Orchestrator Architecture
- Gradle Configuration
- clearPackageData Flag
- Test Sharding
- Custom Test Runners
- JUnit 4 Rules for Setup and Teardown
- Device State Management
- Orchestrator with Firebase Test Lab
- Troubleshooting
- Performance Impact and Mitigation
- Related Resources
---
Orchestrator Architecture
Without Orchestrator, all tests run in a single instrumentation process. If one test crashes, all subsequent tests fail. With Orchestrator, each test runs in its own instrumentation invocation.
WITHOUT ORCHESTRATOR:
┌─────────────────────────────────────┐
│ Single Instrumentation Process │
│ Test A → Test B → CRASH → Test C ✗ │
│ Test D ✗ │
│ Test E ✗ │
└─────────────────────────────────────┘
All remaining tests lost after crash.
WITH ORCHESTRATOR:
┌──────────────────┐
│ Orchestrator APK │ (controls execution)
└────────┬─────────┘
├── Instrumentation 1 → Test A ✓
├── Instrumentation 2 → Test B ✓
├── Instrumentation 3 → Test C CRASH (isolated)
├── Instrumentation 4 → Test D ✓
└── Instrumentation 5 → Test E ✓Benefits
| Benefit | Description |
|---|---|
| Crash isolation | One test crash does not affect other tests |
| Shared state elimination | Each test starts with a clean process |
| Reliable results | No flakes from leaked state between tests |
| Per-test data clearing | Optional clearPackageData between tests |
| Better CI reporting | Crashed tests reported individually, not as batch |
---
Gradle Configuration
Basic Setup
// app/build.gradle.kts
android {
defaultConfig {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
testInstrumentationRunnerArguments["clearPackageData"] = "true"
}
testOptions {
execution = "ANDROIDX_TEST_ORCHESTRATOR"
}
}
dependencies {
androidTestImplementation("androidx.test:runner:1.6.2")
androidTestImplementation("androidx.test:rules:1.6.1")
androidTestUtil("androidx.test:orchestrator:1.5.1")
}Using Gradle Managed Devices with Orchestrator
android {
testOptions {
execution = "ANDROIDX_TEST_ORCHESTRATOR"
managedDevices {
localDevices {
create("pixel6api34") {
device = "Pixel 6"
apiLevel = 34
systemImageSource = "aosp-atd"
}
}
}
}
}# Run with managed device + orchestrator
./gradlew pixel6api34DebugAndroidTest---
clearPackageData Flag
When enabled, Orchestrator clears all app data (SharedPreferences, databases, files) between each test.
When to Use
| Scenario | clearPackageData | Reason |
|---|---|---|
| Tests modify SharedPreferences | Yes | Prevent state leakage |
| Tests write to local database | Yes | Clean database per test |
| Tests are read-only | No | Skip overhead for faster runs |
| Login state persists between tests | Yes | Ensure consistent auth state |
| Performance-sensitive CI | No | Reduces per-test overhead ~2-5s |
Selective Clearing
You cannot selectively enable clearPackageData per test class with the flag alone. Instead, handle cleanup in test code.
@Before
fun clearState() {
// Clear only specific data
InstrumentationRegistry.getInstrumentation()
.targetContext
.deleteDatabase("app.db")
InstrumentationRegistry.getInstrumentation()
.targetContext
.getSharedPreferences("user_prefs", Context.MODE_PRIVATE)
.edit()
.clear()
.commit()
}---
Test Sharding
Orchestrator supports sharding tests across multiple devices or emulators for parallel execution.
Command-Line Sharding
# Shard 1 of 3
adb shell am instrument -w \
-e numShards 3 \
-e shardIndex 0 \
-e clearPackageData true \
androidx.test.orchestrator/androidx.test.orchestrator.AndroidTestOrchestrator
# Shard 2 of 3
adb shell am instrument -w \
-e numShards 3 \
-e shardIndex 1 \
-e clearPackageData true \
androidx.test.orchestrator/androidx.test.orchestrator.AndroidTestOrchestrator
# Shard 3 of 3
adb shell am instrument -w \
-e numShards 3 \
-e shardIndex 2 \
-e clearPackageData true \
androidx.test.orchestrator/androidx.test.orchestrator.AndroidTestOrchestratorCI Sharding with Gradle Managed Devices
android {
testOptions {
managedDevices {
groups {
create("phoneShards") {
targetDevices.addAll(
listOf(
devices["pixel6api34"],
)
)
// GMD handles sharding automatically with -Pandroid.experimental.androidTest.numManagedDeviceShards
}
}
}
}
}# Run with 4 shards across managed devices
./gradlew pixel6api34GroupDebugAndroidTest \
-Pandroid.experimental.androidTest.numManagedDeviceShards=4---
Custom Test Runners
Filtering Tests by Annotation
// Custom annotation
@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class SmokeSuite
// Test class
class LoginTests {
@SmokeSuite
@Test
fun login_with_valid_credentials() { /* ... */ }
@Test
fun login_with_expired_token() { /* ... */ }
}# Run only smoke tests with Orchestrator
adb shell am instrument -w \
-e annotation com.example.app.SmokeSuite \
-e clearPackageData true \
androidx.test.orchestrator/androidx.test.orchestrator.AndroidTestOrchestratorCustom Runner with Hilt
// HiltTestRunner.kt
class HiltTestRunner : AndroidJUnitRunner() {
override fun newApplication(
cl: ClassLoader?,
className: String?,
context: Context?
): Application {
return super.newApplication(cl, HiltTestApplication::class.java.name, context)
}
}// build.gradle.kts
android {
defaultConfig {
testInstrumentationRunner = "com.example.app.HiltTestRunner"
}
}---
JUnit 4 Rules for Setup and Teardown
Activity Scenario Rule
import androidx.test.ext.junit.rules.ActivityScenarioRule
class MainActivityTest {
@get:Rule
val activityRule = ActivityScenarioRule(MainActivity::class.java)
@Test
fun activity_launches_successfully() {
activityRule.scenario.onActivity { activity ->
assertNotNull(activity.findViewById<View>(R.id.root))
}
}
}Compose Test Rule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
class ComposeActivityTest {
@get:Rule
val composeRule = createAndroidComposeRule<MainActivity>()
@Test
fun greeting_displays() {
composeRule.onNodeWithText("Welcome").assertIsDisplayed()
}
}Custom Rule: Database Seeding
class DatabaseSeedRule(
private val seedData: () -> Unit,
private val cleanUp: () -> Unit
) : TestRule {
override fun apply(base: Statement, description: Description): Statement {
return object : Statement() {
override fun evaluate() {
seedData()
try {
base.evaluate()
} finally {
cleanUp()
}
}
}
}
}
// Usage
class OrderHistoryTest {
@get:Rule
val dbRule = DatabaseSeedRule(
seedData = { TestDatabase.insertOrders(sampleOrders) },
cleanUp = { TestDatabase.clearAll() },
)
@Test
fun displays_order_list() {
// sampleOrders are in the database
}
}Rule Ordering
class ComplexTest {
// Rules execute outer-to-inner based on order
@get:Rule(order = 0)
val hiltRule = HiltAndroidRule(this)
@get:Rule(order = 1)
val composeRule = createAndroidComposeRule<MainActivity>()
@get:Rule(order = 2)
val dbRule = DatabaseSeedRule(::seed, ::clean)
}---
Device State Management
Disabling Animations
# Via adb (do this before test suite)
adb shell settings put global window_animation_scale 0
adb shell settings put global transition_animation_scale 0
adb shell settings put global animator_duration_scale 0// Via Gradle test options
android {
testOptions {
animationsDisabled = true
}
}Setting Locale
@Before
fun setLocale() {
val locale = Locale("es", "ES")
Locale.setDefault(locale)
val config = InstrumentationRegistry.getInstrumentation()
.targetContext.resources.configuration
config.setLocale(locale)
InstrumentationRegistry.getInstrumentation()
.targetContext.createConfigurationContext(config)
}Managing WiFi and Network
// Requires android.permission.CHANGE_WIFI_STATE in androidTest manifest
@Before
fun enableAirplaneMode() {
InstrumentationRegistry.getInstrumentation()
.uiAutomation
.executeShellCommand("cmd connectivity airplane-mode enable")
}
@After
fun disableAirplaneMode() {
InstrumentationRegistry.getInstrumentation()
.uiAutomation
.executeShellCommand("cmd connectivity airplane-mode disable")
}Screen State
@Before
fun wakeDevice() {
val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
device.wakeUp()
// Dismiss keyguard
InstrumentationRegistry.getInstrumentation()
.uiAutomation
.executeShellCommand("wm dismiss-keyguard")
}---
Orchestrator with Firebase Test Lab
gcloud Command
gcloud firebase test android run \
--type instrumentation \
--app app-debug.apk \
--test app-debug-androidTest.apk \
--use-orchestrator \
--environment-variables clearPackageData=true \
--device model=Pixel6,version=34,locale=en,orientation=portrait \
--num-uniform-shards=4 \
--timeout 30m \
--results-dir="test-results/$(date +%Y%m%d)" \
--results-bucket=gs://my-test-resultsFirebase Test Lab Configuration Matrix
| Parameter | Value | Purpose |
|---|---|---|
--use-orchestrator | (flag) | Enable test isolation |
--num-uniform-shards | 4 | Parallel execution |
--timeout | 30m | Per-shard timeout |
--environment-variables | clearPackageData=true | Clean state per test |
--device | model=Pixel6,version=34 | Target device profile |
---
Troubleshooting
Common Issues
| Issue | Cause | Fix |
|---|---|---|
| Tests hang indefinitely | Orchestrator APK not installed | Add androidTestUtil dependency |
| "No tests found" | Wrong runner class | Verify testInstrumentationRunner |
| Tests pass locally, fail on CI | State leaking without Orchestrator | Enable clearPackageData |
| Extremely slow test suite | Per-test process overhead | Limit clearPackageData to needed tests |
SecurityException on shell commands | Missing permissions | Add to androidTest/AndroidManifest.xml |
| Orchestrator crashes on start | Version mismatch | Align runner, rules, and orchestrator versions |
Debugging
# Check Orchestrator logs
adb logcat -s "AndroidTestOrchestrator" "TestRunner"
# Verify Orchestrator APK is installed
adb shell pm list packages | grep orchestrator
# Check test APK instrumentation
adb shell pm list instrumentationVersion Alignment
// All AndroidX Test dependencies should use compatible versions
dependencies {
androidTestImplementation("androidx.test:runner:1.6.2")
androidTestImplementation("androidx.test:rules:1.6.1")
androidTestImplementation("androidx.test:core:1.6.1")
androidTestImplementation("androidx.test.ext:junit:1.2.1")
androidTestUtil("androidx.test:orchestrator:1.5.1")
}---
Performance Impact and Mitigation
Overhead Measurement
| Configuration | Overhead per Test | 100-Test Suite |
|---|---|---|
| No Orchestrator | ~0s | ~5 min |
| Orchestrator (no clearPackageData) | ~2-3s | ~9 min |
| Orchestrator + clearPackageData | ~4-6s | ~13 min |
Mitigation Strategies
1. SHARD AGGRESSIVELY
Split tests across 4-8 parallel emulators to offset per-test overhead.
2. USE ATD IMAGES
Automated Test Devices boot faster and have less overhead.
systemImageSource = "aosp-atd"
3. SELECTIVE ORCHESTRATOR
Use Orchestrator only for integration/E2E tests.
Run unit tests without Orchestrator (they are process-isolated anyway).
4. MINIMIZE clearPackageData
Only enable for tests that genuinely need clean state.
Handle cleanup in @Before/@After for lightweight state resets.
5. EMULATOR SNAPSHOTS
Boot emulator once, snapshot, restore per shard.
Saves 30-60s per shard start.Gradle Task Separation
// Separate tasks for unit vs instrumented tests
// Unit tests: no orchestrator overhead
// ./gradlew testDebugUnitTest
// Instrumented tests with orchestrator
// ./gradlew connectedDebugAndroidTestChecklist -- Orchestrator Setup:
- [ ] Orchestrator dependency added as
androidTestUtil - [ ]
execution = "ANDROIDX_TEST_ORCHESTRATOR"intestOptions - [ ]
clearPackageDataenabled for tests that modify state - [ ] Animations disabled in test options or via adb
- [ ] Version alignment across all AndroidX Test dependencies
- [ ] Sharding configured for CI (4+ shards for large suites)
- [ ] Crash recovery verified (one crashing test does not block others)
---
Related Resources
- espresso-patterns.md -- Espresso testing patterns
- compose-testing.md -- Jetpack Compose test setup
- gradle-managed-devices.md -- Managed device configuration
- android-ci-optimization.md -- CI pipeline optimization
- uiautomator.md -- System-level UI testing
UIAutomator Guide
System UI and cross-app testing patterns using UIAutomator.
Official docs: https://developer.android.com/training/testing/other-components/ui-automator
When to Use UIAutomator
- System dialogs (permissions, settings, biometric prompts) that Espresso/Compose cannot reach
- Cross-app flows (browser, camera picker, share sheet)
- Notifications and Quick Settings
Prefer Espresso/Compose for in-app UI; use UIAutomator only for the system boundary.
Setup
Add UIAutomator as an instrumented-test dependency (align versions with your AndroidX Test stack):
dependencies {
// Preferred: version catalogs (names may vary in your project).
androidTestImplementation(libs.androidx.test.uiautomator)
}Core Pattern
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.By
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.Until
val instrumentation = InstrumentationRegistry.getInstrumentation()
val device = UiDevice.getInstance(instrumentation)
device.waitForIdle()Waits (Avoid Sleeps)
val timeoutMs = 5_000L
device.wait(Until.hasObject(By.textContains("Allow")), timeoutMs)
device.findObject(By.textContains("Allow"))?.click()Notes:
- Text-based selectors are locale-sensitive. Prefer resource-id/content-desc when stable in your device image.
- For runtime permissions, prefer
GrantPermissionRule(Espresso) instead of clicking dialogs.
Common Tasks
Open Notifications / Quick Settings
device.openNotification()
device.waitForIdle()Dismiss a System Dialog (Best-Effort)
device.pressBack()
device.waitForIdle()Cross-App Launch (Example: Browser)
val intent = instrumentation.context.packageManager.getLaunchIntentForPackage("com.android.chrome")
requireNotNull(intent).addFlags(android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK)
instrumentation.context.startActivity(intent)
device.wait(Until.hasObject(By.pkg("com.android.chrome").depth(0)), 10_000)Flake Control
- Prefer ATD/managed devices in CI for consistency.
- Keep waits explicit (
Until.*) and timeouts reasonable; fail fast with diagnostics (screenshot/logcat). - Avoid depending on OEM-specific UI; validate selectors on the device images in your matrix.