Skip to content
Independent guides for QA & test automationRSSEditorial policy
QA Vibes

UI Test Automation Mistakes: Seven We Reproduced on a Demo Shop

A fixed wait that added up to five seconds to a test, an assertion that passed for text that doesn't exist, a click on the wrong product, and a dialog listener that froze the page. Each mistake is reproduced with real output, and each has a fix.

QA Vibes EditorialPublished Updated 8 minTested with Playwright 1.63.0, Chromium 153Revision history ↓

Key takeaways

  • A fixed three-second wait made a test 3 to 5 seconds slower and wasn't needed.
  • expect(locator).toBeTruthy() passed for text that isn't on the page.
  • A dialog listener that never dismisses the dialog froze the page until the test timed out.
  • A checkout test passed while every price was wrong; assert on what customers care about.
  • Mark known bugs with test.fail and a ticket instead of skipping the test.
Contents (12 sections)

Introduction

Most advice about UI automation mistakes is abstract: "avoid brittle selectors", "don't use sleeps". This article shows the mistakes instead. We reproduced each one on Sauce Demo, a public practice shop whose test accounts are deliberately slow or broken, and recorded what actually happened.

Examples use Playwright 1.63.0, but every mistake has an equivalent in Cypress and Selenium. The tests use this fixture, which logs in as the account named by the username option before the test starts:

import { test as base, expect, type Page } from "@playwright/test";
 
const PASSWORD = process.env.SAUCE_PASSWORD ?? "secret_sauce";
 
export async function logIn(page: Page, username: string) {
  await page.goto("/");
  await page.getByPlaceholder("Username").fill(username);
  await page.getByPlaceholder("Password").fill(PASSWORD);
  await page.getByRole("button", { name: "Login" }).click();
}
 
export const test = base.extend<{ username: string; shopper: Page }>({
  baseURL: "https://www.saucedemo.com",
  username: "standard_user",
 
  shopper: async ({ page, username }, use) => {
    await logIn(page, username);
    await expect(page).toHaveURL(/inventory\.html$/);
    await use(page);
  },
});
 
export { expect };

The config sets testIdAttribute: "data-test", because that's the attribute Sauce Demo uses.

Mistake 1: fixed waits "to be safe"

The performance_glitch_user account takes about five seconds to log in. A common reaction to a slow page is a fixed wait:

import { test, expect, logIn } from "./fixtures";
 
test("fixed three-second wait", async ({ page }) => {
  await logIn(page, "performance_glitch_user");
  await page.waitForTimeout(3000);
  expect(page.url()).toContain("inventory.html");
});

It passed, which is what makes the habit stick. But compare it with a version that simply asserts the result:

import { test, expect, logIn } from "./fixtures";
 
test("default assertion timeout", async ({ page }) => {
  await logIn(page, "performance_glitch_user");
  await expect(page).toHaveURL(/inventory\.html$/);
});
  ok 11 [chromium] › tests\shop\quirks.spec.ts:44:7 › performance_glitch_user login › default assertion timeout (6.4s)
  ok 12 [chromium] › tests\shop\quirks.spec.ts:49:7 › performance_glitch_user login › fixed three-second wait (11.5s)

The wait made the test 3 to 5 seconds slower: in two separate runs it took 9.4 and 11.5 seconds, against 6.4 seconds without it. And it wasn't even needed. Playwright's click already waited for the navigation the Login button started, so the page had loaded before the wait began. On a slower day, three seconds could also be too short, and then the test fails for a reason that has nothing to do with the product.

Fix: wait for the condition, not the clock. await expect(...) checks repeatedly and continues as soon as it's true. If one step is legitimately slow, raise the timeout for that assertion only: await expect(page).toHaveURL(/inventory\.html$/, { timeout: 15_000 }).

Mistake 2: an assertion that can never fail

import { test, expect } from "./fixtures";
 
test("truthy check on a locator passes for text that isn't there", async ({ shopper: page }) => {
  expect(page.getByText("This text is not on the page")).toBeTruthy();
});
  ok 2 [chromium] › tests\mistakes\mistakes.spec.ts:3:5 › truthy check on a locator passes for text that isn't there (1.1s)

It passed. A locator is a description of how to find an element, not the element itself, so the locator object always exists and is always truthy. The same trap catches toBeDefined() and not.toBeNull() on locators.

Fix: use a web-first assertion that checks the page: await expect(page.getByText("…")).toBeVisible(). Then break the test on purpose once, by changing the text, and confirm that it fails.

Mistake 3: a missing await

import { test, expect } from "./fixtures";
 
test("missing await on a web-first assertion", async ({ shopper: page }) => {
  expect(page.getByText("This text is not on the page")).toBeVisible();
});

This time Playwright caught it:

Error: expect(locator).toBeVisible() failed
 
Locator: getByText('This text is not on the page')
Expected: visible
Error: element(s) not found
 
Call log:
  - Expect "toBeVisible" getByText('This text is not on the page') with timeout 5000ms
  - waiting for getByText('This text is not on the page')
  - Test ended.

Look at the last line of the call log: "Test ended." The assertion was still waiting when the test finished, so Playwright reported it. That only happened because it was the last line of the test. With more steps after it, the next action would start immediately without waiting for the check, and the result of the assertion would arrive at an unpredictable moment, or not affect the step it was meant to guard.

Fix: await every Playwright action and every expect on a locator or page. The @typescript-eslint/no-floating-promises and playwright/missing-playwright-await lint rules catch this before the test runs.

Mistake 4: picking elements by position

Every product card has an "Add to cart" button, so .first() is tempting. Here the test sorts the list first, as a user or a future product change might:

import { test, expect } from "./fixtures";
 
test("first() adds whatever is first after sorting", async ({ shopper: page }) => {
  await page.getByRole("combobox", { name: "Sort products" }).selectOption("Name (Z to A)");
  await page.getByRole("button", { name: "Add to cart" }).first().click();
  await page.getByTestId("shopping-cart-link").click();
  await expect(page.getByTestId("inventory-item-name")).toHaveText("Sauce Labs Backpack");
});
Error: expect(locator).toHaveText(expected) failed
 
Locator:  getByTestId('inventory-item-name')
Expected: "Sauce Labs Backpack"
Received: "Test.allTheThings() T-Shirt (Red)"

The test added a different product. Here the final assertion caught it; in a test that only checked "cart has 1 item", it would have passed while testing the wrong thing.

Fix: find the element by what identifies it, then act inside it:

import { test, expect } from "./fixtures";
 
test("adds the backpack by name", async ({ shopper: page }) => {
  await page
    .getByTestId("inventory-item")
    .filter({ hasText: "Sauce Labs Backpack" })
    .getByRole("button", { name: "Add to cart" })
    .click();
  await expect(page.getByTestId("shopping-cart-badge")).toHaveText("1");
});

Mistake 5: a dialog listener that doesn't handle the dialog

The error_user account shows a browser alert when you sort products. We added a listener to log the message:

import { test, expect } from "./fixtures";
 
test.use({ username: "error_user" });
 
test("listener that never dismisses the dialog", async ({ shopper: page }) => {
  test.setTimeout(15_000);
  page.on("dialog", (dialog) => console.log(`dialog: ${dialog.message()}`));
  await page.getByRole("combobox", { name: "Sort products" }).selectOption("Price (low to high)");
  await expect(page.getByTestId("inventory-item-price").first()).toHaveText("$7.99");
});
dialog: Sorting is broken! This error has been reported to Backtrace.
 
Test timeout of 15000ms exceeded.
Error: locator.selectOption: Test timeout of 15000ms exceeded.
Call log:
  - attempting select option action
    - waiting for element to be visible and enabled

The page froze until the test timed out. When there's no dialog listener, Playwright dismisses dialogs automatically. As soon as you add one, handling the dialog becomes your job, and an open alert blocks the page.

The same test without the listener didn't hang, and it failed for the right reason, the actual bug:

Error: expect(locator).toHaveText(expected) failed
 
Locator:  getByTestId('inventory-item-price').first()
Expected: "$7.99"
Received: "$29.99"

Fix: if you listen for dialogs, always call dialog.accept() or dialog.dismiss() inside the listener, and await it.

Mistake 6: asserting too little

The visual_user account shows wrong prices: they changed every time we loaded the page (one load showed $82.16, $72.42, $66.47). A checkout test that only checks the confirmation heading passed anyway:

  ok  3 [chromium] › tests\shop\checkout.spec.ts:49:7 › visual_user › checkout completes (3.9s)
  x   8 [chromium] › tests\shop\checkout.spec.ts:61:7 › visual_user › prices match the catalog (7.4s)

Only the second test, which compares the displayed prices with the catalog, noticed:

import { test, expect } from "./fixtures";
 
test.use({ username: "visual_user" });
 
test("prices match the catalog", async ({ shopper: page }) => {
  await expect(page.getByTestId("inventory-item-price")).toHaveText([
    "$29.99", "$9.99", "$15.99", "$49.99", "$7.99", "$15.99",
  ]);
});

Fix: for each journey, write down what a customer would be most upset to see wrong (price, total, the item they chose) and assert on that, not only on reaching the last page.

Mistake 7: skipping a test because of a known bug

On problem_user, typing a last name at checkout overwrites the first name (we wrote it up in How to Write High-Quality Bug Reports). The tempting response to a red test is test.skip. A skipped test says nothing, and nobody notices when the bug is fixed or when it gets worse.

test.fail records the known bug instead:

import { test, expect } from "./fixtures";
 
test.use({ username: "problem_user" });
 
test("last name is kept at checkout", async ({ shopper: page }) => {
  test.fail(true, "Known bug: typing in Last Name overwrites First Name");
  await page.getByRole("button", { name: "Add to cart" }).first().click();
  await page.getByTestId("shopping-cart-link").click();
  await page.getByRole("button", { name: "Checkout" }).click();
  await page.getByRole("textbox", { name: "First Name" }).fill("Ada");
  await page.getByRole("textbox", { name: "Last Name" }).fill("Lovelace");
 
  await expect(page.getByRole("textbox", { name: "Last Name" })).toHaveValue("Lovelace");
});

The test runs every time. While the bug exists, the assertion fails and the test counts as passing, because failure is expected. When someone fixes the bug, the assertion passes, and Playwright reports the test as failed because it was expected to fail. That's the signal to remove the annotation and close the ticket. Put the ticket ID in the description so the link is never lost.

Catch these before they reach CI

Mistakes 1, 2, and 3 are mechanical, so a tool can find them. Paste a test into our free Playwright test reviewer: it flags fixed waits, assertions that can never fail, missing awaits, and 17 other problems, and generates the matching eslint-plugin-playwright config. Every example marked as a mistake on this page is flagged by it; every fixed example passes. For Cypress suites, the Cypress test reviewer catches the same kinds of problems there, such as cy.wait(3000) and assertions chained after .click(), and generates an eslint-plugin-cypress config.

Mistakes 4 to 7 are about judgment: what to assert, how to identify elements, how to handle known bugs. Those belong in code review.

Checklist

  • No waitForTimeout, Thread.sleep, or cy.wait(<number>). Wait for a condition.
  • Every assertion on a locator is awaited and web-first (toBeVisible, toHaveText), never toBeTruthy.
  • Each new test was broken once on purpose to prove it can fail.
  • Elements are found by role, label, text, or test ID, not by position.
  • Every dialog listener accepts or dismisses the dialog.
  • Journeys assert on the values customers care about, not only on reaching the end.
  • Known bugs use test.fail with a ticket ID, not test.skip.

Conclusion

The dangerous mistakes aren't the ones that fail loudly. They're the ones that pass: a wait that hides a slow page, an assertion that checks nothing, a click on the wrong product, a checkout test that ignores the prices. Reproduce your own suite's assumptions the way this article does. Break each test once, and see whether it tells you.

Sources and further reading

Tools mentioned

PlaywrightUI AutomationOpen source
BrowserStackDevice CloudFree trial
AllureReporting & MonitoringOpen source

Links go to each tool’s official site. How we choose and link tools

Revision history

Rewritten: every mistake is now reproduced on the Sauce Demo practice shop with the real test output and a working fix.
Moved four sections that followed the conclusion into the body of the article.
Revised during a site-wide content audit.
First published.

Spotted a mistake? Report it — corrections land here.

Written and reviewed by

QA Vibes Editorial

Articles are written and reviewed by practicing QA and automation engineers. Every article lists its sources and shows when it was last updated.

Check your own tests

Playwright test reviewer

Paste a test to find fixed waits, missing awaits, and assertions that can never fail.