It’s 2am. Your phone buzzes. CI failed.

You pull up the logs. NoSuchElementException: An element could not be located on the page using the given search parameters.

You ran these exact tests an hour ago. They passed. You watched them pass. What changed?

Nothing changed. That’s the problem. Your tests aren’t flaky—they’re environment-dependent. And CI is a different environment than your laptop.

“Flaky” is a codeword for “I don’t know why this fails.” And the real cost of flaky tests goes well beyond wasted debugging time – it compounds into delayed releases and eroded trust. Let’s fix that.

The 7 Environment Differences That Break Your Tests

After running device labs for 12+ years and watching thousands of test suites fail in CI, we’ve identified the most common culprits. Here’s what’s actually happening.

1. CI Runners Are Slower Than Your Machine

This is the #1 cause of tests that pass locally and fail in CI.

Your laptop has 32GB RAM, an M3 chip, and nothing else running. GitHub Actions runners have 7GB RAM, shared CPU, and are running your tests alongside everyone else’s.

What happens:

java
// Works locally (element appears in 2 seconds)
// Fails in CI (element takes 8 seconds)
driver.findElement(By.id("submit_button")).click();

The element exists. It’s just not there yet when Appium looks for it.

The fix:

Never use implicit waits alone. Use explicit waits for every interaction:

java
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(30));
WebElement submitButton = wait.until(
    ExpectedConditions.elementToBeClickable(By.id("submit_button"))
);
submitButton.click();

And never use Thread.sleep(). It’s not a fix—it’s a prayer.

java
// BAD: Works until it doesn't
Thread.sleep(5000);
driver.findElement(By.id("submit_button")).click();

// GOOD: Waits only as long as needed
wait.until(ExpectedConditions.elementToBeClickable(By.id("submit_button"))).click();

2. Cold Start vs Warm State

Your local emulator has been running for hours. The app is cached. The OS is warmed up.

CI starts a fresh emulator every time. Cold boot. Cold app. First-run dialogs you’ve never seen.

What happens:

Local: App launches in 3 seconds
CI: App launches in 15 seconds, shows "What's New" dialog, requests permissions

The fix:

Handle first-run states explicitly:

java
// Wait for app to be ready, not just launched
wait.until(ExpectedConditions.presenceOfElementLocated(By.id("main_screen")));

// Dismiss first-run dialogs if present
try {
    WebElement skipButton = driver.findElement(By.id("skip_intro"));
    skipButton.click();
} catch (NoSuchElementException e) {
    // Already past intro, continue
}

// Handle permission dialogs
try {
    WebElement allowButton = driver.findElement(By.id("com.android.permissioncontroller:id/permission_allow_button"));
    allowButton.click();
} catch (NoSuchElementException e) {
    // No permission dialog, continue
}

Better yet, use Appium capabilities to auto-grant permissions:

java
capabilities.setCapability("appium:autoGrantPermissions", true);

3. Screen Resolution and DPI Differences

Your local emulator is a Pixel 6 at 1080x2400. CI might be running a different AVD profile entirely.

What happens:

  • Tap coordinates that work locally miss the button in CI
  • Scroll distances don’t match
  • Elements are off-screen in CI but visible locally

The fix:

Never use coordinates. Use accessibility IDs and content descriptions:

java
// BAD: Coordinates break across screen sizes
TouchAction action = new TouchAction(driver);
action.tap(PointOption.point(540, 1200)).perform();

// GOOD: Works on any screen size
driver.findElement(By.accessibilityId("submit_button")).click();

If you must scroll, use element-based scrolling:

java
// BAD: Fixed scroll distance
driver.executeScript("mobile: scroll", ImmutableMap.of("direction", "down"));

// GOOD: Scroll until element is visible
wait.until(ExpectedConditions.visibilityOfElementLocated(
    MobileBy.AndroidUIAutomator("new UiScrollable(new UiSelector().scrollable(true)).scrollIntoView(new UiSelector().resourceId(\"target_element\"))")
));

4. Network Latency You Don’t See Locally

One team we worked with had a bizarre issue: tests passed 90% of the time in CI, failed 10% with timeouts. Everything worked locally.

The root cause? Their CI macOS VMs were in Wisconsin. Their Linux VMs (running the mock backend) were in Germany. 8,400 mile round trip. 250ms latency per request.

What happens:

  • API calls that take 50ms locally take 300ms in CI
  • Login flows timeout
  • Data doesn’t load before assertions run

The fix:

Add network-aware waits:

java
// Wait for data to load, not just the container
wait.until(ExpectedConditions.numberOfElementsToBeMoreThan(
    By.className("list_item"),
    0
));

// Or wait for loading indicators to disappear
wait.until(ExpectedConditions.invisibilityOfElementLocated(
    By.id("loading_spinner")
));

If you’re hitting external services, mock them:

java
// Use WireMock or similar for predictable responses
WireMockServer wireMockServer = new WireMockServer(8080);
wireMockServer.stubFor(get(urlEqualTo("/api/user"))
    .willReturn(aResponse()
        .withBody("{\"name\": \"Test User\"}")
        .withStatus(200)));

5. Appium Server Startup Race Condition

Locally, Appium is already running when you start tests. In CI, you start Appium and tests in the same script.

What happens:

bash
# CI script
appium &
npm test  # Tests start before Appium is ready

Your tests try to connect before Appium is listening. Connection refused.

The fix:

Wait for Appium to be ready:

bash
# Start Appium
appium &

# Wait for it to be ready
until curl -s http://localhost:4723/status > /dev/null; do
    echo "Waiting for Appium..."
    sleep 1
done

# Now run tests
npm test

Or in your test setup:

java
@BeforeClass
public void waitForAppium() throws Exception {
    int maxAttempts = 30;
    for (int i = 0; i < maxAttempts; i++) {
        try {
            new URL("http://localhost:4723/status").openConnection().connect();
            return;
        } catch (IOException e) {
            Thread.sleep(1000);
        }
    }
    throw new RuntimeException("Appium server not ready after 30 seconds");
}

6. Port Conflicts in Parallel Execution

Locally, you run one test at a time. CI runs multiple jobs in parallel. For distributed testing strategies, see Appium Distributed Devices Setup Guide.

What happens:

  • Job 1 starts Appium on port 4723
  • Job 2 starts Appium on port 4723
  • One of them fails with “port already in use”

Or worse: both connect to the same Appium instance and send commands to each other’s devices.

The fix:

Use dynamic port allocation:

java
// Find an available port
ServerSocket socket = new ServerSocket(0);
int appiumPort = socket.getLocalPort();
socket.close();

// Start Appium on that port
AppiumServiceBuilder builder = new AppiumServiceBuilder()
    .withIPAddress("127.0.0.1")
    .usingPort(appiumPort);

AppiumDriverLocalService service = builder.build();
service.start();

// Connect to that specific port
capabilities.setCapability("appium:systemPort", appiumPort);

For Android, also randomize the systemPort and chromedriverPort:

java
capabilities.setCapability("appium:systemPort", findAvailablePort());
capabilities.setCapability("appium:chromedriverPort", findAvailablePort());

7. Hardcoded Paths and Missing Environment Variables

Your tests reference /Users/yourname/app.apk. CI has no idea who “yourname” is.

What happens:

java
// Works on your machine
capabilities.setCapability("app", "/Users/john/projects/myapp/app.apk");

// Fails everywhere else

The fix:

Use relative paths and environment variables:

java
// Use relative path from project root
String appPath = System.getProperty("user.dir") + "/app/build/outputs/apk/debug/app-debug.apk";

// Or use environment variable
String appPath = System.getenv("APP_PATH");
if (appPath == null) {
    throw new RuntimeException("APP_PATH environment variable not set");
}

capabilities.setCapability("app", appPath);

In your CI config:

yaml
# GitHub Actions
env:
  APP_PATH: ${{ github.workspace }}/app/build/outputs/apk/debug/app-debug.apk

The Meta Problem: You Can’t Debug What You Can’t See

When tests fail locally, you see the screen. You watch the tap miss. You see the dialog that appeared unexpectedly.

In CI, you get a stack trace and your imagination.

The fix: Add observability to every test run.

Screenshot on Failure

java
@AfterMethod
public void captureScreenshot(ITestResult result) {
    if (result.getStatus() == ITestResult.FAILURE) {
        File screenshot = ((TakesScreenshot) driver).getScreenshotAs(OutputType.FILE);
        String filename = result.getName() + "_" + System.currentTimeMillis() + ".png";
        FileUtils.copyFile(screenshot, new File("screenshots/" + filename));
    }
}

Video Recording

For Appium 2.x with UiAutomator2:

java
// Start recording at test start
driver.executeScript("mobile: startScreenStreaming", ImmutableMap.of(
    "width", 1280,
    "height", 720,
    "bitRate", 2000000
));

// Stop and save at test end
driver.executeScript("mobile: stopScreenStreaming");

Comprehensive Logging

java
// Log page source on failure (helps debug element location issues)
@AfterMethod
public void logPageSource(ITestResult result) {
    if (result.getStatus() == ITestResult.FAILURE) {
        System.out.println("Page source at failure:");
        System.out.println(driver.getPageSource());
    }
}

The Prevention Checklist

Before merging any test to your main branch:

  • Run the test 10 times locally. If it fails once, it’s not ready.
  • Use explicit waits for every element interaction
  • No hardcoded paths or coordinates
  • No Thread.sleep() calls
  • Handle first-run states and permission dialogs
  • Capture screenshots and logs on failure
  • Test with a clean emulator state (cold boot)

The Real Solution: Same Device, Same Environment

Here’s the uncomfortable truth: most CI failures happen because CI and local are fundamentally different environments.

You’re running against an emulator in CI but a real device locally. Or a cloud device that’s 5,000 miles away. Or a shared device that some other test just left in a weird state.

The cleanest solution is to eliminate the environment difference entirely. Run your tests against the same devices, whether you’re debugging locally or running in CI.

That’s why we built DeviceLab. Your physical devices in your office, accessible from your laptop and your CI runners. Same device, same network, same environment. No tunnels needed—localhost actually means localhost.

No more “works on my machine.” Because it’s the same machine.


Running into CI failures with your device tests? DeviceLab connects your physical devices directly to your CI runners via P2P—same device locally and in CI, no environment differences. First device free forever.