Flaky Mobile Tests in Appium: Causes, Patterns, and Reliable Solutions
Mobile test automation is hard. Appium makes it possible, but it does not make it easy. If you have spent any time writing Appium tests, you know the frustration: a test that passes five times in a row, then fails on the sixth. A gesture that works perfectly on a Pixel but breaks on a Samsung. A login flow that times out in your device farm but never locally.
Appium flaky tests are among the most expensive to debug because mobile environments introduce layers of variability that do not exist in web or API testing. The device hardware, OS version, app rendering speed, network conditions, device farm infrastructure, and even battery state can all influence test outcomes.
This guide breaks down every major source of flakiness in Appium tests and provides concrete, tested solutions for each.
Why Mobile Tests Are Inherently More Flaky
Before diving into solutions, it helps to understand why mobile testing produces more flakiness than other testing domains.
Hardware variability. Even two devices of the same model can behave differently. Screen density, available memory, CPU throttling under heat, and background processes all vary device to device. OS fragmentation. Android alone has thousands of OS-version and manufacturer-skin combinations. Each handles animations, rendering, and system dialogs slightly differently. Network dependency. Mobile apps frequently depend on network calls, and mobile network conditions are inherently variable -- even in CI environments using emulators. Rendering asynchronicity. Mobile UI rendering is asynchronous and influenced by device performance. An element that appears in 200ms on a flagship device might take 2 seconds on a budget phone or an overloaded emulator. Appium architecture. Appium communicates with the device over HTTP using the WebDriver protocol. This adds network latency and introduces timing windows between sending a command and it being executed on the device.Understanding these factors is essential for writing appium flaky tests solutions that actually stick.
Element Location Strategies
Unreliable element location is the single biggest cause of appium flaky tests. The way you find elements determines whether your test is resilient to layout changes, rendering delays, and platform differences.
The Locator Reliability Hierarchy
From most reliable to least reliable:
Using Accessibility IDs (Best Practice)
// iOS and Android: Same locator, same test
MobileElement loginButton = driver.findElement(
AppiumBy.accessibilityId("login-button")
);
loginButton.click();
For this to work, your development team must add accessibility identifiers to UI elements:
// iOS (SwiftUI)
Button("Log In") {
viewModel.login()
}
.accessibilityIdentifier("login-button")
// Android (Jetpack Compose)
Button(
onClick = { viewModel.login() },
modifier = Modifier.semantics {
testTag = "login-button"
}
) {
Text("Log In")
}
Why XPath Causes Flakiness
XPath is the default fallback when no better locator is available, but it is a flakiness magnet:
// TERRIBLE: Absolute XPath - breaks if ANY element in the tree changes
driver.findElement(By.xpath(
"/hierarchy/android.widget.FrameLayout/android.widget.LinearLayout/"
+ "android.widget.FrameLayout/android.widget.FrameLayout/"
+ "android.widget.FrameLayout/android.view.ViewGroup/"
+ "android.widget.Button[2]"
));
// BAD: Relative XPath - still slow and fragile
driver.findElement(By.xpath(
"//android.widget.Button[@text='Login']"
));
// GOOD: Accessibility ID
driver.findElement(AppiumBy.accessibilityId("login-button"));
XPath queries traverse the entire UI tree, which is slow on mobile devices. The tree structure can change between app versions, OS updates, or even between renders of the same screen. Every XPath locator in your test suite is a ticking time bomb.
Building a Locator Strategy Guide for Your Team
Create a decision tree that your team follows:
Has accessibility ID? → Use accessibilityId()
Has resource-id/testID? → Use id()
Has unique text? → Use AccessibilityId or predicate (iOS) / UIAutomator (Android)
None of the above? → Add an accessibility ID to the app code
Never write an XPath locator. If you cannot find an element without XPath, the correct fix is to add a proper identifier in the app code, not to write a fragile XPath query.
Implicit vs. Explicit Waits
Wait strategies in Appium are more nuanced than in web testing because mobile rendering is slower and less predictable.
The Implicit Wait Trap
// Setting implicit wait globally
driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(10));
Implicit waits apply to every findElement call. This seems convenient but creates two problems:
Explicit Waits: The Right Approach
// Utility method for reliable element finding
public MobileElement waitForElement(By locator, int timeoutSeconds) {
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(timeoutSeconds));
return (MobileElement) wait.until(
ExpectedConditions.presenceOfElementLocated(locator)
);
}
// Wait for element to be clickable (visible + enabled)
public MobileElement waitForClickable(By locator, int timeoutSeconds) {
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(timeoutSeconds));
return (MobileElement) wait.until(
ExpectedConditions.elementToBeClickable(locator)
);
}
// Usage in tests
@Test
public void testLogin() {
MobileElement emailField = waitForClickable(
AppiumBy.accessibilityId("email-input"), 15
);
emailField.sendKeys("user@example.com");
MobileElement passwordField = waitForClickable(
AppiumBy.accessibilityId("password-input"), 5
);
passwordField.sendKeys("password123");
MobileElement loginBtn = waitForClickable(
AppiumBy.accessibilityId("login-button"), 5
);
loginBtn.click();
// Wait for the next screen to appear
waitForElement(AppiumBy.accessibilityId("dashboard-title"), 20);
}
Custom Wait Conditions for Mobile
Standard WebDriver wait conditions do not cover all mobile scenarios. Build custom ones:
// Wait for a toast message to appear and disappear
public void waitForToast(String expectedText, int timeoutSeconds) {
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(timeoutSeconds));
wait.until(driver -> {
try {
MobileElement toast = driver.findElement(
By.xpath("//android.widget.Toast")
);
return toast.getText().contains(expectedText);
} catch (NoSuchElementException e) {
return false;
}
});
}
// Wait for loading spinner to disappear
public void waitForLoadingComplete(int timeoutSeconds) {
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(timeoutSeconds));
wait.until(ExpectedConditions.invisibilityOfElementLocated(
AppiumBy.accessibilityId("loading-spinner")
));
}
// Wait for list to be populated
public void waitForListItems(By listLocator, int minItems, int timeoutSeconds) {
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(timeoutSeconds));
wait.until(driver -> {
List items = driver.findElements(listLocator);
return items.size() >= minItems;
});
}
Device Farm Inconsistencies
Running appium flaky tests on device farms (AWS Device Farm, BrowserStack, Sauce Labs, or self-hosted farms) introduces a new layer of variability. The same test can behave differently across farm sessions due to device state, network routing, and server-side processing.
Common Device Farm Issues
1. Device state carryover: Previous test sessions may leave residual state -- cached data, logged-in accounts, changed settings.// Always start with a clean app state
DesiredCapabilities caps = new DesiredCapabilities();
caps.setCapability("noReset", false); // Reset app state
caps.setCapability("fullReset", false); // Don't reinstall (too slow)
caps.setCapability(AndroidMobileCapabilityType.AUTO_GRANT_PERMISSIONS, true);
2. Network latency variation: Device farms add network hops between your test runner and the device.
// Increase command timeout for device farms
caps.setCapability("newCommandTimeout", 120); // 2 minutes
caps.setCapability("appWaitDuration", 30000); // 30 seconds for app launch
3. Device allocation randomness: You might get a fast device one run and a slow device the next.
// Set generous timeouts that work on the slowest device in your farm
private static final int ELEMENT_TIMEOUT = 20; // seconds
private static final int PAGE_LOAD_TIMEOUT = 30;
private static final int APP_LAUNCH_TIMEOUT = 45;
Normalizing Device Farm Runs
@BeforeMethod
public void setUp() {
DesiredCapabilities caps = new DesiredCapabilities();
// App configuration
caps.setCapability("app", APP_PATH);
caps.setCapability("noReset", false);
caps.setCapability("autoGrantPermissions", true);
// Performance normalization
caps.setCapability("disableAnimations", true);
caps.setCapability("skipDeviceInitialization", true);
caps.setCapability("skipServerInstallation", true);
// Stability settings
caps.setCapability("newCommandTimeout", 180);
caps.setCapability("appWaitDuration", 30000);
// For Android: disable unnecessary features
caps.setCapability("ignoreUnimportantViews", true);
caps.setCapability("disableWindowAnimation", true);
driver = new AndroidDriver<>(new URL(FARM_URL), caps);
driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(0));
}
App State Management
Mobile apps have complex state: login status, cached data, push notification permissions, location permissions, onboarding completion flags, and more. Poor state management is a persistent source of mobile testing flaky behavior.
The Clean State Principle
Every test should start from a known, clean state. There are three levels of app reset:
// Level 1: No reset (fastest, but state leaks between tests)
caps.setCapability("noReset", true);
// Level 2: Fast reset (clears app data, keeps app installed)
caps.setCapability("noReset", false);
caps.setCapability("fullReset", false);
// Level 3: Full reset (uninstalls and reinstalls the app)
caps.setCapability("fullReset", true); // Slowest, but cleanest
For most test suites, Level 2 is the right balance. Use Level 3 only when you need to test first-launch experiences.
Deep Linking to Skip Setup
Instead of navigating through the app UI to reach the screen under test, use deep links to jump directly there:
@Test
public void testProductDetails() {
// BAD: Navigate through UI (slow, many potential failure points)
// login() -> navigateToShop() -> searchProduct() -> selectProduct()
// GOOD: Deep link directly to the product page
driver.get("myapp://product/12345");
// Now test the actual thing we care about
MobileElement title = waitForElement(
AppiumBy.accessibilityId("product-title"), 15
);
assertEquals("Widget Pro", title.getText());
}
Handling System Dialogs
System dialogs (permissions, notifications, updates) are a major source of appium flaky tests because they appear unpredictably:
// Proactive approach: handle dialogs before they disrupt tests
public void dismissSystemDialogs() {
try {
// Android: Handle "App not responding" dialog
WebDriverWait shortWait = new WebDriverWait(driver, Duration.ofSeconds(2));
MobileElement waitButton = (MobileElement) shortWait.until(
ExpectedConditions.presenceOfElementLocated(
By.id("android:id/aerr_wait")
)
);
waitButton.click();
} catch (TimeoutException ignored) {
// No dialog present, continue
}
// Auto-grant permissions on Android
try {
MobileElement allowButton = driver.findElement(
By.id("com.android.permissioncontroller:id/permission_allow_button")
);
allowButton.click();
} catch (NoSuchElementException ignored) {
// No permission dialog
}
}
A more robust approach is to handle dialogs at the framework level:
// Custom event listener that handles dialogs automatically
public class DialogHandler implements WebDriverEventListener {
@Override
public void afterFindBy(By by, WebElement element, WebDriver driver) {
// Check for and dismiss system dialogs after every find operation
}
// ... implement other methods
}
Gesture Reliability
Touch gestures (swipe, scroll, pinch, long press) are the most fragile operations in Appium. They depend on precise coordinates, timing, and device-specific behavior.
Why Gestures Fail
Reliable Scrolling
Instead of fixed-coordinate swipes, scroll to elements:
// BAD: Fixed-coordinate swipe (breaks on different screen sizes)
TouchAction action = new TouchAction(driver);
action.press(PointOption.point(500, 1500))
.waitAction(WaitOptions.waitOptions(Duration.ofMillis(500)))
.moveTo(PointOption.point(500, 500))
.release()
.perform();
// GOOD: Scroll until element is visible (Android)
public void scrollToElement(String accessibilityId) {
driver.findElement(AppiumBy.androidUIAutomator(
"new UiScrollable(new UiSelector().scrollable(true))"
+ ".scrollIntoView(new UiSelector()"
+ ".description(\"" + accessibilityId + "\"))"
));
}
// GOOD: Scroll until element is visible (iOS)
public void scrollToElement(String accessibilityId) {
Map params = new HashMap<>();
params.put("direction", "down");
params.put("predicateString", "label == '" + accessibilityId + "'");
driver.executeScript("mobile: scroll", params);
}
Reliable Long Press
// Platform-adaptive long press
public void longPress(MobileElement element, int durationMs) {
if (driver instanceof AndroidDriver) {
new Actions(driver)
.clickAndHold(element)
.pause(Duration.ofMillis(durationMs))
.release()
.perform();
} else {
// iOS: Use mobile: touchAndHold
Map params = new HashMap<>();
params.put("element", element.getId());
params.put("duration", durationMs / 1000.0);
driver.executeScript("mobile: touchAndHold", params);
}
}
Swipe with Retry
For gestures that are inherently unreliable, build retry logic at the gesture level:
public void swipeUntilVisible(By targetLocator, String direction, int maxSwipes) {
for (int i = 0; i < maxSwipes; i++) {
try {
MobileElement element = driver.findElement(targetLocator);
if (element.isDisplayed()) {
return;
}
} catch (NoSuchElementException ignored) {
// Element not found yet, swipe and try again
}
performSwipe(direction);
// Brief pause for rendering
try { Thread.sleep(500); } catch (InterruptedException ignored) {}
}
throw new NoSuchElementException(
"Element not found after " + maxSwipes + " swipes"
);
}
Building a Resilient Appium Test Framework
Rather than fixing appium flaky tests one at a time, build reliability into your framework so every test benefits.
Page Object Pattern with Built-In Waits
public abstract class BasePage {
protected AppiumDriver driver;
protected WebDriverWait wait;
public BasePage(AppiumDriver driver) {
this.driver = driver;
this.wait = new WebDriverWait(driver, Duration.ofSeconds(15));
}
protected MobileElement find(By locator) {
return (MobileElement) wait.until(
ExpectedConditions.presenceOfElementLocated(locator)
);
}
protected void tap(By locator) {
MobileElement element = (MobileElement) wait.until(
ExpectedConditions.elementToBeClickable(locator)
);
element.click();
}
protected void type(By locator, String text) {
MobileElement element = find(locator);
element.clear();
element.sendKeys(text);
}
protected boolean isDisplayed(By locator, int timeout) {
try {
new WebDriverWait(driver, Duration.ofSeconds(timeout))
.until(ExpectedConditions.visibilityOfElementLocated(locator));
return true;
} catch (TimeoutException e) {
return false;
}
}
}
public class LoginPage extends BasePage {
private final By emailField = AppiumBy.accessibilityId("email-input");
private final By passwordField = AppiumBy.accessibilityId("password-input");
private final By loginButton = AppiumBy.accessibilityId("login-button");
private final By errorMessage = AppiumBy.accessibilityId("error-message");
public LoginPage(AppiumDriver driver) {
super(driver);
}
public DashboardPage login(String email, String password) {
type(emailField, email);
type(passwordField, password);
tap(loginButton);
return new DashboardPage(driver);
}
public boolean hasError() {
return isDisplayed(errorMessage, 5);
}
}
Test Stability Checklist
Before merging any new Appium test, verify:
- [ ] Uses accessibility IDs exclusively (no XPath)
- [ ] Uses explicit waits (no implicit waits, no Thread.sleep)
- [ ] Handles system dialogs
- [ ] Starts from a clean app state
- [ ] Works on both the fastest and slowest target devices
- [ ] Does not depend on network speed
- [ ] Does not depend on other tests
Monitoring and Detection
Even with all these practices, some appium flaky tests will slip through. Continuous monitoring catches them before they become a persistent problem.
# Run DeFlaky against your Appium test suite
npx deflaky run --framework appium --path ./src/test/java
Track your mobile test flake rate separately from your web and API tests. Mobile tests will always have a higher baseline flake rate, but that does not mean you should accept it -- it means you should invest proportionally more in mobile test reliability.
Conclusion
Appium flaky tests are not a fact of life. They are engineering problems with engineering solutions. The patterns in this guide -- reliable locators, explicit waits, clean state management, gesture resilience, and device farm normalization -- address the root causes of mobile test flakiness rather than masking symptoms with retries.
Start with the highest-impact change: replace XPath locators with accessibility IDs. This single change will eliminate a significant percentage of your mobile test failures. Then work through the remaining patterns systematically.
Identify every flaky test in your Appium suite and get prioritized fix recommendations. DeFlaky detects mobile test flakiness patterns and shows you exactly where to focus:npx deflaky run