DroidFarm Testing Guide¶
This guide explains how to write, run, and integrate Appium tests against DroidFarm-managed Android virtual devices.
1. How DroidFarm Connects to Appium¶
flowchart LR
subgraph K8s["Kubernetes Cluster"]
subgraph Pool["DevicePool (staging-pool)"]
subgraph Pod["Cuttlefish pod (pool-emulator-0)"]
Android["Android guest VM"]
Appium["Appium Server\n:4723"]
Android -->|"ADB"| Appium
end
end
Session["TestSession\nstatus.appiumEndpoint\nstatus.streamURL"]
end
CI["CI runner / pytest"]
Browser["Browser preview"]
Appium <-->|"WebDriver\nhttp://pool-emulator-0\n.droidfarm-system.svc:4723"| CI
Session -->|"appiumEndpoint"| CI
Session -->|"streamURL"| Browser Flow:
- You (or CI) create a
TestSessionresource pointing at aDevicePool. - The DroidFarm operator claims a Cuttlefish pod from the pool and transitions the session through
Pending → Claiming → Preparing → Running. - Once
Running,status.appiumEndpointcontains the WebDriver URL your tests connect to. - Your test process (
pytest, a CI job, a test container) sends W3C WebDriver commands to that URL. The Appium sidecar inside the pod translates them to ADB calls against the running Android guest. - Patch
status.resulttoPass,Fail, orError. The operator transitions throughCollecting(where artifacts are recorded) then to the terminal state. The device is released back to the pool.
2. Writing Tests — Minimal Example¶
import os
from appium import webdriver
from appium.options import UiAutomator2Options
from appium.webdriver.common.appiumby import AppiumBy
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
APPIUM_URL = os.environ["APPIUM_URL"] # from status.appiumEndpoint
options = UiAutomator2Options()
options.platform_name = "Android"
options.automation_name = "UiAutomator2"
options.app_package = "com.example.myapp"
options.app_activity = "com.example.myapp.MainActivity"
options.no_reset = False # clear app data before session
options.new_command_timeout = 300
driver = webdriver.Remote(command_executor=APPIUM_URL, options=options)
try:
wait = WebDriverWait(driver, 30)
title = wait.until(
EC.visibility_of_element_located((AppiumBy.ID, "com.example.myapp:id/tv_main_title"))
)
assert "Welcome" in title.text
finally:
driver.quit()
Install dependencies:
The full example suite lives in examples/tests/python/.
3. Running Tests Locally Against DroidFarm¶
Prerequisites¶
kubectlconfigured to reach the cluster- A
DevicePoolwith at least one idle device
Step-by-step¶
# 1. Create a TestSession and capture its name.
SESSION=$(kubectl create \
-f examples/ci/testsession-ci.yaml \
--namespace droidfarm-system \
-o jsonpath='{.metadata.name}')
echo "Session: $SESSION"
# 2. Wait for it to become Running.
kubectl wait testsession "$SESSION" \
--namespace droidfarm-system \
--for=jsonpath='{.status.phase}'=Running \
--timeout=300s
# 3. Extract the Appium endpoint.
APPIUM_URL=$(kubectl get testsession "$SESSION" \
--namespace droidfarm-system \
-o jsonpath='{.status.appiumEndpoint}')
echo "Appium: $APPIUM_URL"
# 4. Optionally open the live stream in a browser.
STREAM_URL=$(kubectl get testsession "$SESSION" \
--namespace droidfarm-system \
-o jsonpath='{.status.streamURL}')
open "$STREAM_URL" # macOS; use xdg-open on Linux
# 5. Install dependencies and run tests.
cd examples/tests/python
pip install -r requirements.txt
APPIUM_URL="$APPIUM_URL" pytest . -v --tb=short
# 6. Clean up — patch to Succeeded so the device is returned to the pool.
kubectl patch testsession "$SESSION" \
--namespace droidfarm-system \
--type=merge \
--patch '{"status":{"phase":"Succeeded","result":"Pass"}}' \
--subresource=status
Targeting a specific test¶
APPIUM_URL="$APPIUM_URL" pytest test_app_launch.py::TestAppLaunch::test_app_launches_successfully -v
Parallel execution with pytest-xdist¶
Each worker needs its own device, so create multiple TestSessions first (one per worker), then pass the endpoints via environment variables and the -n flag:
Note: --dist=loadfile ensures that tests in the same file run on the same worker (important for stateful tests like test_clear_data_between_runs).
4. CI Integration¶
The reference GitHub Actions workflow is at examples/ci/github-actions-test.yaml.
Required secrets¶
| Secret | Description |
|---|---|
KUBECONFIG_B64 | Base64-encoded kubeconfig with get/create/patch rights on testsessions |
KUBE_API_URL | Kubernetes API server URL (for Python client in tests) |
KUBE_TOKEN | Service account token used by test fixtures to read TestSession status |
Workflow summary¶
- Create TestSession —
kubectl create -f examples/ci/testsession-ci.yaml - Poll until Running — checks
status.phaseevery 10 seconds (10-minute ceiling) - Extract endpoints — reads
status.appiumEndpointandstatus.streamURL - Run pytest —
APPIUM_URL=<endpoint> pytest examples/tests/python/ -v --tb=short --junitxml=test-results/junit.xml - Patch session — sets
status.phasetoSucceededorFailed - Upload to GitHub Actions — JUnit XML results
- Comment on PR — posts a Markdown table with pass/fail counts
5. Writing Stable Mobile Tests¶
Flakiness in mobile UI tests is almost always caused by timing assumptions. Follow these rules to keep your test suite reliable.
Always use explicit waits¶
# BAD — implicit wait races the rendering pipeline
element = driver.find_element(AppiumBy.ID, "com.example.myapp:id/btn_submit")
# GOOD — wait until element is visible before interacting
wait = WebDriverWait(driver, 30)
element = wait.until(
EC.visibility_of_element_located((AppiumBy.ID, "com.example.myapp:id/btn_submit"))
)
element.click()
Prefer resource-id locators over XPath text¶
# FRAGILE — breaks when translations change
driver.find_element(AppiumBy.XPATH, "//android.widget.Button[@text='Submit']")
# STABLE — resource IDs are defined in code, not translations
driver.find_element(AppiumBy.ID, "com.example.myapp:id/btn_submit")
Wrap network-dependent actions in a retry loop¶
API calls made by the app can be slow on first launch. Build a small helper:
import time
from selenium.common.exceptions import TimeoutException
def wait_for_with_retry(driver, by, locator, retries=3, timeout=20):
for attempt in range(retries):
try:
return WebDriverWait(driver, timeout).until(
EC.presence_of_element_located((by, locator))
)
except TimeoutException:
if attempt == retries - 1:
raise
driver.back() # dismiss any blocking dialogs
raise TimeoutException(f"Element {locator} not found after {retries} attempts")
Use driver.background_app instead of time.sleep¶
# BAD
time.sleep(5)
# GOOD — send app to background for a known duration and resume
driver.background_app(3)
Keep each test independent¶
Every test should be able to run in isolation. Never share state through class-level variables or file-system writes; always navigate back to the main screen at the start of each test (or rely on noReset: False + autoLaunch to restart the app).
Use pytest.mark.flaky sparingly¶
The pytest-rerunfailures plugin lets you rerun flaky tests:
Reserve retries for genuinely non-deterministic external dependencies (e.g., network calls to a staging backend), not for timing issues in your test code.
6. Test Isolation — clearDataBetweenSessions and recycleAfterSessions¶
DroidFarm provides two isolation knobs in the DeviceTemplate:
clearDataBetweenSessions: true¶
The operator runs adb shell pm clear com.example.myapp during the Preparing phase, before the Appium session is opened. Effect:
- App's
SharedPreferences, databases, and cache are wiped. - The app behaves as if it was freshly installed.
- Login state, feature flag overrides saved locally, and cached network responses are all gone.
managedConfigvalues are re-injected by the operator after the clear.
This matches the Appium capability noReset: false (which is set in the conftest.py fixtures): Appium will also reset the app at the start of each session by default.
When to use: Any test that must not be affected by data written by a previous test or previous CI run. Enable for all apps under test.
When to disable: Performance benchmarks where you need a warmed cache, or tests that explicitly verify upgrade-from-previous-version behaviour.
recycleAfterSessions¶
After the device has served n sessions its Cuttlefish pod is deleted and replaced with a fresh one. This guards against:
- Memory leaks that accumulate across sessions.
- File-system growth from logs, crash dumps, or media files that
pm cleardoes not remove. - Kernel or SELinux state drift caused by poorly-isolated tests.
Setting recycleAfterSessions: 1 gives maximum isolation (equivalent to a fresh VM per test run) at the cost of longer Preparing times because the Cuttlefish image must boot from scratch each time.
Recommended values:
| Scenario | Recommended value |
|---|---|
| Smoke / sanity tests | 5 – 10 |
| Full regression suite | 3 – 5 |
| Security or auth tests | 1 |
| Performance benchmarks | 1 (boot cost is acceptable for accuracy) |
Summary table¶
| Behaviour | clearDataBetweenSessions: true | recycleAfterSessions: 1 |
|---|---|---|
| App data wiped | Yes | Yes (full VM reset) |
| System-level files wiped | No | Yes |
| Kernel / VM state reset | No | Yes |
| Additional boot time | None | ~60–90 s |
| Suitable for auth/security tests | Partially | Yes |