---
title: "Why Your UI Tests Flake, and the Fix for Web and Mobile"
author: "Harry"
published: 2026-10-31T04:45:51.526Z
updated: 2026-10-31T05:06:17.709Z
category: "QA & Testing"
tags: ["qa-skills-path"]
canonical: "https://fxblog.dev/posts/flaky-ui-tests"
---

# Why Your UI Tests Flake, and the Fix for Web and Mobile

## Key takeaways

- If a check can be proven over HTTP, it belongs in the API suite, not in a browser test.
- Prefer test ids and role-based locators; avoid absolute XPath, generated CSS classes and index-based selection.
- Replace fixed sleeps with assertions that wait for a condition, such as Playwright's toHaveText.
- Create each test's data through the API so no two tests share mutable records.
- On mobile, one Accessibility ID shared by the iOS and Android builds lets one locator find the element on both.
- Quarantine a flaky test the day it flakes, with an owner, instead of retrying the whole suite.

> **TL;DR** A flaky UI test teaches the team that red can mean "just re-run it", and then real bugs ride through on the retry. Flaky UI tests mostly come from brittle locators, fixed sleeps and shared data. Use role and test-id locators, assertions that wait for a condition, data set up through the API, one Accessibility ID on both phones, and quarantine a flaky test the day it flakes.
>
> **Do this:** Search your suite for `waitForTimeout` and `sleep(`, and replace one with an assertion that waits for what you actually need to see.

Part 3 covered the API, and it was never the problem: BUG-311 lives in the page, which shows €27.00 after the API has removed the code. So the next test has to wait for that total in a browser, without `sleep(5)`, and then run the same checkout on two phones.

The repo records an AI assistant writing this:

```ts
await page.fill('#code', 'SAVE10');
await page.click('.btn-apply');
await page.waitForTimeout(3000);
```

Three seconds is too long on a fast day and too short on a slow one. The selectors break on the next redesign.

**The QA Skills Path, in seven parts:**

1. **The QA Skills Path for 2026: What to Learn, in What Order**
2. **Test Design Techniques: Pick the Few Tests That Find Bugs**
3. **Test Below the UI: Your First API Tests in Python**
4. **Why Your UI Tests Flake, and the Fix for Web and Mobile** (you are here)
5. **Green Tests, Broken Checkout: The Checks Functional Tests Miss**
6. **Quality Gates in CI: Make Your Tests Block a Bad Merge**
7. **AI in Testing: Use It Without Trusting It, and Test AI Features**

## Locators that survive a redesign

First, keep the browser suite small. It is the slowest, most fragile layer, and the repo's rule is that a check you can prove over HTTP does not belong in a browser test. The ShopLite checkout flow (add to cart, apply code, pay) belongs here. The boundary and decision-table cases from part 2 belong in part 3's API suite.

For the tests that stay, use the highest locator on this list that the page offers:

1. A test id: `page.getByTestId('order-total')`. Ask developers to add them.
2. Role and accessible name: `page.getByRole('button', { name: 'Apply' })`.
3. A stable `id`, `name` or `aria-label`, if it isn't generated.
4. Short CSS anchored to a stable parent.
5. Never: absolute XPath, generated classes like `.css-1x2y3z`, or `nth(4)`.

> **Tip**
>
> A role locator is also a free accessibility check. If Playwright can't find the "Apply" button by its role and name, a screen reader can't either.

## Arrange through the API, wait in the browser

This test from the repo's test-data guide fixes both other causes at once. It creates its own discount code through the API, so it shares nothing with any other test. Then it clicks through the page and waits for the total, not for a clock:

```ts
const code = `QA${Date.now()}`;
const res = await request.post('/api/test-support/discount-codes', {
  data: { code, percent: 10, minSubtotal: 50 },
  headers: { Authorization: `Bearer ${process.env.QA_ADMIN_TOKEN}` },
});
expect(res.ok()).toBeTruthy();

await page.goto('/products/mug');
await page.getByLabel('Quantity').fill('2');            // 2 × €30.00
await page.getByRole('button', { name: 'Add to cart' }).click();
await page.goto('/checkout');
await page.getByLabel('Discount code').fill(code);
await page.getByRole('button', { name: 'Apply' }).click();
await expect(page.getByTestId('order-total')).toHaveText('€54.00');
```

`toHaveText` retries until the text matches or time runs out, so it passes as soon as the page is ready. For BUG-311, the same test then sets the quantity to 1 and expects the bug report's expected total. Today it fails, for the right reason.

> **Warning**
>
> The test-support endpoint must be switched off in production. Check that with a test too.

## The same rules on a phone

Mobile has its own locator order, and the top of it works on both platforms: an Accessibility ID. Android maps it to `content-desc`, iOS to `accessibilityIdentifier`. If both teams use the same string, one line finds the button on both phones:

```python
login_button = self.driver.find_element(
    by=AppiumBy.ACCESSIBILITY_ID,
    value="login_screen_submit_btn"
)
```

XPath is the trap: Appium first serialises the whole native view into an XML string, which is slow, and the path breaks when a developer wraps the button in a new container. The repo's parallel example runs the suite on two emulators, each with its own Appium server:

Two devices, two workers, no shared state

```mermaid
flowchart LR
  X[pytest -n 2] --> W0[worker gw0]
  X --> W1[worker gw1]
  W0 --> S0[Appium :4723] --> D0[emulator-5554]
  W1 --> S1[Appium :4725] --> D1[emulator-5556]
```

## When a test flakes anyway

The repo's policy, in order:

1. Quarantine it the same day: out of the gating suite, ticketed with an owner.
2. Find the cause. The starter records a trace only when a test is retried, so green runs stay fast and every flaky failure comes with a replay of the DOM and network (`npx playwright show-trace`).
3. Fix it and restore it.

Blanket auto-retry is not on the list. Some flakes are real race conditions in the product.

## Try it

```sh
cd qa-skills-path/04-web-ui-testing/playwright-starter
npx playwright test --ui
npx playwright codegen demo.playwright.dev/todomvc
```

The starter is TypeScript; Playwright also runs from Python, which is how part 6's pipeline runs it.

The ShopLite checkout is now green on three browsers and two phones, and red only when it should be. But green here means it works for one user, with a mouse, against services that never change. Would it hold 2,000 users, a customer on a keyboard, or a pricing team renaming one field? Part 5, **Green Tests, Broken Checkout: The Checks Functional Tests Miss**, adds those checks.

## Read this with your AI

```text
Read the post "Why Your UI Tests Flake, and the Fix for Web and Mobile".
1. I'll paste one of my UI tests. Point out every locator, wait and shared-data problem in it.
2. Rewrite it with role or test-id locators and web-first assertions, setting its data up through an API call.
3. Ask me which of my tests should move down to the API layer.
```

> **Success**
>
> You can write UI tests that fail only for real reasons, on the web and on a phone. Next: **Green Tests, Broken Checkout: The Checks Functional Tests Miss**.

## FAQ

### What causes flaky UI tests?

Most often brittle locators that break on a redesign, fixed waits that are too short on a slow run, and tests that share data with each other. Each has a direct fix: stable locators, waits on conditions and data created per test.

### Why is XPath slow in Appium?

Mobile operating systems don't query XML natively, so Appium has to serialise the whole native view into an XML string before it can run the XPath. It is slow, and it breaks when the layout nesting changes.

### Is retrying failed tests a good fix for flakiness?

Only as a short, logged measure while the cause is fixed. Blanket retries hide real race conditions in the product. Quarantine the flaky test, give it an owner, fix it and restore it.
