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 enabledThe 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, orcy.wait(<number>). Wait for a condition. - Every assertion on a locator is awaited and web-first (
toBeVisible,toHaveText), nevertoBeTruthy. - 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.failwith a ticket ID, nottest.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.