Skip to content
fx-blog

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

Flaky UI tests teach a team to ignore red. Fix the three usual causes: brittle locators, fixed sleeps and shared data. Use role and test-id locators, web-first assertions, API setup, Accessibility IDs on mobile, and same-day quarantine.

Harry5 min readIntermediate
  • Verification
MarkdownSave

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.
Contents

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:

tsThe test that can't be trusted
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).

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:

tsArrange through the API, act and assert through the UI
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.

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:

pythonAppium, both platforms
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:

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

Try it
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

textPaste into your AI
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.

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.

Sources

  1. QA Skills Path: Web UI Test Automation Guide
  2. QA Skills Path: Playwright starter
  3. QA Skills Path: Appium locator strategy
  4. Playwright documentation: Locators
  5. Playwright documentation: Trace viewer

Was this useful?

Only you and the owner see your answer and rating. Send private feedback

Enjoyed it? Support fx-blog with a coffee.

Comments

Oldest first

No comments yet. Start the conversation.

You sign in first; what you typed stays here. · 0 / 2,000